Skip to content

Commit 875fb9c

Browse files
authored
Merge pull request #995 from constructive-io/feat/postgis-spatial-relations-deep-docs
docs(graphile-postgis): deep spatial-relations docs; lead with client-side GeoJSON problem
2 parents b988681 + 7403786 commit 875fb9c

1 file changed

Lines changed: 275 additions & 59 deletions

File tree

graphile/graphile-postgis/README.md

Lines changed: 275 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,31 @@ PostGIS support for PostGraphile v5.
1616

1717
Automatically generates GraphQL types for PostGIS geometry and geography columns, including GeoJSON scalar types, dimension-aware interfaces, and subtype-specific fields (coordinates, points, rings, etc.).
1818

19+
## The problem
20+
21+
Working with PostGIS from an app is usually painful for one specific
22+
reason: **you end up juggling large amounts of GeoJSON across tables on
23+
the client**. You fetch every clinic as GeoJSON, fetch every county
24+
polygon as GeoJSON, and then — in the browser — loop through them
25+
yourself to figure out which clinic sits inside which county. Every
26+
query, every count, every page of results becomes a client-side
27+
geometry problem.
28+
29+
An ORM generated automatically from your database schema can't fix this
30+
on its own. It sees a `geometry` column and stops there — it has no
31+
idea that "clinics inside a county" is the question you actually want
32+
to ask. Foreign keys tell it how tables relate by equality; nothing
33+
tells it how tables relate *spatially*.
34+
35+
So we added the missing primitive: a **spatial relation**. You declare,
36+
on the database column, that `clinics.location` is "inside"
37+
`counties.geom`, and the generated GraphQL schema + ORM gain a
38+
first-class `where: { county: { some: { … } } }` shape that runs the
39+
join server-side, in one SQL query, using PostGIS and a GIST index. No
40+
GeoJSON on the wire, no client-side geometry, and the relation composes
41+
with the rest of your `where:` the same way a foreign-key relation
42+
would.
43+
1944
## Installation
2045

