Skip to content

Commit 7bef6a6

Browse files
authored
Merge pull request #999 from constructive-io/devin/1776484752-postgis-spatial-skills
docs(skills): add graphile-postgis skill for @spatialRelation + RelationSpatial
2 parents 1b3af3c + a09456f commit 7bef6a6

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 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

Comments
 (0)