|
| 1 | +--- |
| 2 | +name: graphile-postgis |
| 3 | +description: How to expose cross-table PostGIS queries to the ORM/GraphQL layer without shipping GeoJSON to the client. Covers the @spatialRelation smart tag (8 operators, parametric distance), the RelationSpatial blueprint node, and the ORM `where:` shape the generated client consumes. |
| 4 | +--- |
| 5 | + |
| 6 | +# graphile-postgis |
| 7 | + |
| 8 | +Use this skill when a task mentions PostGIS, spatial queries, geometry/geography columns, or "the client is pulling GeoJSON and filtering in JS". The answer is almost always **declare a spatial relation and query it through the ORM `where:` tree** — not adding a custom resolver and not sending polygons over the wire. |
| 9 | + |
| 10 | +## When to reach for this |
| 11 | + |
| 12 | +- "Find clinics inside a county / points inside a polygon / things near a location" |
| 13 | +- An agentic-DB session is shipping GeoJSON to the client to compute point-in-polygon or distance on the browser |
| 14 | +- A PR adds a new custom GraphQL field that takes a polygon as input and runs `ST_*` inline |
| 15 | +- You're about to write a per-pair SQL function like `clinics_in_county(county_id)` to paper over a missing relation |
| 16 | + |
| 17 | +In all of those cases: add a `@spatialRelation` tag on the owning column (or a `RelationSpatial` entry in a blueprint) and use the generated `where:` field. |
| 18 | + |
| 19 | +## The primitive: `@spatialRelation` |
| 20 | + |
| 21 | +Declared on the owning geometry/geography column. Turns into a first-class virtual relation: a new field on the owning table's generated `where` input that runs an `EXISTS (…)` subquery using a PostGIS predicate. One line of SQL, and the ORM/GraphQL schema pick it up automatically. |
| 22 | + |
| 23 | +### Tag grammar |
| 24 | + |
| 25 | +``` |
| 26 | +@spatialRelation <relationName> <targetRef> <operator> [<paramName>] |
| 27 | +``` |
| 28 | + |
| 29 | +- `<relationName>` — name of the emitted `where:` field on the owner. Preserved as-written. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. |
| 30 | +- `<targetRef>` — `table.column` or `schema.table.column`. |
| 31 | +- `<operator>` — one of the eight PG-native snake_case tokens below. |
| 32 | +- `<paramName>` — required iff the operator is parametric (only `st_dwithin` today; use `distance`). |
| 33 | + |
| 34 | +Both sides must be `geometry` or `geography`, and the same codec. Mixing is rejected at schema build. |
| 35 | + |
| 36 | +### Operator reference (v1) |
| 37 | + |
| 38 | +| Tag | PostGIS | Parametric? | Symmetric? | |
| 39 | +|---|---|---|---| |
| 40 | +| `st_contains` | `ST_Contains(A, B)` | no | no (A ⊇ B) | |
| 41 | +| `st_within` | `ST_Within(A, B)` | no | no (A ⊆ B) | |
| 42 | +| `st_covers` | `ST_Covers(A, B)` | no | no | |
| 43 | +| `st_coveredby` | `ST_CoveredBy(A, B)` | no | no | |
| 44 | +| `st_intersects` | `ST_Intersects(A, B)` | no | yes | |
| 45 | +| `st_equals` | `ST_Equals(A, B)` | no | yes | |
| 46 | +| `st_bbox_intersects` | `A && B` (infix) | no | yes | |
| 47 | +| `st_dwithin` | `ST_DWithin(A, B, d)` | **yes (`d`)** | yes | |
| 48 | + |
| 49 | +Tag reads left-to-right as **"owner op target"**. Emitted SQL is exactly `ST_<op>(owner, target[, distance])`. For directional operators, flipping owner/target inverts the result set — put the tag on the column whose type makes the sentence true (`clinics.location st_within counties.geom`). |
| 50 | + |
| 51 | +## Two ways to declare one |
| 52 | + |
| 53 | +### 1. Raw SQL comment (lowest level) |
| 54 | + |
| 55 | +```sql |
| 56 | +COMMENT ON COLUMN telemedicine_clinics.location IS |
| 57 | + E'@spatialRelation county counties.geom st_within'; |
| 58 | +``` |
| 59 | + |
| 60 | +Fine for prototyping or hand-written migrations. Stacks — separate tags with `\n`: |
| 61 | + |
| 62 | +```sql |
| 63 | +COMMENT ON COLUMN telemedicine_clinics.location IS |
| 64 | + E'@spatialRelation county counties.geom st_within\n' |
| 65 | + '@spatialRelation intersectingCounty counties.geom st_intersects\n' |
| 66 | + '@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance'; |
| 67 | +``` |
| 68 | + |
| 69 | +### 2. Blueprint `RelationSpatial` node (preferred in constructive-db) |
| 70 | + |
| 71 | +This is the declarative path that `construct_blueprint` dispatches on. The metaschema trigger emits the smart tag for you — don't write the `COMMENT ON COLUMN` by hand if the column is managed by a blueprint. |
| 72 | + |
| 73 | +```json |
| 74 | +{ |
| 75 | + "$type": "RelationSpatial", |
| 76 | + "source_table": "clinics", |
| 77 | + "source_field": "location", |
| 78 | + "target_table": "counties", |
| 79 | + "target_field": "geom", |
| 80 | + "name": "containing_county", |
| 81 | + "operator": "st_within" |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +With a parametric operator, add `param_name`: |
| 86 | + |
| 87 | +```json |
| 88 | +{ |
| 89 | + "$type": "RelationSpatial", |
| 90 | + "source_table": "telemedicine_clinics", |
| 91 | + "source_field": "location", |
| 92 | + "target_table": "telemedicine_clinics", |
| 93 | + "target_field": "location", |
| 94 | + "name": "nearby_clinic", |
| 95 | + "operator": "st_dwithin", |
| 96 | + "param_name": "distance" |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +- Both fields must already exist — `RelationSpatial` is metadata-only, it doesn't create columns or junction tables. |
| 101 | +- **One direction per tag.** If you want the inverse, write a second `RelationSpatial` on the other side (e.g. `counties.contained_clinic` with `st_contains`). The system does not auto-generate symmetric entries. |
| 102 | +- **Idempotent.** Re-running the blueprint with the same `(source_table, name)` is a no-op — `provision_spatial_relation` returns the existing id without modifying the row. |
| 103 | +- Registered node type: [`graphql/node-type-registry/src/relation/relation-spatial.ts`](../../../graphql/node-type-registry/src/relation/relation-spatial.ts). |
| 104 | +- Dispatcher: `metaschema_modules_public.construct_blueprint` in `constructive-db` routes `$type=RelationSpatial` to `provision_spatial_relation`. |
| 105 | + |
| 106 | +## Querying through the ORM (`where:`) |
| 107 | + |
| 108 | +The generated field lives in the owning table's `where` input. You always write `where:` — that's the shape the ORM exposes. |
| 109 | + |
| 110 | +```ts |
| 111 | +// "Clinics inside any county named 'Bay County'" — one round trip, no GeoJSON on the wire |
| 112 | +await orm.telemedicineClinic |
| 113 | + .findMany({ |
| 114 | + select: { id: true, name: true }, |
| 115 | + where: { county: { some: { name: { equalTo: 'Bay County' } } } }, |
| 116 | + }) |
| 117 | + .execute(); |
| 118 | +``` |
| 119 | + |
| 120 | +### `some` / `every` / `none` |
| 121 | + |
| 122 | +Every 2-arg relation exposes all three: |
| 123 | + |
| 124 | +- `some: { … }` — at least one related target row passes the inner where. |
| 125 | +- `none: { … }` — no related target row passes. |
| 126 | +- `every: { … }` — every related target row passes (vacuously true on empty target set). |
| 127 | +- `some: {}` means "at least one related target row exists, any row" — rows whose column has zero matches on the other side are excluded. |
| 128 | + |
| 129 | +### Parametric (`st_dwithin`) |
| 130 | + |
| 131 | +Adds a **required** `distance: Float!` next to `some`/`every`/`none`. It parametrises the join, not the inner clause: |
| 132 | + |
| 133 | +```ts |
| 134 | +await orm.telemedicineClinic |
| 135 | + .findMany({ |
| 136 | + where: { |
| 137 | + nearbyClinic: { |
| 138 | + distance: 5000, |
| 139 | + some: { specialty: { equalTo: 'pediatrics' } }, |
| 140 | + }, |
| 141 | + }, |
| 142 | + }) |
| 143 | + .execute(); |
| 144 | +``` |
| 145 | + |
| 146 | +Distance units: **meters** for `geography`, **SRID coordinate units** for `geometry` (degrees for SRID 4326 — cast to `::geography` on ingest if you want meter-based radius). |
| 147 | + |
| 148 | +### Composition |
| 149 | + |
| 150 | +Spatial relations live in the same `where:` tree as scalars and compose with `and`/`or`/`not` the same way a foreign-key relation would. See the plugin README for AND/OR/NOT examples. |
| 151 | + |
| 152 | +## Self-relations |
| 153 | + |
| 154 | +Owner and target column can be the same. The plugin emits a self-exclusion predicate so a row never matches itself: |
| 155 | + |
| 156 | +- Single-column PK: `other.<pk> <> self.<pk>` |
| 157 | +- Composite PK: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)` |
| 158 | + |
| 159 | +Tables without a primary key are rejected at schema-build. One consequence: `st_dwithin` with `distance: 0` on a self-relation returns zero rows. |
| 160 | + |
| 161 | +## GIST indexes |
| 162 | + |
| 163 | +Without a GIST index on the target column, spatial predicates fall back to seq scans. The plugin emits a non-fatal build warning when one is missing; act on it. |
| 164 | + |
| 165 | +```sql |
| 166 | +CREATE INDEX ON telemedicine_clinics USING GIST(location); |
| 167 | +CREATE INDEX ON counties USING GIST(geom); |
| 168 | +``` |
| 169 | + |
| 170 | +Opt a column out with `@spatialRelationSkipIndexCheck` on that column. |
| 171 | + |
| 172 | +## Debugging checklist |
| 173 | + |
| 174 | +| Symptom | Likely cause | |
| 175 | +|---|---| |
| 176 | +| `where: { myRelation: { some: {} } }` excludes rows you expected to see | `some: {}` means "at least one related target row exists". Rows whose owner column has zero matches on the target side are correctly excluded. If you want unfiltered, drop the relation from the where clause. | |
| 177 | +| Radius search returns wrong rows on a `geometry` column | `distance` is SRID units, not meters, for `geometry`. Cast to `::geography` on ingest for meter-based radius, or pick the SRID whose units you want. | |
| 178 | +| Schema-build warning about missing GIST index | Target column has no GIST index. Add one, or set `@spatialRelationSkipIndexCheck` if you know what you're doing (small table, prototype). | |
| 179 | +| Schema-build error "cannot mix geometry and geography" | Owner and target columns have different codecs. Pick one — cast on ingest. | |
| 180 | +| Schema-build error on a self-relation | Owner table has no primary key. Self-relations need a PK so a row can be excluded from matching itself. Add one. | |
| 181 | + |
| 182 | +## Scope guardrails |
| 183 | + |
| 184 | +- **Don't** add a custom GraphQL resolver that takes a polygon as input to compute the relation — use a spatial relation. |
| 185 | +- **Don't** write per-pair helper functions (`clinics_in_county(uuid)`). The plugin is the general case. |
| 186 | +- **Don't** auto-generate inverse relations. One direction per tag — write a second entry if you need both sides. |
| 187 | +- **Don't** mix `geometry` and `geography` across a single relation. Cast on ingest. |
| 188 | +- **Don't** use a spatial relation in `orderBy`. It's where-only. For measurement fields you want to sort on, use the `geometry-fields` / `measurement-fields` plugins. |
| 189 | + |
| 190 | +## Pointers |
| 191 | + |
| 192 | +- Plugin source: [`graphile/graphile-postgis/src/plugins/PostgisSpatialRelationsPlugin.ts`](../../../graphile/graphile-postgis/src/plugins/PostgisSpatialRelationsPlugin.ts) |
| 193 | +- Plugin README (full reference + FAQ): [`graphile/graphile-postgis/README.md`](../../../graphile/graphile-postgis/README.md) |
| 194 | +- Blueprint node type: [`graphql/node-type-registry/src/relation/relation-spatial.ts`](../../../graphql/node-type-registry/src/relation/relation-spatial.ts) |
| 195 | +- Metaschema table, trigger, provisioner: `constructive-io/constructive-db`, `metaschema_public.spatial_relation` (PR #840) + `metaschema_modules_public.provision_spatial_relation` + `construct_blueprint` dispatcher (PR #844) |
| 196 | +- E2E test suite (66 live-PG cases): `graphql/orm-test/__tests__/postgis-spatial.test.ts` |
| 197 | +- Unit test suite (218 structural cases): `graphile/graphile-postgis/__tests__/` |
0 commit comments