2146
```bash
@@ -40,70 +65,171 @@ const preset = {
4065
- Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
4166
- Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.)
4267
- Geography-aware field naming (longitude/latitude/height instead of x/y/z)
43-
- Cross-table spatial filters via `@spatialRelation` smart tags (see below)
68+
- Cross-table spatial relations via `@spatialRelation` smart tags (see below)
4469
- Graceful degradation when PostGIS is not installed
4570

4671
## Spatial relations via smart tags
4772

48-
`PostgisSpatialRelationsPlugin` lets you declare a cross-table or
49-
self-relation whose join predicate is a PostGIS spatial function. The
50-
plugin emits a first-class relation + filter field on the owning codec's
51-
`Filter` type that compiles to an `EXISTS (…)` subquery using the
52-
declared operator.
73+
You declare a **spatial relation** with a `@spatialRelation` smart tag
74+
on a `geometry` or `geography` column. The plugin turns that tag into a
75+
virtual relation on the owning table: a new field on the table's
76+
generated `where` input that runs a PostGIS join server-side. You write
77+
one line of SQL once; the generated ORM and GraphQL schema pick it up
78+
automatically.
79+
80+
### At a glance
81+
82+
**Before** — GeoJSON juggling on the client:
83+
84+
```ts
85+
// 1. Pull every clinic's location as GeoJSON.
86+
const clinics = await gql(`{ telemedicineClinics { nodes { id name location } } }`);
87+
// 2. Pull the polygon of the one county you care about.
88+
const { geom } = await gql(`{ countyByName(name: "Bay County") { geom } }`);
89+
// 3. Run point-in-polygon on the client for each clinic.
90+
const inBay = clinics.telemedicineClinics.nodes.filter((c) =>
91+
booleanPointInPolygon(c.location, geom),
92+
);
93+
```
5394

54-
### Tag grammar
95+
**After** — server-side, one trip:
5596

5697
```sql
57-
COMMENT ON COLUMN <owner_table>.<owner_col> IS
58-
E'@spatialRelation <relation_name> <target_table>.<target_col> <operator> [<param_name>]';
98+
COMMENT ON COLUMN telemedicine_clinics.location IS
99+
E'@spatialRelation county counties.geom st_within';
59100
```
60101

61-
- `<relation_name>` — user-chosen name for the generated field (e.g. `county`)
62-
- `<target_table>.<target_col>` — target geometry/geography column; also
63-
accepts `<schema>.<table>.<col>`
64-
- `<operator>` — PG-native `st_*` function name; resolved at schema build
65-
time against `pg_proc`
66-
- `<param_name>` — required only for parametric operators
67-
(currently `st_dwithin`)
102+
```ts
103+
const inBay = await orm.telemedicineClinic
104+
.findMany({
105+
select: { id: true, name: true },
106+
where: { county: { some: { name: { equalTo: 'Bay County' } } } },
107+
})
108+
.execute();
109+
```
68110

69-
### Supported operators (v1)
111+
No polygon crosses the wire. The join happens in a single
112+
`EXISTS (…)` subquery on the server, using a PostGIS predicate on the
113+
two columns.
70114

71-
| Operator | PostGIS function | Kind | Arity |
72-
|---|---|---|---|
73-
| `st_contains` | `ST_Contains` | function | 2 |
74-
| `st_within` | `ST_Within` | function | 2 |
75-
| `st_covers` | `ST_Covers` | function | 2 |
76-
| `st_coveredby` | `ST_CoveredBy` | function | 2 |
77-
| `st_intersects` | `ST_Intersects` | function | 2 |
78-
| `st_equals` | `ST_Equals` | function | 2 |
79-
| `st_bbox_intersects` | `&&` | infix | 2 |
80-
| `st_dwithin` | `ST_DWithin` | function | 3 (parametric) |
115+
### Declaring a relation
81116

82-
### Filter shapes
117+
#### Tag grammar
83118

84-
2-arg operators use the familiar `some` / `every` / `none` shape.
119+
```
120+
@spatialRelation <relationName> <targetRef> <operator> [<paramName>]
121+
```
122+
123+
- `<relationName>` — user-chosen name for the new field on the owning
124+
table's `where` input. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. The
125+
name is preserved as-written — `county` stays `county`,
126+
`nearbyClinic` stays `nearbyClinic`.
127+
- `<targetRef>``table.column` (defaults to the owning column's
128+
schema) or `schema.table.column` (for references in another schema,
129+
e.g. a shared `geo` schema).
130+
- `<operator>` — one of the eight PG-native snake_case tokens listed in
131+
[Operator reference](#operator-reference).
132+
- `<paramName>` — required if and only if the operator is parametric.
133+
Today that's `st_dwithin`, which needs a parameter name (typically
134+
`distance`).
85135

86-
Through the generated ORM (`where:`):
136+
Both sides of the relation must be `geometry` or `geography`, and they
137+
must share the **same** base codec — you cannot mix `geometry` and
138+
`geography`.
139+
140+
#### Multiple relations on one column
141+
142+
Stack tags. Each line becomes its own field on the owning table's
143+
`where` input:
144+
145+
```sql
146+
COMMENT ON COLUMN telemedicine_clinics.location IS
147+
E'@spatialRelation county counties.geom st_within\n'
148+
'@spatialRelation intersectingCounty counties.geom st_intersects\n'
149+
'@spatialRelation coveringCounty counties.geom st_coveredby\n'
150+
'@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
151+
```
152+
153+
The four relations above all exist in the integration test suite and
154+
can be used in the same query. Two relations on the same owner cannot
155+
share a `<relationName>`.
156+
157+
### Operator reference
158+
159+
| Tag operator | PostGIS function | Parametric? | Symmetric? | Typical use |
160+
|---|---|---|---|---|
161+
| `st_contains` | `ST_Contains(A, B)` | no | **no** (A contains B) | polygon containing a point / line / polygon |
162+
| `st_within` | `ST_Within(A, B)` | no | **no** (A within B) | point-in-polygon, line-in-polygon |
163+
| `st_covers` | `ST_Covers(A, B)` | no | **no** | like `st_contains` but boundary-inclusive |
164+
| `st_coveredby` | `ST_CoveredBy(A, B)` | no | **no** | dual of `st_covers` |
165+
| `st_intersects` | `ST_Intersects(A, B)`| no | yes | any overlap at all |
166+
| `st_equals` | `ST_Equals(A, B)` | no | yes | exact geometry match |
167+
| `st_bbox_intersects` | `A && B` (infix) | no | yes | fast bounding-box prefilter |
168+
| `st_dwithin` | `ST_DWithin(A, B, d)`| **yes** (`d`) | yes | radius / proximity search |
169+
170+
> The tag reads left-to-right as **"owner op target"**, and the emitted
171+
> SQL is exactly `ST_<op>(owner_col, target_col[, distance])`. For
172+
> symmetric operators (`st_intersects`, `st_equals`, `st_dwithin`,
173+
> `st_bbox_intersects`) argument order doesn't matter. For directional
174+
> operators (`st_within`, `st_contains`, `st_covers`, `st_coveredby`),
175+
> flipping the two columns inverts the result set. Rule of thumb: put
176+
> the relation on the column whose type makes the sentence true —
177+
> `clinics.location st_within counties.geom` reads naturally; the
178+
> reverse does not.
179+
180+
### Using the generated `where` shape
181+
182+
#### Through the ORM
87183

88184
```ts
185+
// "Clinics inside any county named 'Bay County'"
89186
await orm.telemedicineClinic
90187
.findMany({
91188
select: { id: true, name: true },
92-
where: { county: { some: { name: { equalTo: 'California County' } } } },
189+
where: { county: { some: { name: { equalTo: 'Bay County' } } } },
93190
})
94191
.execute();
95192
```
96193

97-
Or equivalently at the GraphQL layer (`filter:`):
194+
#### Through GraphQL
195+
196+
The connection argument is `where:` at the GraphQL layer too — same
197+
name, same tree. Only the generated input **type** keeps the word
198+
"Filter" in it (e.g. `TelemedicineClinicFilter`):
98199

99200
```graphql
100-
telemedicineClinics(
101-
filter: { county: { some: { name: { equalTo: "California County" } } } }
102-
) { nodes { id name } }
201+
{
202+
telemedicineClinics(
203+
where: { county: { some: { name: { equalTo: "Bay County" } } } }
204+
) {
205+
nodes { id name }
206+
}
207+
}
103208
```
104209

105-
`st_dwithin` takes its distance at the relation level (it parametrises
106-
the join, not the joined row):
210+
#### `some` / `every` / `none`
211+
212+
Every 2-argument relation exposes three modes. They mean what you'd
213+
expect, backed by `EXISTS` / `NOT EXISTS`:
214+
215+
- `some: { <where clause> }` — the row matches if at least one related
216+
target row passes the where clause.
217+
- `none: { <where clause> }` — the row matches if no related target row
218+
passes.
219+
- `every: { <where clause> }` — the row matches when every related
220+
target row passes (i.e. "no counter-example exists"). Note that
221+
`every: {}` on an empty target set is vacuously true.
222+
223+
An empty inner clause (`some: {}`) means "at least one related target
224+
row exists, any row will do" — so for `@spatialRelation county …
225+
st_within`, clinics whose point is inside zero counties are correctly
226+
excluded.
227+
228+
#### Parametric operators (`st_dwithin` + `distance`)
229+
230+
Parametric relations add a **required** `distance: Float!` field next
231+
to `some` / `every` / `none`. The distance parametrises the join
232+
itself, not the inner `some:` clause:
107233

108234
```ts
109235
await orm.telemedicineClinic
@@ -119,37 +245,127 @@ await orm.telemedicineClinic
119245
.execute();
120246
```
121247

122-
```graphql
123-
telemedicineClinics(
124-
filter: {
125-
nearbyClinic: {
126-
distance: 5000
127-
some: { specialty: { equalTo: "pediatrics" } }
128-
}
129-
}
130-
) { nodes { id name } }
248+
Distance units follow PostGIS semantics:
249+
250+
| Owner codec | `distance` units |
251+
|---|---|
252+
| `geography` | meters |
253+
| `geometry` | SRID coordinate units (degrees for SRID 4326) |
254+
255+
#### Composition with `and` / `or` / `not` and scalar where clauses
256+
257+
Spatial relations live in the same `where:` tree as every scalar
258+
predicate and compose the same way:
259+
260+
```ts
261+
// AND — Bay County clinics that are cardiology
262+
where: {
263+
and: [
264+
{ county: { some: { name: { equalTo: 'Bay County' } } } },
265+
{ specialty: { equalTo: 'cardiology' } },
266+
],
267+
}
268+
269+
// OR — Bay County clinics OR the one named "LA Pediatrics"
270+
where: {
271+
or: [
272+
{ county: { some: { name: { equalTo: 'Bay County' } } } },
273+
{ name: { equalTo: 'LA Pediatrics' } },
274+
],
275+
}
276+
277+
// NOT — clinics that are NOT in Bay County
278+
where: {
279+
not: { county: { some: { name: { equalTo: 'Bay County' } } } },
280+
}
131281
```
132282

