Skip to content

Commit e3fa889

Browse files
committed
docs(skills): add graphile-postgis skill for @spatialRelation + RelationSpatial
1 parent 1b3af3c commit e3fa889

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

.agents/skills/constructive-monorepo-setup/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ For detailed codegen configuration, see the [constructive-graphql skill](https:/
6464

6565
Graphile plugins live under `graphile/*`. For PostGIS plugin development and testing, see the [constructive-graphql skill](https://github.com/constructive-io/constructive-skills/tree/main/.agents/skills/constructive-graphql) — specifically the [search-postgis.md reference](https://github.com/constructive-io/constructive-skills/tree/main/.agents/skills/constructive-graphql/references/search-postgis.md).
6666

67+
For exposing cross-table PostGIS queries to the ORM/GraphQL layer via `@spatialRelation` smart tags and the `RelationSpatial` blueprint node (point-in-polygon, radius search, etc. without sending GeoJSON to the client), see the [graphile-postgis skill](../graphile-postgis/SKILL.md) in this repo.
68+
6769
## Testing
6870

6971
See [AGENTS.md](../../AGENTS.md) for the testing framework selection guide. For comprehensive database testing patterns, see the [constructive-testing skill](https://github.com/constructive-io/constructive-skills/tree/main/.agents/skills/constructive-testing).
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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

Comments
 (0)