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 +357 -198
- package/esm/plugins/spatial-relations.js +14 -3
- package/package.json +3 -3
- package/plugins/spatial-relations.js +14 -3
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
geometry
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
103
|
+
Input values (mutations, filters) accept GeoJSON directly — any of
|
|
104
|
+
`Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`,
|
|
105
|
+
`MultiPolygon`, or `GeometryCollection`.
|
|
72
106
|
|
|
73
|
-
|
|
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
|
-
|
|
109
|
+
Each concrete subtype is its own GraphQL object with fields that make
|
|
110
|
+
sense for that subtype:
|
|
81
111
|
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
143
|
+
## Measurement fields (`length`, `area`, `perimeter`)
|
|
96
144
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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: {
|
|
237
|
+
where: { loc: { bboxLeftOf: { type: 'Point', coordinates: [-100.0, 37.77] } } },
|
|
107
238
|
})
|
|
108
239
|
.execute();
|
|
109
240
|
```
|
|
110
241
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
314
|
+
Both sides must be `geometry` or `geography`, and share the **same**
|
|
315
|
+
codec — mixing is rejected at schema build.
|
|
141
316
|
|
|
142
|
-
Stack
|
|
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
|
|
160
|
-
|
|
161
|
-
| `st_contains` | `ST_Contains(A, B)`
|
|
162
|
-
| `st_within` | `ST_Within(A, B)`
|
|
163
|
-
| `st_covers` | `ST_Covers(A, B)`
|
|
164
|
-
| `st_coveredby` | `ST_CoveredBy(A, B)`
|
|
165
|
-
| `st_intersects` | `ST_Intersects(A, B)
|
|
166
|
-
| `st_equals` | `ST_Equals(A, B)`
|
|
167
|
-
| `st_bbox_intersects` | `A && B` (infix)
|
|
168
|
-
| `st_dwithin` | `ST_DWithin(A, B, d)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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: '
|
|
356
|
+
where: { county: { some: { name: { equalTo: 'LA County' } } } },
|
|
190
357
|
})
|
|
191
358
|
.execute();
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
#### Through GraphQL
|
|
195
359
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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:
|
|
241
|
-
some: { specialty: { equalTo: '
|
|
389
|
+
distance: 10.0,
|
|
390
|
+
some: { specialty: { equalTo: 'cardiology' } },
|
|
242
391
|
},
|
|
243
392
|
},
|
|
244
393
|
})
|
|
245
394
|
.execute();
|
|
246
395
|
```
|
|
247
396
|
|
|
248
|
-
|
|
397
|
+
### Using a spatial relation from GraphQL
|
|
249
398
|
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
415
|
+
predicate and compose identically:
|
|
259
416
|
|
|
260
417
|
```ts
|
|
261
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
317
|
-
composes with pagination, ordering, and the rest of the outer
|
|
318
|
-
`st_bbox_intersects` compiles to infix `&&` rather than a
|
|
319
|
-
PostGIS functions are called with whichever schema
|
|
320
|
-
in, so non-`public` installs work without
|
|
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
|
|
327
|
-
index is missing, including the recommended `CREATE INDEX
|
|
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
|
|
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`
|
|
352
|
-
"at least one related target row exists". Rows whose column
|
|
353
|
-
match on the other side are correctly excluded.
|
|
354
|
-
- **"Why does `distance: 0` on a self-relation return nothing?"** —
|
|
355
|
-
self-exclusion predicate removes the row's match with itself,
|
|
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`
|
|
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
|
|
367
|
-
fields
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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]) {
|