133-
Distance units follow PostGIS semantics: **meters** for `geography`
134-
columns, **SRID coordinate units** for `geometry` columns.
283+
Inside `some` / `every` / `none`, the inner where clause is the target
284+
table's full `where` input — every scalar predicate the target exposes
285+
is available.
135286

136287
### Self-relations
137288

138-
When `<owner_table>` equals `<target_table>`, the plugin emits an
139-
automatic self-exclusion predicate so a row is never "related to
140-
itself":
289+
When the owner and target columns are the same column, the plugin
290+
emits a self-exclusion predicate so a row never matches itself:
291+
292+
- Single-column primary key: `other.<pk> <> self.<pk>`
293+
- Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`
294+
295+
Tables without a primary key are rejected at schema build — a
296+
self-relation there would match every row against itself.
141297

142-
- Single-column PK: `other.id <> self.id`
143-
- Composite PK: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`
298+
One concrete consequence: with `st_dwithin`, a self-relation at
299+
`distance: 0` matches zero rows, because the only candidate at
300+
distance 0 is the row itself, which is excluded.
144301

145-
Self-relations on tables without a primary key are rejected at schema
146-
build time.
302+
### Generated SQL shape
303+
304+
```sql
305+
SELECT ...
306+
FROM <owner_table> self
307+
WHERE EXISTS (
308+
SELECT 1
309+
FROM <target_table> other
310+
WHERE ST_<op>(self.<owner_col>, other.<target_col>[, <distance>])
311+
AND <self-exclusion for self-relations>
312+
AND <nested some/every/none conditions>
313+
);
314+
```
147315

