|
| 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 where:/filter: shape the generated ORM 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. Through the ORM, you write `where:` — the codegen layer translates it to `filter:` at the GraphQL layer: |
| 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 | +| `column reference "name" is ambiguous` from a procedure | A PL/pgSQL parameter clashes with a column of the target table. Rename params with `p_` prefix (see `provision_spatial_relation` in constructive-db). | |
| 177 | +| `Missing required changes … metaschema:schemas/metaschema_modules_public/schema` on `pgpm deploy` | `pgpm.plan` entry uses bare `schemas/…` for a cross-module dep. Prefix with the owning module: `metaschema-modules:schemas/metaschema_modules_public/schema`. | |
| 178 | +| Smart tag not appearing on `field.smart_tags` | `spatial_relation` row inserted but the `@spatialRelation` key got clobbered by an unrelated writer. The trigger preserves other keys, but confirm no other writer is overwriting the whole smart-tags jsonb on the same column. | |
| 179 | +| `where: { myRelation: { some: {} } }` returns everything | Using `some:` as if it meant "no filter". `some: {}` means "at least one related target row exists". Use `every:` or drop the relation from the where clause if you want unfiltered. | |
| 180 | +| Radius search returns wrong rows on a `geometry` column | SRID units, not meters. Cast to `::geography` on ingest or switch the column codec. | |
| 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