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
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import 'graphile-build';
|
|
2
|
+
import 'graphile-build-pg';
|
|
3
|
+
import 'graphile-connection-filter';
|
|
4
|
+
import sql from 'pg-sql2';
|
|
5
|
+
export const OPERATOR_REGISTRY = {
|
|
6
|
+
st_contains: {
|
|
7
|
+
name: 'st_contains',
|
|
8
|
+
kind: 'function',
|
|
9
|
+
pgToken: 'st_contains',
|
|
10
|
+
parametric: false,
|
|
11
|
+
description: 'Every point of the owner column lies in the interior of the target column (ST_Contains).',
|
|
12
|
+
},
|
|
13
|
+
st_within: {
|
|
14
|
+
name: 'st_within',
|
|
15
|
+
kind: 'function',
|
|
16
|
+
pgToken: 'st_within',
|
|
17
|
+
parametric: false,
|
|
18
|
+
description: 'Owner column is completely inside the target column (ST_Within).',
|
|
19
|
+
},
|
|
20
|
+
st_covers: {
|
|
21
|
+
name: 'st_covers',
|
|
22
|
+
kind: 'function',
|
|
23
|
+
pgToken: 'st_covers',
|
|
24
|
+
parametric: false,
|
|
25
|
+
description: 'No point in the target column lies outside the owner column (ST_Covers).',
|
|
26
|
+
},
|
|
27
|
+
st_coveredby: {
|
|
28
|
+
name: 'st_coveredby',
|
|
29
|
+
kind: 'function',
|
|
30
|
+
pgToken: 'st_coveredby',
|
|
31
|
+
parametric: false,
|
|
32
|
+
description: 'No point in the owner column lies outside the target column (ST_CoveredBy).',
|
|
33
|
+
},
|
|
34
|
+
st_intersects: {
|
|
35
|
+
name: 'st_intersects',
|
|
36
|
+
kind: 'function',
|
|
37
|
+
pgToken: 'st_intersects',
|
|
38
|
+
parametric: false,
|
|
39
|
+
description: 'Owner and target columns share any portion of space (ST_Intersects).',
|
|
40
|
+
},
|
|
41
|
+
st_equals: {
|
|
42
|
+
name: 'st_equals',
|
|
43
|
+
kind: 'function',
|
|
44
|
+
pgToken: 'st_equals',
|
|
45
|
+
parametric: false,
|
|
46
|
+
description: 'Owner and target columns represent the same geometry (ST_Equals).',
|
|
47
|
+
},
|
|
48
|
+
st_bbox_intersects: {
|
|
49
|
+
name: 'st_bbox_intersects',
|
|
50
|
+
kind: 'infix',
|
|
51
|
+
pgToken: '&&',
|
|
52
|
+
parametric: false,
|
|
53
|
+
description: "Owner column's 2D bounding box intersects the target's 2D bounding box (&&).",
|
|
54
|
+
},
|
|
55
|
+
st_dwithin: {
|
|
56
|
+
name: 'st_dwithin',
|
|
57
|
+
kind: 'function',
|
|
58
|
+
pgToken: 'st_dwithin',
|
|
59
|
+
parametric: true,
|
|
60
|
+
description: 'Owner column is within <distance> of the target column (ST_DWithin). ' +
|
|
61
|
+
'Distance is in meters for geography, SRID coordinate units for geometry.',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Parse a single `@spatialRelation` tag value.
|
|
66
|
+
*
|
|
67
|
+
* Accepts a string of the form `<name> <target> <op> [<param>]`.
|
|
68
|
+
*/
|
|
69
|
+
export function parseSpatialRelationTag(raw) {
|
|
70
|
+
if (typeof raw !== 'string') {
|
|
71
|
+
return { ok: false, error: `Expected string, got ${typeof raw}` };
|
|
72
|
+
}
|
|
73
|
+
const parts = raw.trim().split(/\s+/);
|
|
74
|
+
if (parts.length < 3 || parts.length > 4) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `Expected 3 or 4 whitespace-separated tokens; got ${parts.length}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const [relationName, targetRef, operator, paramName] = parts;
|
|
81
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(relationName)) {
|
|
82
|
+
return { ok: false, error: `Invalid relation name '${relationName}'` };
|
|
83
|
+
}
|
|
84
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(targetRef)) {
|
|
85
|
+
return { ok: false, error: `Invalid target reference '${targetRef}'` };
|
|
86
|
+
}
|
|
87
|
+
const targetParts = targetRef.split('.');
|
|
88
|
+
if (targetParts.length < 2 || targetParts.length > 3) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `Target must be 'table.col' or 'schema.table.col'; got '${targetRef}'`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!(operator in OPERATOR_REGISTRY)) {
|
|
95
|
+
const known = Object.keys(OPERATOR_REGISTRY).sort().join(', ');
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `Unknown spatial operator '${operator}'. Known ops: ${known}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const op = OPERATOR_REGISTRY[operator];
|
|
102
|
+
if (op.parametric) {
|
|
103
|
+
if (!paramName) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: `Operator '${operator}' requires a parameter name (e.g. 'distance')`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(paramName)) {
|
|
110
|
+
return { ok: false, error: `Invalid param name '${paramName}'` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
if (paramName) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: `Operator '${operator}' does not take a parameter; got extra token '${paramName}'`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
relationName,
|
|
124
|
+
targetRef,
|
|
125
|
+
operator,
|
|
126
|
+
paramName: paramName ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a `<table.col>` or `<schema.table.col>` reference to a
|
|
131
|
+
* `pgResource` + attribute name.
|
|
132
|
+
*/
|
|
133
|
+
function resolveTargetRef(pgRegistry, ownerResource, targetRef) {
|
|
134
|
+
const parts = targetRef.split('.');
|
|
135
|
+
let schemaName = null;
|
|
136
|
+
let tableName;
|
|
137
|
+
let columnName;
|
|
138
|
+
if (parts.length === 2) {
|
|
139
|
+
[tableName, columnName] = parts;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
[schemaName, tableName, columnName] = parts;
|
|
143
|
+
}
|
|
144
|
+
const ownerPgExt = ownerResource?.codec?.extensions?.pg;
|
|
145
|
+
const defaultSchema = ownerPgExt?.schemaName ?? 'public';
|
|
146
|
+
const lookupSchema = schemaName ?? defaultSchema;
|
|
147
|
+
for (const res of Object.values(pgRegistry.pgResources)) {
|
|
148
|
+
if (res.parameters)
|
|
149
|
+
continue;
|
|
150
|
+
const pg = res?.codec?.extensions?.pg;
|
|
151
|
+
if (!pg)
|
|
152
|
+
continue;
|
|
153
|
+
if (pg.name !== tableName)
|
|
154
|
+
continue;
|
|
155
|
+
if (pg.schemaName !== lookupSchema)
|
|
156
|
+
continue;
|
|
157
|
+
const attr = res.codec.attributes?.[columnName];
|
|
158
|
+
if (!attr)
|
|
159
|
+
return null;
|
|
160
|
+
return { resource: res, attributeName: columnName };
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/** Get the PK attribute names for a resource, or null if none discoverable. */
|
|
165
|
+
function getPrimaryKeyAttributes(resource) {
|
|
166
|
+
const uniques = resource?.uniques;
|
|
167
|
+
if (!uniques || uniques.length === 0)
|
|
168
|
+
return null;
|
|
169
|
+
const primary = uniques.find((u) => u.isPrimary);
|
|
170
|
+
const chosen = primary ?? uniques[0];
|
|
171
|
+
if (!chosen?.attributes || chosen.attributes.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
return chosen.attributes;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Collect tag strings from an attribute, handling the `string | string[]`
|
|
177
|
+
* normalisation graphile-build-pg does for repeated smart tags.
|
|
178
|
+
*/
|
|
179
|
+
function collectTagStrings(tagValue) {
|
|
180
|
+
if (tagValue == null)
|
|
181
|
+
return [];
|
|
182
|
+
if (Array.isArray(tagValue)) {
|
|
183
|
+
return tagValue.filter((v) => typeof v === 'string');
|
|
184
|
+
}
|
|
185
|
+
if (typeof tagValue === 'string')
|
|
186
|
+
return [tagValue];
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build the full set of spatial relations from all resources.
|
|
191
|
+
* Validates tags and throws (at schema build) on anything malformed.
|
|
192
|
+
* Returns relations keyed by (owner codec identity, relation name).
|
|
193
|
+
*/
|
|
194
|
+
export function collectSpatialRelations(build) {
|
|
195
|
+
const pgRegistry = build.input?.pgRegistry;
|
|
196
|
+
if (!pgRegistry)
|
|
197
|
+
return [];
|
|
198
|
+
const relations = [];
|
|
199
|
+
for (const resource of Object.values(pgRegistry.pgResources)) {
|
|
200
|
+
if (resource.parameters)
|
|
201
|
+
continue;
|
|
202
|
+
const attributes = resource.codec?.attributes;
|
|
203
|
+
if (!attributes)
|
|
204
|
+
continue;
|
|
205
|
+
for (const [ownerAttributeName, attribute] of Object.entries(attributes)) {
|
|
206
|
+
const tags = attribute?.extensions?.tags;
|
|
207
|
+
if (!tags)
|
|
208
|
+
continue;
|
|
209
|
+
const rawValues = collectTagStrings(tags.spatialRelation);
|
|
210
|
+
if (rawValues.length === 0)
|
|
211
|
+
continue;
|
|
212
|
+
for (const rawValue of rawValues) {
|
|
213
|
+
const parsed = parseSpatialRelationTag(rawValue);
|
|
214
|
+
if (parsed.ok !== true) {
|
|
215
|
+
throw new Error(`[graphile-postgis] Invalid @spatialRelation tag on ` +
|
|
216
|
+
`${resource.codec.name}.${ownerAttributeName}: ${parsed.error}`);
|
|
217
|
+
}
|
|
218
|
+
const target = resolveTargetRef(pgRegistry, resource, parsed.targetRef);
|
|
219
|
+
if (!target) {
|
|
220
|
+
throw new Error(`[graphile-postgis] @spatialRelation tag on ` +
|
|
221
|
+
`${resource.codec.name}.${ownerAttributeName} references ` +
|
|
222
|
+
`'${parsed.targetRef}' which does not resolve to a known column.`);
|
|
223
|
+
}
|
|
224
|
+
// Validate geometry/geography codec symmetry.
|
|
225
|
+
const ownerPgExt = attribute.codec?.extensions?.pg;
|
|
226
|
+
const targetAttr = target.resource.codec.attributes[target.attributeName];
|
|
227
|
+
const targetPgExt = targetAttr?.codec?.extensions?.pg;
|
|
228
|
+
const ownerBase = ownerPgExt?.name;
|
|
229
|
+
const targetBase = targetPgExt?.name;
|
|
230
|
+
if ((ownerBase === 'geometry' || ownerBase === 'geography') &&
|
|
231
|
+
(targetBase === 'geometry' || targetBase === 'geography') &&
|
|
232
|
+
ownerBase !== targetBase) {
|
|
233
|
+
throw new Error(`[graphile-postgis] @spatialRelation ${resource.codec.name}.${ownerAttributeName} ` +
|
|
234
|
+
`-> ${target.resource.codec.name}.${target.attributeName}: ` +
|
|
235
|
+
`codec mismatch (${ownerBase} vs ${targetBase}). Both sides must share a base codec.`);
|
|
236
|
+
}
|
|
237
|
+
if (ownerBase !== 'geometry' &&
|
|
238
|
+
ownerBase !== 'geography') {
|
|
239
|
+
throw new Error(`[graphile-postgis] @spatialRelation requires a geometry or geography column; ` +
|
|
240
|
+
`${resource.codec.name}.${ownerAttributeName} is ${ownerBase ?? 'unknown'}.`);
|
|
241
|
+
}
|
|
242
|
+
const isSelfRelation = resource === target.resource;
|
|
243
|
+
const ownerPkAttributes = getPrimaryKeyAttributes(resource);
|
|
244
|
+
const targetPkAttributes = getPrimaryKeyAttributes(target.resource);
|
|
245
|
+
if (isSelfRelation && !ownerPkAttributes) {
|
|
246
|
+
throw new Error(`[graphile-postgis] @spatialRelation '${parsed.relationName}' on ` +
|
|
247
|
+
`${resource.codec.name}.${ownerAttributeName} is a self-relation, but the ` +
|
|
248
|
+
`table has no primary key; refusing to register (would match every row against itself).`);
|
|
249
|
+
}
|
|
250
|
+
relations.push({
|
|
251
|
+
relationName: parsed.relationName,
|
|
252
|
+
ownerCodec: resource.codec,
|
|
253
|
+
ownerAttributeName,
|
|
254
|
+
targetResource: target.resource,
|
|
255
|
+
targetAttributeName: target.attributeName,
|
|
256
|
+
operator: OPERATOR_REGISTRY[parsed.operator],
|
|
257
|
+
paramFieldName: parsed.paramName,
|
|
258
|
+
isSelfRelation,
|
|
259
|
+
ownerPkAttributes,
|
|
260
|
+
targetPkAttributes,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Detect duplicate (ownerCodec, relationName) pairs — emit a clear error
|
|
266
|
+
// rather than letting registerInputObjectType throw generic "already exists".
|
|
267
|
+
const seen = new Map();
|
|
268
|
+
for (const rel of relations) {
|
|
269
|
+
const key = `${rel.ownerCodec.name}:${rel.relationName}`;
|
|
270
|
+
const prior = seen.get(key);
|
|
271
|
+
if (prior) {
|
|
272
|
+
throw new Error(`[graphile-postgis] Duplicate @spatialRelation name '${rel.relationName}' on ` +
|
|
273
|
+
`codec '${rel.ownerCodec.name}'. Each relation name must be unique per owning table.`);
|
|
274
|
+
}
|
|
275
|
+
seen.set(key, rel);
|
|
276
|
+
}
|
|
277
|
+
return relations;
|
|
278
|
+
}
|
|
279
|
+
/** Name of the per-relation filter type: `<Owner>Spatial<Relation>Filter`. */
|
|
280
|
+
function spatialFilterTypeName(build, rel) {
|
|
281
|
+
const { inflection } = build;
|
|
282
|
+
const ownerTypeName = inflection.tableType(rel.ownerCodec);
|
|
283
|
+
const rel0 = rel.relationName.charAt(0).toUpperCase() + rel.relationName.slice(1);
|
|
284
|
+
return `${ownerTypeName}Spatial${rel0}Filter`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Build the SQL fragment that joins the inner (target) row to the outer
|
|
288
|
+
* (owner) row using the resolved PostGIS predicate.
|
|
289
|
+
*/
|
|
290
|
+
function buildSpatialJoinFragment(rel, schemaName, outerAlias, innerAlias, distanceValue) {
|
|
291
|
+
// Tag grammar reads as "<owner_col> <op> <target_col>" (e.g. "location
|
|
292
|
+
// st_within counties.geom"), so the emitted PostGIS call is always
|
|
293
|
+
// `ST_<op>(owner_col, target_col)`. For symmetric operators
|
|
294
|
+
// (st_intersects, st_dwithin, st_equals, &&) the ordering is immaterial;
|
|
295
|
+
// for directional ones (st_within, st_contains, st_covers, st_coveredby)
|
|
296
|
+
// reversing the operands inverts the set of matched rows.
|
|
297
|
+
const ownerExpr = sql `${outerAlias}.${sql.identifier(rel.ownerAttributeName)}`;
|
|
298
|
+
const targetExpr = sql `${innerAlias}.${sql.identifier(rel.targetAttributeName)}`;
|
|
299
|
+
if (rel.operator.kind === 'infix') {
|
|
300
|
+
// Only `&&` today — simple inline (symmetric).
|
|
301
|
+
return sql `${ownerExpr} && ${targetExpr}`;
|
|
302
|
+
}
|
|
303
|
+
const fn = sql.identifier(schemaName, rel.operator.pgToken);
|
|
304
|
+
if (rel.operator.parametric) {
|
|
305
|
+
if (!distanceValue) {
|
|
306
|
+
// The apply() guards this; defensive throw.
|
|
307
|
+
throw new Error(`[graphile-postgis] Parametric operator '${rel.operator.name}' invoked without ` +
|
|
308
|
+
`a distance value in spatial relation '${rel.relationName}'.`);
|
|
309
|
+
}
|
|
310
|
+
return sql `${fn}(${ownerExpr}, ${targetExpr}, ${distanceValue})`;
|
|
311
|
+
}
|
|
312
|
+
return sql `${fn}(${ownerExpr}, ${targetExpr})`;
|
|
313
|
+
}
|
|
314
|
+
/** Build the `other.pk <> self.pk` exclusion predicate for self-relations. */
|
|
315
|
+
function buildSelfExclusionFragment(rel, outerAlias, innerAlias) {
|
|
316
|
+
if (!rel.isSelfRelation)
|
|
317
|
+
return null;
|
|
318
|
+
const pk = rel.ownerPkAttributes;
|
|
319
|
+
if (!pk || pk.length === 0)
|
|
320
|
+
return null;
|
|
321
|
+
if (pk.length === 1) {
|
|
322
|
+
const c = pk[0];
|
|
323
|
+
return sql `${innerAlias}.${sql.identifier(c)} <> ${outerAlias}.${sql.identifier(c)}`;
|
|
324
|
+
}
|
|
325
|
+
// Composite PK: IS DISTINCT FROM tuple comparison.
|
|
326
|
+
const left = sql.join(pk.map((c) => sql `${innerAlias}.${sql.identifier(c)}`), ', ');
|
|
327
|
+
const right = sql.join(pk.map((c) => sql `${outerAlias}.${sql.identifier(c)}`), ', ');
|
|
328
|
+
return sql `(${left}) IS DISTINCT FROM (${right})`;
|
|
329
|
+
}
|
|
330
|
+
export const PostgisSpatialRelationsPlugin = {
|
|
331
|
+
name: 'PostgisSpatialRelationsPlugin',
|
|
332
|
+
version: '1.0.0',
|
|
333
|
+
description: 'Adds cross-table spatial filtering via @spatialRelation smart tags; ' +
|
|
334
|
+
'synthesises virtual relations whose EXISTS predicate uses PostGIS ops ' +
|
|
335
|
+
'instead of column equality.',
|
|
336
|
+
after: [
|
|
337
|
+
'PostgisExtensionDetectionPlugin',
|
|
338
|
+
'PostgisRegisterTypesPlugin',
|
|
339
|
+
'ConnectionFilterBackwardRelationsPlugin',
|
|
340
|
+
],
|
|
341
|
+
schema: {
|
|
342
|
+
hooks: {
|
|
343
|
+
build(build) {
|
|
344
|
+
const postgisInfo = build.pgGISExtensionInfo;
|
|
345
|
+
if (!postgisInfo)
|
|
346
|
+
return build;
|
|
347
|
+
const relations = collectSpatialRelations(build);
|
|
348
|
+
// Emit GIST-index warnings for target columns without a GIST index.
|
|
349
|
+
// Warnings never block schema build — we defer to the build logger.
|
|
350
|
+
const warn = build.console?.warn ?? console.warn;
|
|
351
|
+
for (const rel of relations) {
|
|
352
|
+
const targetAttr = rel.targetResource.codec.attributes?.[rel.targetAttributeName];
|
|
353
|
+
const indexes = rel.targetResource.extensions?.pg?.indexes;
|
|
354
|
+
let hasGist = false;
|
|
355
|
+
if (Array.isArray(indexes)) {
|
|
356
|
+
hasGist = indexes.some((idx) => idx &&
|
|
357
|
+
typeof idx === 'object' &&
|
|
358
|
+
idx.method === 'gist' &&
|
|
359
|
+
Array.isArray(idx.attributes) &&
|
|
360
|
+
idx.attributes.includes(rel.targetAttributeName));
|
|
361
|
+
}
|
|
362
|
+
// Introspection of indexes through @dataplan/pg isn't universally
|
|
363
|
+
// exposed; if we can't tell, stay quiet rather than cry wolf.
|
|
364
|
+
const canDiscoverIndexes = Array.isArray(indexes);
|
|
365
|
+
const skipCheck = targetAttr?.extensions?.tags?.spatialRelationSkipIndexCheck === true;
|
|
366
|
+
if (canDiscoverIndexes && !hasGist && !skipCheck) {
|
|
367
|
+
warn(`[graphile-postgis] Spatial relation '${rel.relationName}' ` +
|
|
368
|
+
`targets ${rel.targetResource.codec.name}.${rel.targetAttributeName} ` +
|
|
369
|
+
`which has no GIST index; expect sequential scans. ` +
|
|
370
|
+
`Recommended: CREATE INDEX ON ${rel.targetResource.codec.name} ` +
|
|
371
|
+
`USING GIST (${rel.targetAttributeName});`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return build.extend(build, { pgGISSpatialRelations: relations }, 'PostgisSpatialRelationsPlugin adding spatial relation registry');
|
|
375
|
+
},
|
|
376
|
+
init(_, build) {
|
|
377
|
+
if (!build.pgGISExtensionInfo)
|
|
378
|
+
return _;
|
|
379
|
+
const relations = build.pgGISSpatialRelations;
|
|
380
|
+
if (!relations || relations.length === 0)
|
|
381
|
+
return _;
|
|
382
|
+
for (const rel of relations) {
|
|
383
|
+
const typeName = spatialFilterTypeName(build, rel);
|
|
384
|
+
if (build.getTypeMetaByName(typeName))
|
|
385
|
+
continue;
|
|
386
|
+
const targetTypeName = build.inflection.tableType(rel.targetResource.codec);
|
|
387
|
+
build.recoverable(null, () => {
|
|
388
|
+
build.registerInputObjectType(typeName, {
|
|
389
|
+
// NOTE: intentionally NOT setting `isPgConnectionFilterMany`.
|
|
390
|
+
// That flag triggers ConnectionFilterBackwardRelationsPlugin
|
|
391
|
+
// (and friends) to auto-register `some`/`every`/`none` fields
|
|
392
|
+
// with FK-join semantics, which would collide with — and
|
|
393
|
+
// semantically differ from — ours. We own those fields here.
|
|
394
|
+
foreignTable: rel.targetResource,
|
|
395
|
+
isPgGISSpatialFilter: true,
|
|
396
|
+
pgGISSpatialRelation: rel,
|
|
397
|
+
}, () => ({
|
|
398
|
+
name: typeName,
|
|
399
|
+
description: `A filter on \`${targetTypeName}\` rows spatially related ` +
|
|
400
|
+
`to the current row via \`${rel.operator.name}\`. ` +
|
|
401
|
+
`All fields are combined with a logical \u2018and\u2019.`,
|
|
402
|
+
}), `PostgisSpatialRelationsPlugin adding '${typeName}' spatial filter type`);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return _;
|
|
406
|
+
},
|
|
407
|
+
GraphQLInputObjectType_fields(inFields, build, context) {
|
|
408
|
+
if (!build.pgGISExtensionInfo)
|
|
409
|
+
return inFields;
|
|
410
|
+
const relations = build.pgGISSpatialRelations;
|
|
411
|
+
if (!relations || relations.length === 0)
|
|
412
|
+
return inFields;
|
|
413
|
+
let fields = inFields;
|
|
414
|
+
const { extend, inflection, graphql: { GraphQLFloat, GraphQLNonNull }, EXPORTABLE, } = build;
|
|
415
|
+
const { fieldWithHooks, scope: { pgCodec, isPgConnectionFilter, isPgGISSpatialFilter, pgGISSpatialRelation, }, } = context;
|
|
416
|
+
const postgisInfo = build.pgGISExtensionInfo;
|
|
417
|
+
const { schemaName } = postgisInfo;
|
|
418
|
+
// ── Part 1: inject <relationName> on the owning codec's filter type
|
|
419
|
+
if (isPgConnectionFilter && pgCodec) {
|
|
420
|
+
const ownRelations = relations.filter((r) => r.ownerCodec === pgCodec);
|
|
421
|
+
for (const rel of ownRelations) {
|
|
422
|
+
const filterTypeName = spatialFilterTypeName(build, rel);
|
|
423
|
+
const FilterType = build.getTypeByName(filterTypeName);
|
|
424
|
+
if (!FilterType)
|
|
425
|
+
continue;
|
|
426
|
+
const fieldName = rel.relationName;
|
|
427
|
+
// Avoid clobbering fields an upstream plugin may have registered
|
|
428
|
+
// (e.g. an FK-derived relation with the same name).
|
|
429
|
+
if (fields[fieldName]) {
|
|
430
|
+
throw new Error(`[graphile-postgis] @spatialRelation '${rel.relationName}' on ` +
|
|
431
|
+
`codec '${rel.ownerCodec.name}' collides with an existing filter ` +
|
|
432
|
+
`field of the same name. Rename the spatial relation or the colliding field.`);
|
|
433
|
+
}
|
|
434
|
+
const targetTypeName = inflection.tableType(rel.targetResource.codec);
|
|
435
|
+
const relSnapshot = rel;
|
|
436
|
+
fields = extend(fields, {
|
|
437
|
+
[fieldName]: fieldWithHooks({
|
|
438
|
+
fieldName,
|
|
439
|
+
isPgConnectionFilterField: true,
|
|
440
|
+
isPgGISSpatialRelationField: true,
|
|
441
|
+
}, () => ({
|
|
442
|
+
description: `Filter by rows from \`${targetTypeName}\` related to this ` +
|
|
443
|
+
`row via \`${relSnapshot.operator.name}\`.`,
|
|
444
|
+
type: FilterType,
|
|
445
|
+
apply: EXPORTABLE((relationInfo) => function ($where, value) {
|
|
446
|
+
if (value == null)
|
|
447
|
+
return;
|
|
448
|
+
$where._spatialRelation = relationInfo;
|
|
449
|
+
// Parent apply runs BEFORE child field applies, so
|
|
450
|
+
// read the parametric value here (if any) and stash
|
|
451
|
+
// it on $where for some/every/none to consume. This
|
|
452
|
+
// avoids relying on input-field iteration order.
|
|
453
|
+
if (relationInfo.operator.parametric &&
|
|
454
|
+
relationInfo.paramFieldName) {
|
|
455
|
+
const raw = value[relationInfo.paramFieldName];
|
|
456
|
+
if (typeof raw !== 'number') {
|
|
457
|
+
throw Object.assign(new Error(`Spatial relation '${relationInfo.relationName}' requires ` +
|
|
458
|
+
`a numeric '${relationInfo.paramFieldName}' argument; got ${raw}`), {});
|
|
459
|
+
}
|
|
460
|
+
$where._spatialRelationParam = raw;
|
|
461
|
+
}
|
|
462
|
+
return $where;
|
|
463
|
+
}, [relSnapshot]),
|
|
464
|
+
})),
|
|
465
|
+
}, `PostgisSpatialRelationsPlugin adding '${fieldName}' field to ` +
|
|
466
|
+
`${inflection.filterType(inflection.tableType(rel.ownerCodec))}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// ── Part 2: inject some/every/none (+ optional distance) on the
|
|
470
|
+
// per-relation filter type.
|
|
471
|
+
if (isPgGISSpatialFilter && pgGISSpatialRelation) {
|
|
472
|
+
const rel = pgGISSpatialRelation;
|
|
473
|
+
const targetTypeName = inflection.tableType(rel.targetResource.codec);
|
|
474
|
+
const TargetFilterTypeName = inflection.filterType(targetTypeName);
|
|
475
|
+
const TargetFilterType = build.getTypeByName(TargetFilterTypeName);
|
|
476
|
+
if (!TargetFilterType)
|
|
477
|
+
return fields;
|
|
478
|
+
const paramFieldName = rel.paramFieldName;
|
|
479
|
+
// Parametric: add required <param> field (Float!). The parent
|
|
480
|
+
// relation field's apply reads the value from the input object
|
|
481
|
+
// directly — this field's apply is a no-op used only so the schema
|
|
482
|
+
// validates the input shape.
|
|
483
|
+
if (rel.operator.parametric && paramFieldName) {
|
|
484
|
+
fields = extend(fields, {
|
|
485
|
+
[paramFieldName]: fieldWithHooks({
|
|
486
|
+
fieldName: paramFieldName,
|
|
487
|
+
isPgConnectionFilterField: true,
|
|
488
|
+
isPgGISSpatialParamField: true,
|
|
489
|
+
}, () => ({
|
|
490
|
+
description: `Parametric argument for ${rel.operator.name} ` +
|
|
491
|
+
`(units: meters for geography, SRID units for geometry).`,
|
|
492
|
+
type: new GraphQLNonNull(GraphQLFloat),
|
|
493
|
+
apply: EXPORTABLE(() => function (_$where, _value) {
|
|
494
|
+
// No-op; parent apply already stashed the value.
|
|
495
|
+
}, []),
|
|
496
|
+
})),
|
|
497
|
+
}, `PostgisSpatialRelationsPlugin adding '${paramFieldName}' param to ` +
|
|
498
|
+
`${spatialFilterTypeName(build, rel)}`);
|
|
499
|
+
}
|
|
500
|
+
// Build the three apply() closures. `mode` selects the EXISTS
|
|
501
|
+
// variant: `'some'` => EXISTS, `'none'` => NOT EXISTS,
|
|
502
|
+
// `'every'` => NOT EXISTS (... AND NOT filter) via notPlan().
|
|
503
|
+
const buildApply = (mode) => EXPORTABLE((buildJoin, buildExcl, relationInfo, sqlSchemaName, sqlLib, applyMode) => function ($where, value) {
|
|
504
|
+
if (value == null)
|
|
505
|
+
return;
|
|
506
|
+
const foreignTable = relationInfo.targetResource;
|
|
507
|
+
const foreignTableExpression = foreignTable.from;
|
|
508
|
+
const existsOpts = {
|
|
509
|
+
tableExpression: foreignTableExpression,
|
|
510
|
+
alias: foreignTable.name,
|
|
511
|
+
};
|
|
512
|
+
if (applyMode !== 'some') {
|
|
513
|
+
existsOpts.equals = false;
|
|
514
|
+
}
|
|
515
|
+
const $subQuery = $where.existsPlan(existsOpts);
|
|
516
|
+
const outerAlias = $where.alias;
|
|
517
|
+
const innerAlias = $subQuery.alias;
|
|
518
|
+
let distance = null;
|
|
519
|
+
if (relationInfo.operator.parametric) {
|
|
520
|
+
const raw = $where._spatialRelationParam;
|
|
521
|
+
if (raw == null || typeof raw !== 'number') {
|
|
522
|
+
throw Object.assign(new Error(`Spatial relation '${relationInfo.relationName}' requires a ` +
|
|
523
|
+
`'${relationInfo.paramFieldName}' value; got ${raw}`), {});
|
|
524
|
+
}
|
|
525
|
+
distance = sqlLib.value(raw);
|
|
526
|
+
}
|
|
527
|
+
$subQuery.where(buildJoin(relationInfo, sqlSchemaName, outerAlias, innerAlias, distance));
|
|
528
|
+
const exclusion = buildExcl(relationInfo, outerAlias, innerAlias);
|
|
529
|
+
if (exclusion) {
|
|
530
|
+
$subQuery.where(exclusion);
|
|
531
|
+
}
|
|
532
|
+
if (applyMode === 'every') {
|
|
533
|
+
return $subQuery.notPlan();
|
|
534
|
+
}
|
|
535
|
+
return $subQuery;
|
|
536
|
+
}, [
|
|
537
|
+
buildSpatialJoinFragment,
|
|
538
|
+
buildSelfExclusionFragment,
|
|
539
|
+
rel,
|
|
540
|
+
schemaName,
|
|
541
|
+
sql,
|
|
542
|
+
mode,
|
|
543
|
+
]);
|
|
544
|
+
fields = extend(fields, {
|
|
545
|
+
some: fieldWithHooks({
|
|
546
|
+
fieldName: 'some',
|
|
547
|
+
isPgConnectionFilterField: true,
|
|
548
|
+
}, () => ({
|
|
549
|
+
description: 'Filters to entities where at least one spatially-related entity matches.',
|
|
550
|
+
type: TargetFilterType,
|
|
551
|
+
apply: buildApply('some'),
|
|
552
|
+
})),
|
|
553
|
+
every: fieldWithHooks({
|
|
554
|
+
fieldName: 'every',
|
|
555
|
+
isPgConnectionFilterField: true,
|
|
556
|
+
}, () => ({
|
|
557
|
+
description: 'Filters to entities where every spatially-related entity matches.',
|
|
558
|
+
type: TargetFilterType,
|
|
559
|
+
apply: buildApply('every'),
|
|
560
|
+
})),
|
|
561
|
+
none: fieldWithHooks({
|
|
562
|
+
fieldName: 'none',
|
|
563
|
+
isPgConnectionFilterField: true,
|
|
564
|
+
}, () => ({
|
|
565
|
+
description: 'Filters to entities where no spatially-related entity matches.',
|
|
566
|
+
type: TargetFilterType,
|
|
567
|
+
apply: buildApply('none'),
|
|
568
|
+
})),
|
|
569
|
+
}, `PostgisSpatialRelationsPlugin adding some/every/none to ${spatialFilterTypeName(build, rel)}`);
|
|
570
|
+
}
|
|
571
|
+
return fields;
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
};
|
package/esm/preset.js
CHANGED
|
@@ -6,6 +6,7 @@ import { PostgisGeometryFieldsPlugin } from './plugins/geometry-fields';
|
|
|
6
6
|
import { PostgisMeasurementFieldsPlugin } from './plugins/measurement-fields';
|
|
7
7
|
import { PostgisTransformationFieldsPlugin } from './plugins/transformation-functions';
|
|
8
8
|
import { PostgisAggregatePlugin } from './plugins/aggregate-functions';
|
|
9
|
+
import { PostgisSpatialRelationsPlugin } from './plugins/spatial-relations';
|
|
9
10
|
import { createPostgisOperatorFactory } from './plugins/connection-filter-operators';
|
|
10
11
|
import { createWithinDistanceOperatorFactory } from './plugins/within-distance-operator';
|
|
11
12
|
/**
|
|
@@ -42,7 +43,8 @@ export const GraphilePostgisPreset = {
|
|
|
42
43
|
PostgisGeometryFieldsPlugin,
|
|
43
44
|
PostgisMeasurementFieldsPlugin,
|
|
44
45
|
PostgisTransformationFieldsPlugin,
|
|
45
|
-
PostgisAggregatePlugin
|
|
46
|
+
PostgisAggregatePlugin,
|
|
47
|
+
PostgisSpatialRelationsPlugin,
|
|
46
48
|
],
|
|
47
49
|
schema: {
|
|
48
50
|
// connectionFilterOperatorFactories is augmented by graphile-connection-filter
|
package/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/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* ```
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.getGISTypeName = exports.getGISTypeModifier = exports.getGISTypeDetails = exports.CONCRETE_SUBTYPES = exports.GIS_SUBTYPE_NAME = exports.SUBTYPE_STRING_BY_SUBTYPE = exports.GisSubtype = exports.createWithinDistanceOperatorFactory = exports.createPostgisOperatorFactory = exports.PostgisAggregatePlugin = exports.PostgisTransformationFieldsPlugin = exports.PostgisMeasurementFieldsPlugin = exports.PostgisGeometryFieldsPlugin = exports.PostgisRegisterTypesPlugin = exports.PostgisExtensionDetectionPlugin = exports.PostgisInflectionPlugin = exports.PostgisCodecPlugin = exports.GraphilePostgisPreset = void 0;
|
|
17
|
+
exports.getGISTypeName = exports.getGISTypeModifier = exports.getGISTypeDetails = exports.CONCRETE_SUBTYPES = exports.GIS_SUBTYPE_NAME = exports.SUBTYPE_STRING_BY_SUBTYPE = exports.GisSubtype = exports.createWithinDistanceOperatorFactory = exports.createPostgisOperatorFactory = exports.collectSpatialRelations = exports.parseSpatialRelationTag = exports.OPERATOR_REGISTRY = exports.PostgisSpatialRelationsPlugin = exports.PostgisAggregatePlugin = exports.PostgisTransformationFieldsPlugin = exports.PostgisMeasurementFieldsPlugin = exports.PostgisGeometryFieldsPlugin = exports.PostgisRegisterTypesPlugin = exports.PostgisExtensionDetectionPlugin = exports.PostgisInflectionPlugin = exports.PostgisCodecPlugin = exports.GraphilePostgisPreset = void 0;
|
|
18
18
|
// Preset (recommended entry point)
|
|
19
19
|
var preset_1 = require("./preset");
|
|
20
20
|
Object.defineProperty(exports, "GraphilePostgisPreset", { enumerable: true, get: function () { return preset_1.GraphilePostgisPreset; } });
|
|
@@ -35,6 +35,11 @@ var transformation_functions_1 = require("./plugins/transformation-functions");
|
|
|
35
35
|
Object.defineProperty(exports, "PostgisTransformationFieldsPlugin", { enumerable: true, get: function () { return transformation_functions_1.PostgisTransformationFieldsPlugin; } });
|
|
36
36
|
var aggregate_functions_1 = require("./plugins/aggregate-functions");
|
|
37
37
|
Object.defineProperty(exports, "PostgisAggregatePlugin", { enumerable: true, get: function () { return aggregate_functions_1.PostgisAggregatePlugin; } });
|
|
38
|
+
var spatial_relations_1 = require("./plugins/spatial-relations");
|
|
39
|
+
Object.defineProperty(exports, "PostgisSpatialRelationsPlugin", { enumerable: true, get: function () { return spatial_relations_1.PostgisSpatialRelationsPlugin; } });
|
|
40
|
+
Object.defineProperty(exports, "OPERATOR_REGISTRY", { enumerable: true, get: function () { return spatial_relations_1.OPERATOR_REGISTRY; } });
|
|
41
|
+
Object.defineProperty(exports, "parseSpatialRelationTag", { enumerable: true, get: function () { return spatial_relations_1.parseSpatialRelationTag; } });
|
|
42
|
+
Object.defineProperty(exports, "collectSpatialRelations", { enumerable: true, get: function () { return spatial_relations_1.collectSpatialRelations; } });
|
|
38
43
|
// Connection filter operator factories (spatial operators for graphile-connection-filter)
|
|
39
44
|
var connection_filter_operators_1 = require("./plugins/connection-filter-operators");
|
|
40
45
|
Object.defineProperty(exports, "createPostgisOperatorFactory", { enumerable: true, get: function () { return connection_filter_operators_1.createPostgisOperatorFactory; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-postgis",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
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.
|
|
49
|
+
"graphile-connection-filter": "^1.5.0",
|
|
50
50
|
"graphql": "16.13.0",
|
|
51
51
|
"pg-sql2": "5.0.0",
|
|
52
52
|
"postgraphile": "5.0.0"
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/geojson": "^7946.0.14",
|
|
61
61
|
"@types/node": "^22.19.11",
|
|
62
|
-
"graphile-test": "^4.
|
|
62
|
+
"graphile-test": "^4.9.0",
|
|
63
63
|
"makage": "^0.3.0",
|
|
64
|
-
"pgsql-test": "^4.
|
|
64
|
+
"pgsql-test": "^4.9.0"
|
|
65
65
|
},
|
|
66
|
-
"gitHead": "
|
|
66
|
+
"gitHead": "1b3af3c5189b9ca2e765b9239a4b287099e64a03"
|
|
67
67
|
}
|