graphile-postgis 2.10.1 → 2.11.1
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 +497 -13
- package/esm/index.d.ts +2 -0
- package/esm/index.js +1 -0
- package/esm/plugins/connection-filter-operators.js +22 -2
- package/esm/plugins/spatial-relations.d.ts +130 -0
- package/esm/plugins/spatial-relations.js +575 -0
- package/esm/preset.js +3 -1
- package/index.d.ts +2 -0
- package/index.js +6 -1
- package/package.json +5 -5
- package/plugins/connection-filter-operators.js +22 -2
- package/plugins/spatial-relations.d.ts +130 -0
- package/plugins/spatial-relations.js +583 -0
- package/preset.js +3 -1
package/README.md
CHANGED
|
@@ -12,9 +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
|
|
16
|
-
|
|
17
|
-
|
|
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)
|
|
18
36
|
|
|
19
37
|
## Installation
|
|
20
38
|
|
|
@@ -24,23 +42,489 @@ npm install graphile-postgis
|
|
|
24
42
|
|
|
25
43
|
## Usage
|
|
26
44
|
|
|
27
|
-
```
|
|
45
|
+
```ts
|
|
28
46
|
import { GraphilePostgisPreset } from 'graphile-postgis';
|
|
29
47
|
|
|
30
48
|
const preset = {
|
|
31
|
-
extends: [GraphilePostgisPreset]
|
|
49
|
+
extends: [GraphilePostgisPreset],
|
|
32
50
|
};
|
|
33
51
|
```
|
|
34
52
|
|
|
35
|
-
|
|
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:
|
|
92
|
+
|
|
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
|
+
```
|
|
102
|
+
|
|
103
|
+
Input values (mutations, filters) accept GeoJSON directly — any of
|
|
104
|
+
`Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`,
|
|
105
|
+
`MultiPolygon`, or `GeometryCollection`.
|
|
106
|
+
|
|
107
|
+
## Dimension-aware interfaces and subtype fields
|
|
108
|
+
|
|
109
|
+
Each concrete subtype is its own GraphQL object with fields that make
|
|
110
|
+
sense for that subtype:
|
|
111
|
+
|
|
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!]` |
|
|
121
|
+
|
|
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
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Measurement fields (`length`, `area`, `perimeter`)
|
|
144
|
+
|
|
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
|
+
}
|
|
197
|
+
```
|
|
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
|
+
|
|
224
|
+
```ts
|
|
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
|
|
235
|
+
.findMany({
|
|
236
|
+
select: { id: true, name: true },
|
|
237
|
+
where: { loc: { bboxLeftOf: { type: 'Point', coordinates: [-100.0, 37.77] } } },
|
|
238
|
+
})
|
|
239
|
+
.execute();
|
|
240
|
+
```
|
|
241
|
+
|
|
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.
|
|
289
|
+
|
|
290
|
+
### Declaring a relation
|
|
291
|
+
|
|
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:
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
@spatialRelation <relationName> <targetRef> <operator> [<paramName>]
|
|
303
|
+
```
|
|
304
|
+
|
|
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_]*$/`.
|
|
307
|
+
- `<targetRef>` — `table.column` (defaults to the owning column's
|
|
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`).
|
|
313
|
+
|
|
314
|
+
Both sides must be `geometry` or `geography`, and share the **same**
|
|
315
|
+
codec — mixing is rejected at schema build.
|
|
316
|
+
|
|
317
|
+
Stack multiple relations on one column by separating tags with `\n`:
|
|
318
|
+
|
|
319
|
+
```sql
|
|
320
|
+
COMMENT ON COLUMN telemedicine_clinics.location IS
|
|
321
|
+
E'@spatialRelation county counties.geom st_within\n'
|
|
322
|
+
'@spatialRelation intersectingCounty counties.geom st_intersects\n'
|
|
323
|
+
'@spatialRelation coveringCounty counties.geom st_coveredby\n'
|
|
324
|
+
'@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Operator reference
|
|
328
|
+
|
|
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:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// "Clinics inside LA County" — st_within, one SQL query, no GeoJSON on the wire.
|
|
353
|
+
await orm.telemedicineClinic
|
|
354
|
+
.findMany({
|
|
355
|
+
select: { id: true, name: true },
|
|
356
|
+
where: { county: { some: { name: { equalTo: 'LA County' } } } },
|
|
357
|
+
})
|
|
358
|
+
.execute();
|
|
359
|
+
|
|
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();
|
|
367
|
+
|
|
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();
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Parametric relations (today: `st_dwithin`) add a required `distance`
|
|
379
|
+
field alongside `some` / `every` / `none`:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
// "Clinics within 10 SRID units of any cardiology clinic" — self-relation
|
|
383
|
+
// with parametric distance; a row never matches itself.
|
|
384
|
+
await orm.telemedicineClinic
|
|
385
|
+
.findMany({
|
|
386
|
+
select: { id: true, name: true },
|
|
387
|
+
where: {
|
|
388
|
+
nearbyClinic: {
|
|
389
|
+
distance: 10.0,
|
|
390
|
+
some: { specialty: { equalTo: 'cardiology' } },
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
.execute();
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Using a spatial relation from GraphQL
|
|
398
|
+
|
|
399
|
+
The same tree, same field names — just under `where:` on the
|
|
400
|
+
connection argument:
|
|
401
|
+
|
|
402
|
+
```graphql
|
|
403
|
+
{
|
|
404
|
+
telemedicineClinics(
|
|
405
|
+
where: { county: { some: { name: { equalTo: "Bay County" } } } }
|
|
406
|
+
) {
|
|
407
|
+
nodes { id name }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Composition
|
|
413
|
+
|
|
414
|
+
Spatial relations live in the same `where:` tree as every scalar
|
|
415
|
+
predicate and compose identically:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
// Bay County clinics that are cardiology
|
|
419
|
+
where: {
|
|
420
|
+
and: [
|
|
421
|
+
{ county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
422
|
+
{ specialty: { equalTo: 'cardiology' } },
|
|
423
|
+
],
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Bay County clinics OR the one named "LA Pediatrics"
|
|
427
|
+
where: {
|
|
428
|
+
or: [
|
|
429
|
+
{ county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
430
|
+
{ name: { equalTo: 'LA Pediatrics' } },
|
|
431
|
+
],
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Clinics NOT in Bay County
|
|
435
|
+
where: {
|
|
436
|
+
not: { county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Self-relations and self-exclusion
|
|
441
|
+
|
|
442
|
+
When the owner and target columns are the same column, the plugin
|
|
443
|
+
emits a self-exclusion predicate so a row never matches itself:
|
|
444
|
+
|
|
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.
|
|
448
|
+
|
|
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.
|
|
452
|
+
|
|
453
|
+
### Generated SQL shape
|
|
454
|
+
|
|
455
|
+
```sql
|
|
456
|
+
SELECT ...
|
|
457
|
+
FROM <owner_table> self
|
|
458
|
+
WHERE EXISTS (
|
|
459
|
+
SELECT 1
|
|
460
|
+
FROM <target_table> other
|
|
461
|
+
WHERE ST_<op>(self.<owner_col>, other.<target_col>[, <distance>])
|
|
462
|
+
AND <self-exclusion for self-relations>
|
|
463
|
+
AND <nested some/every/none conditions>
|
|
464
|
+
);
|
|
465
|
+
```
|
|
466
|
+
|
|
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.
|
|
473
|
+
|
|
474
|
+
### Indexing
|
|
475
|
+
|
|
476
|
+
Spatial predicates without a GIST index fall back to sequential scans,
|
|
477
|
+
which is almost never what you want. The plugin checks your target
|
|
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:
|
|
481
|
+
|
|
482
|
+
```sql
|
|
483
|
+
CREATE INDEX ON telemedicine_clinics USING GIST(location);
|
|
484
|
+
CREATE INDEX ON counties USING GIST(geom);
|
|
485
|
+
```
|
|
36
486
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
487
|
+
If a particular column is a known exception (e.g. a small prototype
|
|
488
|
+
table), set `@spatialRelationSkipIndexCheck` on that column to
|
|
489
|
+
suppress the warning.
|
|
490
|
+
|
|
491
|
+
### `geometry` vs `geography`
|
|
492
|
+
|
|
493
|
+
Pick one side of a relation and stick with it — mixing codecs across
|
|
494
|
+
the two sides is rejected at schema build. `geography` distances are
|
|
495
|
+
always meters; `geometry` distances follow the SRID's native units
|
|
496
|
+
(degrees for SRID 4326, which is rarely what you want for radius
|
|
497
|
+
searches). If you need meter-based proximity on a `geometry` column,
|
|
498
|
+
cast on ingest (`::geography`) rather than mixing codecs across a
|
|
499
|
+
single relation.
|
|
500
|
+
|
|
501
|
+
### FAQ
|
|
502
|
+
|
|
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.
|
|
509
|
+
- **"Can I reuse a `relationName` across tables?"** — yes; uniqueness
|
|
510
|
+
is scoped to the owning table.
|
|
511
|
+
- **"Can I declare the relation from the polygon side instead of the
|
|
512
|
+
point side?"** — yes. Flip owner and target and use the inverse
|
|
513
|
+
operator (`st_contains` in place of `st_within`). Same rows, same
|
|
514
|
+
SQL, different `where` location.
|
|
515
|
+
- **"Does this work with PostGIS installed in a non-`public`
|
|
516
|
+
schema?"** — yes.
|
|
517
|
+
- **"Can I use a spatial relation in `orderBy` or on a connection
|
|
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.
|
|
44
528
|
|
|
45
529
|
## License
|
|
46
530
|
|
package/esm/index.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export { PostgisGeometryFieldsPlugin } from './plugins/geometry-fields';
|
|
|
21
21
|
export { PostgisMeasurementFieldsPlugin } from './plugins/measurement-fields';
|
|
22
22
|
export { PostgisTransformationFieldsPlugin } from './plugins/transformation-functions';
|
|
23
23
|
export { PostgisAggregatePlugin } from './plugins/aggregate-functions';
|
|
24
|
+
export { PostgisSpatialRelationsPlugin, OPERATOR_REGISTRY, parseSpatialRelationTag, collectSpatialRelations, } from './plugins/spatial-relations';
|
|
25
|
+
export type { SpatialOperatorRegistration, SpatialRelationInfo, } from './plugins/spatial-relations';
|
|
24
26
|
export { createPostgisOperatorFactory } from './plugins/connection-filter-operators';
|
|
25
27
|
export { createWithinDistanceOperatorFactory } from './plugins/within-distance-operator';
|
|
26
28
|
export { GisSubtype, SUBTYPE_STRING_BY_SUBTYPE, GIS_SUBTYPE_NAME, CONCRETE_SUBTYPES } from './constants';
|
package/esm/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export { PostgisGeometryFieldsPlugin } from './plugins/geometry-fields';
|
|
|
23
23
|
export { PostgisMeasurementFieldsPlugin } from './plugins/measurement-fields';
|
|
24
24
|
export { PostgisTransformationFieldsPlugin } from './plugins/transformation-functions';
|
|
25
25
|
export { PostgisAggregatePlugin } from './plugins/aggregate-functions';
|
|
26
|
+
export { PostgisSpatialRelationsPlugin, OPERATOR_REGISTRY, parseSpatialRelationTag, collectSpatialRelations, } from './plugins/spatial-relations';
|
|
26
27
|
// Connection filter operator factories (spatial operators for graphile-connection-filter)
|
|
27
28
|
export { createPostgisOperatorFactory } from './plugins/connection-filter-operators';
|
|
28
29
|
export { createWithinDistanceOperatorFactory } from './plugins/within-distance-operator';
|
|
@@ -207,6 +207,8 @@ export function createPostgisOperatorFactory() {
|
|
|
207
207
|
}
|
|
208
208
|
const { inflection } = build;
|
|
209
209
|
const { schemaName, geometryCodec, geographyCodec } = postgisInfo;
|
|
210
|
+
const sqlGeomFromGeoJSON = sql.identifier(schemaName, 'st_geomfromgeojson');
|
|
211
|
+
const sqlGeographyType = sql.identifier(schemaName, 'geography');
|
|
210
212
|
// Collect all GQL type names for geometry and geography
|
|
211
213
|
const gqlTypeNamesByBase = {
|
|
212
214
|
geometry: [],
|
|
@@ -239,6 +241,7 @@ export function createPostgisOperatorFactory() {
|
|
|
239
241
|
typeNames: gqlTypeNamesByBase[baseType],
|
|
240
242
|
operatorName,
|
|
241
243
|
description,
|
|
244
|
+
baseType: baseType,
|
|
242
245
|
resolve: (i, v) => sql.fragment `${sqlGisFunction}(${i}, ${v})`
|
|
243
246
|
});
|
|
244
247
|
}
|
|
@@ -252,6 +255,7 @@ export function createPostgisOperatorFactory() {
|
|
|
252
255
|
typeNames: gqlTypeNamesByBase[baseType],
|
|
253
256
|
operatorName,
|
|
254
257
|
description,
|
|
258
|
+
baseType: baseType,
|
|
255
259
|
resolve: (i, v) => buildOperatorExpr(capturedOp, i, v)
|
|
256
260
|
});
|
|
257
261
|
}
|
|
@@ -261,8 +265,21 @@ export function createPostgisOperatorFactory() {
|
|
|
261
265
|
// Convert to ConnectionFilterOperatorRegistration format.
|
|
262
266
|
// Each InternalSpec may target multiple type names; we expand each
|
|
263
267
|
// into individual registrations keyed by typeName.
|
|
268
|
+
//
|
|
269
|
+
// The default operatorApply pipeline binds the filter value as a raw
|
|
270
|
+
// text parameter cast to the column codec's sqlType (geometry /
|
|
271
|
+
// geography). PostgreSQL's geometry_in / geography_in parsers reject
|
|
272
|
+
// GeoJSON text, so we must wrap the input with ST_GeomFromGeoJSON
|
|
273
|
+
// ourselves — see within-distance-operator.ts for the pattern.
|
|
274
|
+
//
|
|
275
|
+
// We disable the default bind via `resolveSqlValue: () => sql.null`
|
|
276
|
+
// and construct the geometry value from `input` inside resolve(),
|
|
277
|
+
// mirroring the ST_DWithin implementation.
|
|
264
278
|
const registrations = [];
|
|
265
279
|
for (const spec of allSpecs) {
|
|
280
|
+
const geographyCast = spec.baseType === 'geography'
|
|
281
|
+
? sql.fragment `::${sqlGeographyType}`
|
|
282
|
+
: sql.fragment ``;
|
|
266
283
|
for (const typeName of spec.typeNames) {
|
|
267
284
|
registrations.push({
|
|
268
285
|
typeNames: typeName,
|
|
@@ -270,8 +287,11 @@ export function createPostgisOperatorFactory() {
|
|
|
270
287
|
spec: {
|
|
271
288
|
description: spec.description,
|
|
272
289
|
resolveType: (fieldType) => fieldType,
|
|
273
|
-
|
|
274
|
-
|
|
290
|
+
resolveSqlValue: () => sql.null,
|
|
291
|
+
resolve(sqlIdentifier, _sqlValue, input, _$where, _details) {
|
|
292
|
+
const geoJsonStr = sql.value(JSON.stringify(input));
|
|
293
|
+
const geomSql = sql.fragment `${sqlGeomFromGeoJSON}(${geoJsonStr}::text)${geographyCast}`;
|
|
294
|
+
return spec.resolve(sqlIdentifier, geomSql);
|
|
275
295
|
}
|
|
276
296
|
},
|
|
277
297
|
});
|