graphile-postgis 2.10.0 → 2.11.0
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 +325 -0
- 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
|
@@ -16,6 +16,31 @@ PostGIS support for PostGraphile v5.
|
|
|
16
16
|
|
|
17
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
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.
|
|
43
|
+
|
|
19
44
|
## Installation
|
|
20
45
|
|
|
21
46
|
```bash
|
|
@@ -40,8 +65,308 @@ const preset = {
|
|
|
40
65
|
- Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
|
|
41
66
|
- Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.)
|
|
42
67
|
- Geography-aware field naming (longitude/latitude/height instead of x/y/z)
|
|
68
|
+
- Cross-table spatial relations via `@spatialRelation` smart tags (see below)
|
|
43
69
|
- Graceful degradation when PostGIS is not installed
|
|
44
70
|
|
|
71
|
+
## Spatial relations via smart tags
|
|
72
|
+
|
|
73
|
+
You declare a **spatial relation** with a `@spatialRelation` smart tag
|
|
74
|
+
on a `geometry` or `geography` column. The plugin turns that tag into a
|
|
75
|
+
virtual relation on the owning table: a new field on the table's
|
|
76
|
+
generated `where` input that runs a PostGIS join server-side. You write
|
|
77
|
+
one line of SQL once; the generated ORM and GraphQL schema pick it up
|
|
78
|
+
automatically.
|
|
79
|
+
|
|
80
|
+
### At a glance
|
|
81
|
+
|
|
82
|
+
**Before** — GeoJSON juggling on the client:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// 1. Pull every clinic's location as GeoJSON.
|
|
86
|
+
const clinics = await gql(`{ telemedicineClinics { nodes { id name location } } }`);
|
|
87
|
+
// 2. Pull the polygon of the one county you care about.
|
|
88
|
+
const { geom } = await gql(`{ countyByName(name: "Bay County") { geom } }`);
|
|
89
|
+
// 3. Run point-in-polygon on the client for each clinic.
|
|
90
|
+
const inBay = clinics.telemedicineClinics.nodes.filter((c) =>
|
|
91
|
+
booleanPointInPolygon(c.location, geom),
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**After** — server-side, one trip:
|
|
96
|
+
|
|
97
|
+
```sql
|
|
98
|
+
COMMENT ON COLUMN telemedicine_clinics.location IS
|
|
99
|
+
E'@spatialRelation county counties.geom st_within';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const inBay = await orm.telemedicineClinic
|
|
104
|
+
.findMany({
|
|
105
|
+
select: { id: true, name: true },
|
|
106
|
+
where: { county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
107
|
+
})
|
|
108
|
+
.execute();
|
|
109
|
+
```
|
|
110
|
+
|
|
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.
|
|
114
|
+
|
|
115
|
+
### Declaring a relation
|
|
116
|
+
|
|
117
|
+
#### Tag grammar
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
@spatialRelation <relationName> <targetRef> <operator> [<paramName>]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- `<relationName>` — user-chosen name for the new field on the owning
|
|
124
|
+
table's `where` input. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. The
|
|
125
|
+
name is preserved as-written — `county` stays `county`,
|
|
126
|
+
`nearbyClinic` stays `nearbyClinic`.
|
|
127
|
+
- `<targetRef>` — `table.column` (defaults to the owning column's
|
|
128
|
+
schema) or `schema.table.column` (for references in another schema,
|
|
129
|
+
e.g. a shared `geo` schema).
|
|
130
|
+
- `<operator>` — one of the eight PG-native snake_case tokens listed in
|
|
131
|
+
[Operator reference](#operator-reference).
|
|
132
|
+
- `<paramName>` — required if and only if the operator is parametric.
|
|
133
|
+
Today that's `st_dwithin`, which needs a parameter name (typically
|
|
134
|
+
`distance`).
|
|
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`.
|
|
139
|
+
|
|
140
|
+
#### Multiple relations on one column
|
|
141
|
+
|
|
142
|
+
Stack tags. Each line becomes its own field on the owning table's
|
|
143
|
+
`where` input:
|
|
144
|
+
|
|
145
|
+
```sql
|
|
146
|
+
COMMENT ON COLUMN telemedicine_clinics.location IS
|
|
147
|
+
E'@spatialRelation county counties.geom st_within\n'
|
|
148
|
+
'@spatialRelation intersectingCounty counties.geom st_intersects\n'
|
|
149
|
+
'@spatialRelation coveringCounty counties.geom st_coveredby\n'
|
|
150
|
+
'@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The four relations above all exist in the integration test suite and
|
|
154
|
+
can be used in the same query. Two relations on the same owner cannot
|
|
155
|
+
share a `<relationName>`.
|
|
156
|
+
|
|
157
|
+
### Operator reference
|
|
158
|
+
|
|
159
|
+
| Tag operator | PostGIS function | Parametric? | Symmetric? | Typical use |
|
|
160
|
+
|---|---|---|---|---|
|
|
161
|
+
| `st_contains` | `ST_Contains(A, B)` | no | **no** (A contains B) | polygon containing a point / line / polygon |
|
|
162
|
+
| `st_within` | `ST_Within(A, B)` | no | **no** (A within B) | point-in-polygon, line-in-polygon |
|
|
163
|
+
| `st_covers` | `ST_Covers(A, B)` | no | **no** | like `st_contains` but boundary-inclusive |
|
|
164
|
+
| `st_coveredby` | `ST_CoveredBy(A, B)` | no | **no** | dual of `st_covers` |
|
|
165
|
+
| `st_intersects` | `ST_Intersects(A, B)`| no | yes | any overlap at all |
|
|
166
|
+
| `st_equals` | `ST_Equals(A, B)` | no | yes | exact geometry match |
|
|
167
|
+
| `st_bbox_intersects` | `A && B` (infix) | no | yes | fast bounding-box prefilter |
|
|
168
|
+
| `st_dwithin` | `ST_DWithin(A, B, d)`| **yes** (`d`) | yes | radius / proximity search |
|
|
169
|
+
|
|
170
|
+
> The tag reads left-to-right as **"owner op target"**, and the emitted
|
|
171
|
+
> SQL is exactly `ST_<op>(owner_col, target_col[, distance])`. For
|
|
172
|
+
> symmetric operators (`st_intersects`, `st_equals`, `st_dwithin`,
|
|
173
|
+
> `st_bbox_intersects`) argument order doesn't matter. For directional
|
|
174
|
+
> operators (`st_within`, `st_contains`, `st_covers`, `st_coveredby`),
|
|
175
|
+
> flipping the two columns inverts the result set. Rule of thumb: put
|
|
176
|
+
> the relation on the column whose type makes the sentence true —
|
|
177
|
+
> `clinics.location st_within counties.geom` reads naturally; the
|
|
178
|
+
> reverse does not.
|
|
179
|
+
|
|
180
|
+
### Using the generated `where` shape
|
|
181
|
+
|
|
182
|
+
#### Through the ORM
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// "Clinics inside any county named 'Bay County'"
|
|
186
|
+
await orm.telemedicineClinic
|
|
187
|
+
.findMany({
|
|
188
|
+
select: { id: true, name: true },
|
|
189
|
+
where: { county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
190
|
+
})
|
|
191
|
+
.execute();
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Through GraphQL
|
|
195
|
+
|
|
196
|
+
The connection argument is `where:` at the GraphQL layer too — same
|
|
197
|
+
name, same tree. Only the generated input **type** keeps the word
|
|
198
|
+
"Filter" in it (e.g. `TelemedicineClinicFilter`):
|
|
199
|
+
|
|
200
|
+
```graphql
|
|
201
|
+
{
|
|
202
|
+
telemedicineClinics(
|
|
203
|
+
where: { county: { some: { name: { equalTo: "Bay County" } } } }
|
|
204
|
+
) {
|
|
205
|
+
nodes { id name }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
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:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
await orm.telemedicineClinic
|
|
236
|
+
.findMany({
|
|
237
|
+
select: { id: true, name: true },
|
|
238
|
+
where: {
|
|
239
|
+
nearbyClinic: {
|
|
240
|
+
distance: 5000,
|
|
241
|
+
some: { specialty: { equalTo: 'pediatrics' } },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
.execute();
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Distance units follow PostGIS semantics:
|
|
249
|
+
|
|
250
|
+
| Owner codec | `distance` units |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `geography` | meters |
|
|
253
|
+
| `geometry` | SRID coordinate units (degrees for SRID 4326) |
|
|
254
|
+
|
|
255
|
+
#### Composition with `and` / `or` / `not` and scalar where clauses
|
|
256
|
+
|
|
257
|
+
Spatial relations live in the same `where:` tree as every scalar
|
|
258
|
+
predicate and compose the same way:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// AND — Bay County clinics that are cardiology
|
|
262
|
+
where: {
|
|
263
|
+
and: [
|
|
264
|
+
{ county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
265
|
+
{ specialty: { equalTo: 'cardiology' } },
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// OR — Bay County clinics OR the one named "LA Pediatrics"
|
|
270
|
+
where: {
|
|
271
|
+
or: [
|
|
272
|
+
{ county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
273
|
+
{ name: { equalTo: 'LA Pediatrics' } },
|
|
274
|
+
],
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// NOT — clinics that are NOT in Bay County
|
|
278
|
+
where: {
|
|
279
|
+
not: { county: { some: { name: { equalTo: 'Bay County' } } } },
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
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
|
|
288
|
+
|
|
289
|
+
When the owner and target columns are the same column, the plugin
|
|
290
|
+
emits a self-exclusion predicate so a row never matches itself:
|
|
291
|
+
|
|
292
|
+
- Single-column primary key: `other.<pk> <> self.<pk>`
|
|
293
|
+
- Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`
|
|
294
|
+
|
|
295
|
+
Tables without a primary key are rejected at schema build — a
|
|
296
|
+
self-relation there would match every row against itself.
|
|
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.
|
|
301
|
+
|
|
302
|
+
### Generated SQL shape
|
|
303
|
+
|
|
304
|
+
```sql
|
|
305
|
+
SELECT ...
|
|
306
|
+
FROM <owner_table> self
|
|
307
|
+
WHERE EXISTS (
|
|
308
|
+
SELECT 1
|
|
309
|
+
FROM <target_table> other
|
|
310
|
+
WHERE ST_<op>(self.<owner_col>, other.<target_col>[, <distance>])
|
|
311
|
+
AND <self-exclusion for self-relations>
|
|
312
|
+
AND <nested some/every/none conditions>
|
|
313
|
+
);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The EXISTS lives inside the owner's generated `where` input, so it
|
|
317
|
+
composes with pagination, ordering, and the rest of the outer plan.
|
|
318
|
+
`st_bbox_intersects` compiles to infix `&&` rather than a function call.
|
|
319
|
+
PostGIS functions are called with whichever schema PostGIS is installed
|
|
320
|
+
in, so non-`public` installs work without configuration.
|
|
321
|
+
|
|
322
|
+
### Indexing
|
|
323
|
+
|
|
324
|
+
Spatial predicates without a GIST index fall back to sequential scans,
|
|
325
|
+
which is almost never what you want. The plugin checks your target
|
|
326
|
+
columns at schema-build time and emits a non-fatal warning when a GIST
|
|
327
|
+
index is missing, including the recommended `CREATE INDEX ... USING
|
|
328
|
+
GIST(...)` in the warning text.
|
|
329
|
+
|
|
330
|
+
```sql
|
|
331
|
+
CREATE INDEX ON telemedicine_clinics USING GIST(location);
|
|
332
|
+
CREATE INDEX ON counties USING GIST(geom);
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
If a particular column is a known exception (e.g. a small prototype
|
|
336
|
+
table), set `@spatialRelationSkipIndexCheck` on that column to suppress
|
|
337
|
+
the warning.
|
|
338
|
+
|
|
339
|
+
### `geometry` vs `geography`
|
|
340
|
+
|
|
341
|
+
Pick one side of a relation and stick with it — mixing codecs across
|
|
342
|
+
the two sides is rejected at schema build. `geography` distances are
|
|
343
|
+
always meters; `geometry` distances follow the SRID's native units
|
|
344
|
+
(degrees for SRID 4326, which is rarely what you want for radius
|
|
345
|
+
searches). If you need meter-based proximity on a `geometry` column,
|
|
346
|
+
cast on ingest (`::geography`) rather than mixing codecs across a
|
|
347
|
+
single relation.
|
|
348
|
+
|
|
349
|
+
### FAQ
|
|
350
|
+
|
|
351
|
+
- **"Why doesn't `some: {}` return every row?"** — because `some` means
|
|
352
|
+
"at least one related target row exists". Rows whose column has no
|
|
353
|
+
match on the other side are correctly excluded.
|
|
354
|
+
- **"Why does `distance: 0` on a self-relation return nothing?"** — the
|
|
355
|
+
self-exclusion predicate removes the row's match with itself, so at
|
|
356
|
+
distance 0 no candidates remain.
|
|
357
|
+
- **"Can I reuse a `relationName` across tables?"** — yes; uniqueness
|
|
358
|
+
is scoped to the owning table.
|
|
359
|
+
- **"Can I declare the relation from the polygon side instead of the
|
|
360
|
+
point side?"** — yes. Flip owner and target and use the inverse
|
|
361
|
+
operator (`st_contains` in place of `st_within`). Same rows, same
|
|
362
|
+
SQL, different `where` location.
|
|
363
|
+
- **"Does this work with PostGIS installed in a non-`public` schema?"**
|
|
364
|
+
— yes.
|
|
365
|
+
- **"Can I use a spatial relation in `orderBy` or on a connection
|
|
366
|
+
field?"** — no; it's a where-only construct. Use PostGIS measurement
|
|
367
|
+
fields (see the `geometry-fields` / `measurement-fields` plugins) for
|
|
368
|
+
values you want to sort on.
|
|
369
|
+
|
|
45
370
|
## License
|
|
46
371
|
|
|
47
372
|
MIT
|
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
|
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import 'graphile-build';
|
|
2
|
+
import 'graphile-build-pg';
|
|
3
|
+
import 'graphile-connection-filter';
|
|
4
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
5
|
+
/**
|
|
6
|
+
* PostgisSpatialRelationsPlugin
|
|
7
|
+
*
|
|
8
|
+
* Adds cross-table spatial filtering to `graphile-connection-filter` by
|
|
9
|
+
* reading a `@spatialRelation` smart tag on geometry/geography columns and
|
|
10
|
+
* synthesising a virtual relation + filter field that emits an EXISTS
|
|
11
|
+
* subquery joined by a PostGIS predicate (e.g. `ST_Contains`, `ST_DWithin`).
|
|
12
|
+
*
|
|
13
|
+
* The regular `ConnectionFilterBackwardRelationsPlugin` is FK-driven — it
|
|
14
|
+
* joins on column equality. Spatial relationships are not backed by FKs, so
|
|
15
|
+
* this plugin hooks the same `pgCodec`-scoped filter input types and injects
|
|
16
|
+
* its own fields whose `apply()` emits `ST_<op>(...)` instead of `a = b`.
|
|
17
|
+
*
|
|
18
|
+
* Tag grammar:
|
|
19
|
+
*
|
|
20
|
+
* ```sql
|
|
21
|
+
* COMMENT ON COLUMN <owner_table>.<owner_col> IS
|
|
22
|
+
* E'@spatialRelation <relation_name> <target_ref> <operator> [<param_name>]';
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* - `target_ref` — `schema.table.col` or `table.col` (same schema as owner).
|
|
26
|
+
* - `operator` — one of the PG-native snake_case ops in OPERATOR_REGISTRY.
|
|
27
|
+
* - `param_name` — required iff the operator is parametric (currently
|
|
28
|
+
* only `st_dwithin`, which needs a distance).
|
|
29
|
+
*
|
|
30
|
+
* Examples:
|
|
31
|
+
*
|
|
32
|
+
* ```sql
|
|
33
|
+
* -- Point in polygon
|
|
34
|
+
* COMMENT ON COLUMN telemedicine_clinics.location IS
|
|
35
|
+
* E'@spatialRelation county counties.geom st_contains';
|
|
36
|
+
*
|
|
37
|
+
* -- Self-referential radius search
|
|
38
|
+
* COMMENT ON COLUMN telemedicine_clinics.location IS
|
|
39
|
+
* E'@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Generated GraphQL (for the `st_dwithin` case):
|
|
43
|
+
*
|
|
44
|
+
* ```graphql
|
|
45
|
+
* telemedicineClinics(where: {
|
|
46
|
+
* nearbyClinic: {
|
|
47
|
+
* distance: 5000,
|
|
48
|
+
* some: { specialty: { eq: "pediatrics" } }
|
|
49
|
+
* }
|
|
50
|
+
* })
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* The generated SQL uses the same EXISTS pattern as backward relations but
|
|
54
|
+
* substitutes `ST_<op>(...)` for column equality:
|
|
55
|
+
*
|
|
56
|
+
* ```sql
|
|
57
|
+
* WHERE EXISTS (
|
|
58
|
+
* SELECT 1 FROM <target_table> other
|
|
59
|
+
* WHERE ST_<op>(other.<target_col>, self.<owner_col>[, distance])
|
|
60
|
+
* AND other.<pk> <> self.<pk> -- self-relations only
|
|
61
|
+
* AND <nested filter conditions>
|
|
62
|
+
* )
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export interface SpatialOperatorRegistration {
|
|
66
|
+
/** Tag-facing op name (PG-native snake_case). */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Kind of PG-level operator. */
|
|
69
|
+
kind: 'function' | 'infix';
|
|
70
|
+
/**
|
|
71
|
+
* For `kind: 'function'`, the PG function name (snake_case) resolved
|
|
72
|
+
* against the PostGIS schema at SQL-emit time. For `kind: 'infix'`,
|
|
73
|
+
* the PG binary operator token (e.g. `&&`).
|
|
74
|
+
*/
|
|
75
|
+
pgToken: string;
|
|
76
|
+
/** Whether this op takes an extra numeric parameter (e.g. `st_dwithin`). */
|
|
77
|
+
parametric: boolean;
|
|
78
|
+
description: string;
|
|
79
|
+
}
|
|
80
|
+
export declare const OPERATOR_REGISTRY: Record<string, SpatialOperatorRegistration>;
|
|
81
|
+
export interface SpatialRelationInfo {
|
|
82
|
+
/** GraphQL-facing relation name, derived from the tag. */
|
|
83
|
+
relationName: string;
|
|
84
|
+
/** The codec that owns the tag (outer side of the EXISTS). */
|
|
85
|
+
ownerCodec: any;
|
|
86
|
+
/** The owning attribute name (column). */
|
|
87
|
+
ownerAttributeName: string;
|
|
88
|
+
/** Qualified target resource (inner side of the EXISTS). */
|
|
89
|
+
targetResource: any;
|
|
90
|
+
/** Column name on the target resource. */
|
|
91
|
+
targetAttributeName: string;
|
|
92
|
+
/** Resolved operator. */
|
|
93
|
+
operator: SpatialOperatorRegistration;
|
|
94
|
+
/** Field name for the parametric argument, if any. */
|
|
95
|
+
paramFieldName: string | null;
|
|
96
|
+
/** Whether owner === target (self-relation needs row exclusion). */
|
|
97
|
+
isSelfRelation: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Cached primary-key attribute names for the owner+target codecs. Used
|
|
100
|
+
* to synthesise the self-exclusion predicate (`other.<pk> <> self.<pk>`).
|
|
101
|
+
* `null` if the codec has no discoverable PK.
|
|
102
|
+
*/
|
|
103
|
+
ownerPkAttributes: string[] | null;
|
|
104
|
+
targetPkAttributes: string[] | null;
|
|
105
|
+
}
|
|
106
|
+
interface TagParseResult {
|
|
107
|
+
ok: true;
|
|
108
|
+
relationName: string;
|
|
109
|
+
targetRef: string;
|
|
110
|
+
operator: string;
|
|
111
|
+
paramName: string | null;
|
|
112
|
+
}
|
|
113
|
+
interface TagParseError {
|
|
114
|
+
ok: false;
|
|
115
|
+
error: string;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Parse a single `@spatialRelation` tag value.
|
|
119
|
+
*
|
|
120
|
+
* Accepts a string of the form `<name> <target> <op> [<param>]`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function parseSpatialRelationTag(raw: string): TagParseResult | TagParseError;
|
|
123
|
+
/**
|
|
124
|
+
* Build the full set of spatial relations from all resources.
|
|
125
|
+
* Validates tags and throws (at schema build) on anything malformed.
|
|
126
|
+
* Returns relations keyed by (owner codec identity, relation name).
|
|
127
|
+
*/
|
|
128
|
+
export declare function collectSpatialRelations(build: any): SpatialRelationInfo[];
|
|
129
|
+
export declare const PostgisSpatialRelationsPlugin: GraphileConfig.Plugin;
|
|
130
|
+
export {};
|