graphile-postgis 2.11.0 → 2.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,34 +12,27 @@
12
12
  <a href="https://www.npmjs.com/package/graphile-postgis"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-postgis%2Fpackage.json"/></a>
13
13
  </p>
14
14
 
15
- PostGIS support for PostGraphile v5.
16
-
17
- 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.).
18
-
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.
15
+ A full PostGIS integration for PostGraphile v5. Turns every
16
+ `geometry` / `geography` column into a typed, introspectable GraphQL
17
+ field with GeoJSON scalars, subtype-specific fields, measurement
18
+ helpers, spatial filters, aggregates, and cross-table **spatial
19
+ relations** and wires the whole thing into the generated ORM so you
20
+ can query spatial data the same way you query anything else.
21
+
22
+ ## Table of contents
23
+
24
+ - [Installation](#installation)
25
+ - [Usage](#usage)
26
+ - [Features at a glance](#features-at-a-glance)
27
+ - [GeoJSON scalar and typed geometry columns](#geojson-scalar-and-typed-geometry-columns)
28
+ - [Dimension-aware interfaces and subtype fields](#dimension-aware-interfaces-and-subtype-fields)
29
+ - [Measurement fields (`length`, `area`, `perimeter`)](#measurement-fields-length-area-perimeter)
30
+ - [Transformation fields (`centroid`, `bbox`, `numPoints`)](#transformation-fields-centroid-bbox-numpoints)
31
+ - [Per-column spatial filters](#per-column-spatial-filters)
32
+ - [PostGIS aggregate fields](#postgis-aggregate-fields)
33
+ - [Spatial relations (`@spatialRelation`)](#spatial-relations-spatialrelation)
34
+ - [Graceful degradation](#graceful-degradation)
35
+ - [License](#license)
43
36
 
44
37
  ## Installation
45
38
 
@@ -49,98 +42,279 @@ npm install graphile-postgis
49
42
 
50
43
  ## Usage
51
44
 
52
- ```typescript
45
+ ```ts
53
46
  import { GraphilePostgisPreset } from 'graphile-postgis';
54
47
 
55
48
  const preset = {
56
- extends: [GraphilePostgisPreset]
49
+ extends: [GraphilePostgisPreset],
57
50
  };
58
51
  ```
59
52
 
60
- ## Features
53
+ The preset bundles every plugin listed below. You can also import each
54
+ plugin individually (`PostgisCodecPlugin`, `PostgisRegisterTypesPlugin`,
55
+ `PostgisGeometryFieldsPlugin`, `PostgisMeasurementFieldsPlugin`,
56
+ `PostgisTransformationFieldsPlugin`, `PostgisAggregatePlugin`,
57
+ `PostgisSpatialRelationsPlugin`, …) if you prefer à-la-carte.
58
+
59
+ ## Features at a glance
60
+
61
+ - **GeoJSON scalar** for input and output on every `geometry` /
62
+ `geography` column.
63
+ - **Full type hierarchy** — `Geometry` / `Geography` interfaces,
64
+ dimension-aware interfaces (`XY`, `XYZ`, `XYM`, `XYZM`), and
65
+ concrete subtype objects (`Point`, `LineString`, `Polygon`,
66
+ `MultiPoint`, `MultiLineString`, `MultiPolygon`,
67
+ `GeometryCollection`).
68
+ - **Subtype-specific accessors** — `x` / `y` / `z` on points
69
+ (`longitude` / `latitude` / `height` on `geography`), `points` on
70
+ line strings, `exterior` / `interiors` on polygons, etc.
71
+ - **Measurement fields** — `length`, `area`, `perimeter`, computed
72
+ geodesically from GeoJSON on the server.
73
+ - **Transformation fields** — `centroid`, `bbox`, `numPoints`.
74
+ - **Per-column spatial filters** — every PostGIS topological
75
+ predicate (`intersects`, `contains`, `within`, `dwithin`, …) and
76
+ every bounding-box operator (`bboxIntersects2D`, `bboxContains`,
77
+ `bboxLeftOf`, …) wired into the generated `where:` shape.
78
+ - **Aggregate fields** — `stExtent`, `stUnion`, `stCollect`,
79
+ `stConvexHull` exposed on every aggregate type for a geometry
80
+ column.
81
+ - **Spatial relations** — a `@spatialRelation` smart tag that
82
+ declares cross-table spatial joins as first-class relations (ORM +
83
+ GraphQL), backed by PostGIS predicates and GIST indexes.
84
+ - **Auto-detects PostGIS** in any schema (not just `public`) and
85
+ **degrades gracefully** when the extension isn't installed.
86
+
87
+ ## GeoJSON scalar and typed geometry columns
88
+
89
+ A `geometry` / `geography` column is exposed as a typed GraphQL object
90
+ with a `geojson` field carrying the GeoJSON payload. You select it the
91
+ same way you select any nested object:
61
92
 
62
- - GeoJSON scalar type for input/output
63
- - GraphQL interfaces for geometry and geography base types
64
- - Dimension-aware interfaces (XY, XYZ, XYM, XYZM)
65
- - Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
66
- - Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.)
67
- - Geography-aware field naming (longitude/latitude/height instead of x/y/z)
68
- - Cross-table spatial relations via `@spatialRelation` smart tags (see below)
69
- - Graceful degradation when PostGIS is not installed
93
+ ```ts
94
+ // Read a location column as GeoJSON through the ORM
95
+ const result = await orm.location
96
+ .findMany({
97
+ select: { name: true, geom: { select: { geojson: true } } },
98
+ where: { name: { equalTo: 'Central Park Cafe' } },
99
+ })
100
+ .execute();
101
+ ```
70
102
 
71
- ## Spatial relations via smart tags
103
+ Input values (mutations, filters) accept GeoJSON directly — any of
104
+ `Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`,
105
+ `MultiPolygon`, or `GeometryCollection`.
72
106
 
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.
107
+ ## Dimension-aware interfaces and subtype fields
79
108
 
80
- ### At a glance
109
+ Each concrete subtype is its own GraphQL object with fields that make
110
+ sense for that subtype:
81
111
 
82
- **Before** GeoJSON juggling on the client:
112
+ | Subtype | Notable fields |
113
+ |---------------------|------------------------------------------------------|
114
+ | `Point` | `x` / `y` / `z` (or `longitude` / `latitude` / `height` on `geography`) |
115
+ | `LineString` | `points: [Point!]` |
116
+ | `Polygon` | `exterior: LineString`, `interiors: [LineString!]` |
117
+ | `MultiPoint` | `points: [Point!]` |
118
+ | `MultiLineString` | `lineStrings: [LineString!]` |
119
+ | `MultiPolygon` | `polygons: [Polygon!]` |
120
+ | `GeometryCollection`| `geometries: [Geometry!]` |
83
121
 
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
- );
122
+ On top of those, every geometry type also exposes the `XY` / `XYZ` /
123
+ `XYM` / `XYZM` dimension interfaces so a client can ask for
124
+ coordinates without branching on the specific subtype.
125
+
126
+ ```graphql
127
+ # Example GraphQL selection on a Polygon column
128
+ {
129
+ counties {
130
+ nodes {
131
+ name
132
+ geom {
133
+ geojson
134
+ exterior {
135
+ points { x y }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
93
141
  ```
94
142
 
95
- **After** server-side, one trip:
143
+ ## Measurement fields (`length`, `area`, `perimeter`)
96
144
 
97
- ```sql
98
- COMMENT ON COLUMN telemedicine_clinics.location IS
99
- E'@spatialRelation county counties.geom st_within';
145
+ Subtype-appropriate measurement fields are added automatically, using
146
+ geodesic math on the GeoJSON payload (Haversine for distance,
147
+ spherical excess for area, WGS84 / SRID 4326 assumed):
148
+
149
+ | Subtype | Fields added |
150
+ |-------------------------------------------------|-------------------------|
151
+ | `LineString`, `MultiLineString` | `length` |
152
+ | `Polygon`, `MultiPolygon` | `area`, `perimeter` |
153
+
154
+ Values are `Float` in meters (length / perimeter) and square meters
155
+ (area).
156
+
157
+ ```graphql
158
+ {
159
+ counties {
160
+ nodes {
161
+ name
162
+ geom { area perimeter }
163
+ }
164
+ }
165
+ routes {
166
+ nodes {
167
+ id
168
+ path { length }
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
+ For exact server-side PostGIS measurements (e.g. `ST_Area` with a
175
+ specific SRID projection), define a computed column in SQL — these
176
+ fields are client-facing conveniences, not a replacement for
177
+ projection-aware analytics.
178
+
179
+ ## Transformation fields (`centroid`, `bbox`, `numPoints`)
180
+
181
+ Every geometry object also gets three lightweight transformation
182
+ fields:
183
+
184
+ - `centroid: [Float!]` — coordinate-mean centroid.
185
+ - `bbox: [Float!]` — `[minX, minY, maxX, maxY]` bounding box.
186
+ - `numPoints: Int!` — total coordinate count.
187
+
188
+ ```graphql
189
+ {
190
+ parks {
191
+ nodes {
192
+ name
193
+ geom { centroid bbox numPoints }
194
+ }
195
+ }
196
+ }
100
197
  ```
101
198
 
199
+ For `ST_Transform` / `ST_Buffer` / `ST_Simplify` / `ST_MakeValid`,
200
+ which all take parameters, declare a custom SQL function or computed
201
+ column — the object-level transformation fields intentionally stick
202
+ to parameter-free helpers.
203
+
204
+ ## Per-column spatial filters
205
+
206
+ Every PostGIS predicate is registered as a filter operator on the
207
+ column's `where:` entry, both for `geometry` and `geography` codecs:
208
+
209
+ - Topological: `intersects`, `contains`, `containsProperly`, `within`,
210
+ `covers`, `coveredBy`, `touches`, `crosses`, `disjoint`, `overlaps`,
211
+ `equals`, `orderingEquals`.
212
+ - Distance: `dwithin` (parametric).
213
+ - 2D / ND bounding-box: `bboxIntersects2D`, `bboxIntersectsND`,
214
+ `bboxContains`, `bboxEquals`.
215
+ - Directional bounding-box: `bboxLeftOf`, `bboxRightOf`, `bboxAbove`,
216
+ `bboxBelow`, `bboxOverlapsOrLeftOf`, `bboxOverlapsOrRightOf`,
217
+ `bboxOverlapsOrAbove`, `bboxOverlapsOrBelow`.
218
+
219
+ All of them take GeoJSON as input — the plugin wraps the value with
220
+ `ST_GeomFromGeoJSON(...)::<codec>` before it hits PostgreSQL, so
221
+ `Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`,
222
+ `MultiPolygon`, and `GeometryCollection` inputs all work uniformly.
223
+
102
224
  ```ts
103
- const inBay = await orm.telemedicineClinic
225
+ // Cities whose location is inside a polygon
226
+ const inBayArea = await orm.citiesGeom
227
+ .findMany({
228
+ select: { id: true, name: true },
229
+ where: { loc: { intersects: BAY_AREA_POLYGON } },
230
+ })
231
+ .execute();
232
+
233
+ // Cities whose bbox sits strictly west of a reference point
234
+ const westOfCentral = await orm.citiesGeom
104
235
  .findMany({
105
236
  select: { id: true, name: true },
106
- where: { county: { some: { name: { equalTo: 'Bay County' } } } },
237
+ where: { loc: { bboxLeftOf: { type: 'Point', coordinates: [-100.0, 37.77] } } },
107
238
  })
108
239
  .execute();
109
240
  ```
110
241
 
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.
242
+ ## PostGIS aggregate fields
243
+
244
+ On every aggregate type for a geometry / geography column, the plugin
245
+ adds four SQL-level aggregate fields that run in-database:
246
+
247
+ - `stExtent` — `ST_Extent(...)` — bounding box of all rows as a
248
+ GeoJSON Polygon.
249
+ - `stUnion` — `ST_Union(...)` — union of all rows as GeoJSON.
250
+ - `stCollect` — `ST_Collect(...)` — collect into a
251
+ `GeometryCollection`.
252
+ - `stConvexHull` — `ST_ConvexHull(ST_Collect(...))` — convex hull of
253
+ all rows as a GeoJSON Polygon.
254
+
255
+ ```graphql
256
+ {
257
+ citiesGeoms {
258
+ aggregates {
259
+ stExtent { loc { geojson } }
260
+ stConvexHull { loc { geojson } }
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ ## Spatial relations (`@spatialRelation`)
267
+
268
+ Spatial relations are the plugin's cross-table feature: a way to
269
+ declare, directly on a database column, that two tables are related
270
+ *spatially* — "clinics inside a county", "parcels touching a road",
271
+ "events within 5 km of a user" — and get a first-class relation in
272
+ the generated ORM and GraphQL schema for free.
273
+
274
+ ### Why a dedicated primitive
275
+
276
+ Without this, spatial joins from an app usually devolve into shipping
277
+ GeoJSON across the wire: every clinic as GeoJSON, every county
278
+ polygon as GeoJSON, a point-in-polygon loop on the client. An
279
+ auto-generated ORM can't do better on its own — it sees a `geometry`
280
+ column and stops there. Foreign keys describe equality; nothing
281
+ describes *containment* or *proximity*.
282
+
283
+ A `@spatialRelation` tag declares that `clinics.location` is
284
+ "within" `counties.geom`, and the generated schema + ORM gain a
285
+ first-class `where: { county: { some: { … } } }` shape that runs the
286
+ join server-side, in one SQL query, using PostGIS and a GIST index.
287
+ No GeoJSON on the wire; the relation composes with the rest of your
288
+ `where:` the same way a foreign-key relation would.
114
289
 
115
290
  ### Declaring a relation
116
291
 
117
- #### Tag grammar
292
+ Put the tag on the owning geometry / geography column:
293
+
294
+ ```sql
295
+ COMMENT ON COLUMN telemedicine_clinics.location IS
296
+ E'@spatialRelation county counties.geom st_within';
297
+ ```
298
+
299
+ Tag grammar:
118
300
 
119
301
  ```
120
302
  @spatialRelation <relationName> <targetRef> <operator> [<paramName>]
121
303
  ```
122
304
 
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`.
305
+ - `<relationName>` — user-chosen name for the new field on the
306
+ owner's `where` input. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`.
127
307
  - `<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`).
135
-
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`.
308
+ schema) or `schema.table.column`.
309
+ - `<operator>` one of the eight PG-native snake_case tokens listed
310
+ below.
311
+ - `<paramName>` — required if the operator is parametric. Today that
312
+ is only `st_dwithin` (use `distance`).
139
313
 
140
- #### Multiple relations on one column
314
+ Both sides must be `geometry` or `geography`, and share the **same**
315
+ codec — mixing is rejected at schema build.
141
316
 
142
- Stack tags. Each line becomes its own field on the owning table's
143
- `where` input:
317
+ Stack multiple relations on one column by separating tags with `\n`:
144
318
 
145
319
  ```sql
146
320
  COMMENT ON COLUMN telemedicine_clinics.location IS
@@ -150,115 +324,98 @@ COMMENT ON COLUMN telemedicine_clinics.location IS
150
324
  '@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
151
325
  ```
152
326
 
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
327
  ### Operator reference
158
328
 
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
329
+ | Tag operator | PostGIS function | Parametric? | Symmetric? | Typical use |
330
+ |----------------------|-----------------------|----------------|-----------------------|--------------------------------------------------|
331
+ | `st_contains` | `ST_Contains(A, B)` | no | **no** (A contains B) | polygon containing a point / line / polygon |
332
+ | `st_within` | `ST_Within(A, B)` | no | **no** (A within B) | point-in-polygon, line-in-polygon |
333
+ | `st_covers` | `ST_Covers(A, B)` | no | **no** | like `st_contains`, boundary-inclusive |
334
+ | `st_coveredby` | `ST_CoveredBy(A, B)` | no | **no** | dual of `st_covers` |
335
+ | `st_intersects` | `ST_Intersects(A, B)` | no | yes | any overlap at all |
336
+ | `st_equals` | `ST_Equals(A, B)` | no | yes | exact geometry match |
337
+ | `st_bbox_intersects` | `A && B` (infix) | no | yes | fast bounding-box prefilter |
338
+ | `st_dwithin` | `ST_DWithin(A, B, d)` | **yes** (`d`) | yes | radius / proximity search |
339
+
340
+ The tag reads left-to-right as **"owner op target"**, and the emitted
341
+ SQL is exactly `ST_<op>(owner_col, target_col[, distance])`. For
342
+ directional operators (`st_within`, `st_contains`, `st_covers`,
343
+ `st_coveredby`), flipping the two columns inverts the result set; put
344
+ the relation on the column whose type makes the sentence true.
345
+
346
+ ### Using a spatial relation from the ORM
347
+
348
+ Every 2-argument relation exposes `some` / `every` / `none` against
349
+ the target table's full `where` input:
183
350
 
184
351
  ```ts
185
- // "Clinics inside any county named 'Bay County'"
352
+ // "Clinics inside LA County" st_within, one SQL query, no GeoJSON on the wire.
186
353
  await orm.telemedicineClinic
187
354
  .findMany({
188
355
  select: { id: true, name: true },
189
- where: { county: { some: { name: { equalTo: 'Bay County' } } } },
356
+ where: { county: { some: { name: { equalTo: 'LA County' } } } },
190
357
  })
191
358
  .execute();
192
- ```
193
-
194
- #### Through GraphQL
195
359
 
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`):
360
+ // "Clinics NOT in NYC County" negation via `none`.
361
+ await orm.telemedicineClinic
362
+ .findMany({
363
+ select: { id: true },
364
+ where: { county: { none: { name: { equalTo: 'NYC County' } } } },
365
+ })
366
+ .execute();
199
367
 
200
- ```graphql
201
- {
202
- telemedicineClinics(
203
- where: { county: { some: { name: { equalTo: "Bay County" } } } }
204
- ) {
205
- nodes { id name }
206
- }
207
- }
368
+ // "Any clinic that sits inside at least one county" — empty inner
369
+ // clause still excludes points that fall outside every county.
370
+ await orm.telemedicineClinic
371
+ .findMany({
372
+ select: { id: true, name: true },
373
+ where: { county: { some: {} } },
374
+ })
375
+ .execute();
208
376
  ```
209
377
 
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:
378
+ Parametric relations (today: `st_dwithin`) add a required `distance`
379
+ field alongside `some` / `every` / `none`:
233
380
 
234
381
  ```ts
382
+ // "Clinics within 10 SRID units of any cardiology clinic" — self-relation
383
+ // with parametric distance; a row never matches itself.
235
384
  await orm.telemedicineClinic
236
385
  .findMany({
237
386
  select: { id: true, name: true },
238
387
  where: {
239
388
  nearbyClinic: {
240
- distance: 5000,
241
- some: { specialty: { equalTo: 'pediatrics' } },
389
+ distance: 10.0,
390
+ some: { specialty: { equalTo: 'cardiology' } },
242
391
  },
243
392
  },
244
393
  })
245
394
  .execute();
246
395
  ```
247
396
 
248
- Distance units follow PostGIS semantics:
397
+ ### Using a spatial relation from GraphQL
249
398
 
250
- | Owner codec | `distance` units |
251
- |---|---|
252
- | `geography` | meters |
253
- | `geometry` | SRID coordinate units (degrees for SRID 4326) |
399
+ The same tree, same field names — just under `where:` on the
400
+ connection argument:
254
401
 
255
- #### Composition with `and` / `or` / `not` and scalar where clauses
402
+ ```graphql
403
+ {
404
+ telemedicineClinics(
405
+ where: { county: { some: { name: { equalTo: "Bay County" } } } }
406
+ ) {
407
+ nodes { id name }
408
+ }
409
+ }
410
+ ```
411
+
412
+ ### Composition
256
413
 
257
414
  Spatial relations live in the same `where:` tree as every scalar
258
- predicate and compose the same way:
415
+ predicate and compose identically:
259
416
 
260
417
  ```ts
261
- // AND — Bay County clinics that are cardiology
418
+ // Bay County clinics that are cardiology
262
419
  where: {
263
420
  and: [
264
421
  { county: { some: { name: { equalTo: 'Bay County' } } } },
@@ -266,7 +423,7 @@ where: {
266
423
  ],
267
424
  }
268
425
 
269
- // OR — Bay County clinics OR the one named "LA Pediatrics"
426
+ // Bay County clinics OR the one named "LA Pediatrics"
270
427
  where: {
271
428
  or: [
272
429
  { county: { some: { name: { equalTo: 'Bay County' } } } },
@@ -274,30 +431,24 @@ where: {
274
431
  ],
275
432
  }
276
433
 
277
- // NOT — clinics that are NOT in Bay County
434
+ // Clinics NOT in Bay County
278
435
  where: {
279
436
  not: { county: { some: { name: { equalTo: 'Bay County' } } } },
280
437
  }
281
438
  ```
282
439
 
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.
286
-
287
- ### Self-relations
440
+ ### Self-relations and self-exclusion
288
441
 
289
442
  When the owner and target columns are the same column, the plugin
290
443
  emits a self-exclusion predicate so a row never matches itself:
291
444
 
292
- - Single-column primary key: `other.<pk> <> self.<pk>`
293
- - Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`
445
+ - Single-column primary key: `other.<pk> <> self.<pk>`.
446
+ - Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`.
447
+ - Tables without a primary key are rejected at schema build.
294
448
 
295
- Tables without a primary key are rejected at schema build — a
296
- self-relation there would match every row against itself.
297
-
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.
449
+ One consequence: with `st_dwithin`, a self-relation at `distance: 0`
450
+ matches zero rows, because the only candidate at distance 0 is the
451
+ row itself — and it is excluded.
301
452
 
302
453
  ### Generated SQL shape
303
454
 
@@ -313,19 +464,20 @@ WHERE EXISTS (
313
464
  );
314
465
  ```
315
466
 
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.
467
+ The `EXISTS` sits inside the owner's generated `where` input, so it
468
+ composes cleanly with pagination, ordering, and the rest of the outer
469
+ plan. `st_bbox_intersects` compiles to infix `&&` rather than a
470
+ function call. PostGIS functions are called with whichever schema
471
+ PostGIS is installed in, so non-`public` installs work without extra
472
+ configuration.
321
473
 
322
474
  ### Indexing
323
475
 
324
476
  Spatial predicates without a GIST index fall back to sequential scans,
325
477
  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.
478
+ columns at schema-build time and emits a non-fatal warning when a
479
+ GIST index is missing, including the recommended `CREATE INDEX
480
+ USING GIST(...)` in the warning text:
329
481
 
330
482
  ```sql
331
483
  CREATE INDEX ON telemedicine_clinics USING GIST(location);
@@ -333,8 +485,8 @@ CREATE INDEX ON counties USING GIST(geom);
333
485
  ```
334
486
 
335
487
  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.
488
+ table), set `@spatialRelationSkipIndexCheck` on that column to
489
+ suppress the warning.
338
490
 
339
491
  ### `geometry` vs `geography`
340
492
 
@@ -348,24 +500,31 @@ single relation.
348
500
 
349
501
  ### FAQ
350
502
 
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.
503
+ - **"Why doesn't `some: {}` return every row?"** — because `some`
504
+ means "at least one related target row exists". Rows whose column
505
+ has no match on the other side are correctly excluded.
506
+ - **"Why does `distance: 0` on a self-relation return nothing?"** —
507
+ the self-exclusion predicate removes the row's match with itself,
508
+ so at distance 0 no candidates remain.
357
509
  - **"Can I reuse a `relationName` across tables?"** — yes; uniqueness
358
510
  is scoped to the owning table.
359
511
  - **"Can I declare the relation from the polygon side instead of the
360
512
  point side?"** — yes. Flip owner and target and use the inverse
361
513
  operator (`st_contains` in place of `st_within`). Same rows, same
362
514
  SQL, different `where` location.
363
- - **"Does this work with PostGIS installed in a non-`public` schema?"**
364
- — yes.
515
+ - **"Does this work with PostGIS installed in a non-`public`
516
+ schema?"** — yes.
365
517
  - **"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.
518
+ field?"** — no; it's a where-only construct. Use the measurement /
519
+ transformation fields for values you want to sort on.
520
+
521
+ ## Graceful degradation
522
+
523
+ If the `postgis` extension isn't installed in the target database,
524
+ the plugin detects that at schema-build time and skips type, filter,
525
+ aggregate, and spatial-relation registration instead of breaking the
526
+ build. Turning PostGIS on later only requires restarting the server
527
+ (or invalidating the schema cache) — no config change.
369
528
 
370
529
  ## License
371
530
 
@@ -195,6 +195,11 @@ export function collectSpatialRelations(build) {
195
195
  const pgRegistry = build.input?.pgRegistry;
196
196
  if (!pgRegistry)
197
197
  return [];
198
+ // Inflection is used to normalize user-supplied identifiers (the
199
+ // parametric arg name, e.g. `travel_distance` → `travelDistance`) into the
200
+ // GraphQL casing conventions. Fall back to identity if not available
201
+ // (e.g. when invoked from unit tests with a stub build).
202
+ const camelCase = build.inflection?.camelCase?.bind(build.inflection) ?? ((s) => s);
198
203
  const relations = [];
199
204
  for (const resource of Object.values(pgRegistry.pgResources)) {
200
205
  if (resource.parameters)
@@ -254,7 +259,7 @@ export function collectSpatialRelations(build) {
254
259
  targetResource: target.resource,
255
260
  targetAttributeName: target.attributeName,
256
261
  operator: OPERATOR_REGISTRY[parsed.operator],
257
- paramFieldName: parsed.paramName,
262
+ paramFieldName: parsed.paramName ? camelCase(parsed.paramName) : null,
258
263
  isSelfRelation,
259
264
  ownerPkAttributes,
260
265
  targetPkAttributes,
@@ -280,7 +285,10 @@ export function collectSpatialRelations(build) {
280
285
  function spatialFilterTypeName(build, rel) {
281
286
  const { inflection } = build;
282
287
  const ownerTypeName = inflection.tableType(rel.ownerCodec);
283
- const rel0 = rel.relationName.charAt(0).toUpperCase() + rel.relationName.slice(1);
288
+ // Normalize the user-supplied relation name (which may be snake_case,
289
+ // kebab-case, or mixed) into PascalCase so the type name is consistent
290
+ // with every other generated GraphQL type name.
291
+ const rel0 = inflection.upperCamelCase(rel.relationName);
284
292
  return `${ownerTypeName}Spatial${rel0}Filter`;
285
293
  }
286
294
  /**
@@ -423,7 +431,10 @@ export const PostgisSpatialRelationsPlugin = {
423
431
  const FilterType = build.getTypeByName(filterTypeName);
424
432
  if (!FilterType)
425
433
  continue;
426
- const fieldName = rel.relationName;
434
+ // Normalize the user-supplied relation name (which may be
435
+ // snake_case, kebab-case, or mixed) into camelCase so the GraphQL
436
+ // field name matches the casing of every other generated field.
437
+ const fieldName = inflection.camelCase(rel.relationName);
427
438
  // Avoid clobbering fields an upstream plugin may have registered
428
439
  // (e.g. an FK-derived relation with the same name).
429
440
  if (fields[fieldName]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-postgis",
3
- "version": "2.11.0",
3
+ "version": "2.11.2",
4
4
  "description": "PostGIS support for PostGraphile v5",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -46,7 +46,7 @@
46
46
  "graphile-build": "5.0.0",
47
47
  "graphile-build-pg": "5.0.0",
48
48
  "graphile-config": "1.0.0",
49
- "graphile-connection-filter": "^1.5.0",
49
+ "graphile-connection-filter": "^1.5.1",
50
50
  "graphql": "16.13.0",
51
51
  "pg-sql2": "5.0.0",
52
52
  "postgraphile": "5.0.0"
@@ -63,5 +63,5 @@
63
63
  "makage": "^0.3.0",
64
64
  "pgsql-test": "^4.9.0"
65
65
  },
66
- "gitHead": "1b3af3c5189b9ca2e765b9239a4b287099e64a03"
66
+ "gitHead": "b421fce9f2ac3ce7cd276e5f724ded99db085066"
67
67
  }
@@ -203,6 +203,11 @@ function collectSpatialRelations(build) {
203
203
  const pgRegistry = build.input?.pgRegistry;
204
204
  if (!pgRegistry)
205
205
  return [];
206
+ // Inflection is used to normalize user-supplied identifiers (the
207
+ // parametric arg name, e.g. `travel_distance` → `travelDistance`) into the
208
+ // GraphQL casing conventions. Fall back to identity if not available
209
+ // (e.g. when invoked from unit tests with a stub build).
210
+ const camelCase = build.inflection?.camelCase?.bind(build.inflection) ?? ((s) => s);
206
211
  const relations = [];
207
212
  for (const resource of Object.values(pgRegistry.pgResources)) {
208
213
  if (resource.parameters)
@@ -262,7 +267,7 @@ function collectSpatialRelations(build) {
262
267
  targetResource: target.resource,
263
268
  targetAttributeName: target.attributeName,
264
269
  operator: exports.OPERATOR_REGISTRY[parsed.operator],
265
- paramFieldName: parsed.paramName,
270
+ paramFieldName: parsed.paramName ? camelCase(parsed.paramName) : null,
266
271
  isSelfRelation,
267
272
  ownerPkAttributes,
268
273
  targetPkAttributes,
@@ -288,7 +293,10 @@ function collectSpatialRelations(build) {
288
293
  function spatialFilterTypeName(build, rel) {
289
294
  const { inflection } = build;
290
295
  const ownerTypeName = inflection.tableType(rel.ownerCodec);
291
- const rel0 = rel.relationName.charAt(0).toUpperCase() + rel.relationName.slice(1);
296
+ // Normalize the user-supplied relation name (which may be snake_case,
297
+ // kebab-case, or mixed) into PascalCase so the type name is consistent
298
+ // with every other generated GraphQL type name.
299
+ const rel0 = inflection.upperCamelCase(rel.relationName);
292
300
  return `${ownerTypeName}Spatial${rel0}Filter`;
293
301
  }
294
302
  /**
@@ -431,7 +439,10 @@ exports.PostgisSpatialRelationsPlugin = {
431
439
  const FilterType = build.getTypeByName(filterTypeName);
432
440
  if (!FilterType)
433
441
  continue;
434
- const fieldName = rel.relationName;
442
+ // Normalize the user-supplied relation name (which may be
443
+ // snake_case, kebab-case, or mixed) into camelCase so the GraphQL
444
+ // field name matches the casing of every other generated field.
445
+ const fieldName = inflection.camelCase(rel.relationName);
435
446
  // Avoid clobbering fields an upstream plugin may have registered
436
447
  // (e.g. an FK-derived relation with the same name).
437
448
  if (fields[fieldName]) {