@@ -16,6 +16,31 @@ PostGIS support for PostGraphile v5.
1616
1717Automatically 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'"
89186await 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
109235await 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