148-
### GIST index warning
316+
The EXISTS lives inside the owner's generated `where` input, so it
317+
composes with pagination, ordering, and the rest of the outer plan.
318+
`st_bbox_intersects` compiles to infix `&&` rather than a function call.
319+
PostGIS functions are called with whichever schema PostGIS is installed
320+
in, so non-`public` installs work without configuration.
321+
322+
### Indexing
323+
324+
Spatial predicates without a GIST index fall back to sequential scans,
325+
which is almost never what you want. The plugin checks your target
326+
columns at schema-build time and emits a non-fatal warning when a GIST
327+
index is missing, including the recommended `CREATE INDEX ... USING
328+
GIST(...)` in the warning text.
329+
330+
```sql
331+
CREATE INDEX ON telemedicine_clinics USING GIST(location);
332+
CREATE INDEX ON counties USING GIST(geom);
333+
```
149334

150-
At schema build time the plugin emits a non-fatal warning when the
151-
target geometry/geography column has no GIST index — spatial predicates
152-
are typically unusable without one.
335+
If a particular column is a known exception (e.g. a small prototype
336+
table), set `@spatialRelationSkipIndexCheck` on that column to suppress
337+
the warning.
338+
339+
### `geometry` vs `geography`
340+
341+
Pick one side of a relation and stick with it — mixing codecs across
342+
the two sides is rejected at schema build. `geography` distances are
343+
always meters; `geometry` distances follow the SRID's native units
344+
(degrees for SRID 4326, which is rarely what you want for radius
345+
searches). If you need meter-based proximity on a `geometry` column,
346+
cast on ingest (`::geography`) rather than mixing codecs across a
347+
single relation.
348+
349+
### FAQ
350+
351+
- **"Why doesn't `some: {}` return every row?"** — because `some` means
352+
"at least one related target row exists". Rows whose column has no
353+
match on the other side are correctly excluded.
354+
- **"Why does `distance: 0` on a self-relation return nothing?"** — the
355+
self-exclusion predicate removes the row's match with itself, so at
356+
distance 0 no candidates remain.
357+
- **"Can I reuse a `relationName` across tables?"** — yes; uniqueness
358+
is scoped to the owning table.
359+
- **"Can I declare the relation from the polygon side instead of the
360+
point side?"** — yes. Flip owner and target and use the inverse
361+
operator (`st_contains` in place of `st_within`). Same rows, same
362+
SQL, different `where` location.
363+
- **"Does this work with PostGIS installed in a non-`public` schema?"**
364+
— yes.
365+
- **"Can I use a spatial relation in `orderBy` or on a connection
366+
field?"** — no; it's a where-only construct. Use PostGIS measurement
367+
fields (see the `geometry-fields` / `measurement-fields` plugins) for
368+
values you want to sort on.
153369

154370
## License
155371

0 commit comments

Comments
 (0)