Skip to content

Commit 1281a53

Browse files
authored
Merge pull request #992 from constructive-io/feat/postgis-spatial-fix-and-tests
fix+test(postgis): ST_GeomFromGeoJSON wrap for spatial filters + regression suite (#724)
2 parents 331d894 + 2cc92df commit 1281a53

6 files changed

Lines changed: 1229 additions & 66 deletions

File tree

.github/workflows/run-tests.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ jobs:
7373
env: {}
7474
- package: graphile/graphile-connection-filter
7575
env: {}
76+
- package: graphile/graphile-postgis
77+
env: {}
78+
- package: graphql/orm-test
79+
env: {}
7680
- package: graphql/server-test
7781
env: {}
7882
- package: graphql/env

graphile/graphile-postgis/__tests__/connection-filter-integration.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,43 @@ describe('Integration: connection-filter OperatorSpec compatibility', () => {
147147
}
148148
}
149149
});
150+
151+
// Regression guard for constructive-io/constructive-planning#724.
152+
//
153+
// Spatial filter operators receive GeoJSON input that must be wrapped with
154+
// ST_GeomFromGeoJSON(...)::<codec> before hitting PostgreSQL — PG's
155+
// geometry_in / geography_in parsers do NOT accept GeoJSON text cast to
156+
// geometry / geography directly.
157+
//
158+
// graphile-connection-filter's operatorApply falls through to
159+
// `sqlValueWithCodec(resolvedInput, inputCodec)` (a raw text bind cast to
160+
// the codec's sqlType) unless the operator spec overrides the binding via
161+
// `resolveSqlValue`, `resolveInput`, or `resolveInputCodec`. Without one
162+
// of those overrides the operator is broken end-to-end on both codecs.
163+
//
164+
// See plugins/within-distance-operator.ts for the correct pattern
165+
// (`resolveSqlValue: () => sql.null` + manual ST_GeomFromGeoJSON wrap in
166+
// resolve()).
167+
it('every spec overrides value binding so GeoJSON is wrapped with ST_GeomFromGeoJSON', () => {
168+
const { registered } = runFactory();
169+
170+
for (const { spec, operatorName, typeName } of registered) {
171+
const hasBindingOverride =
172+
typeof spec.resolveSqlValue === 'function' ||
173+
typeof spec.resolveInput === 'function' ||
174+
spec.resolveInputCodec !== undefined;
175+
176+
expect({
177+
operatorName,
178+
typeName,
179+
hasBindingOverride,
180+
}).toEqual({
181+
operatorName,
182+
typeName,
183+
hasBindingOverride: true,
184+
});
185+
}
186+
});
150187
});
151188

152189
describe('Integration: type name generation matches graphile-postgis', () => {

graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts

Lines changed: 75 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -236,34 +236,49 @@ describe('PostGIS operator factory (createPostgisOperatorFactory)', () => {
236236

237237
describe('SQL generation', () => {
238238
describe('function-based operators', () => {
239-
it('generates schema-qualified SQL for public schema', () => {
239+
// The input-binding contract (regression guard for #724): every
240+
// operator must wrap the GeoJSON input with ST_GeomFromGeoJSON(...)
241+
// — PostgreSQL's geometry_in / geography_in parsers reject raw
242+
// GeoJSON text. The compiled SQL must therefore always contain the
243+
// schema-qualified function call binding the JSON-encoded input as
244+
// `::text` before any further casting.
245+
246+
it('wraps input with ST_GeomFromGeoJSON (public schema)', () => {
240247
const { registered } = runFactory({ schemaName: 'public' });
241248
const containsOp = registered.find(r => r.operatorName === 'contains');
242249
expect(containsOp).toBeDefined();
243250

244251
const i = sql.identifier('col');
245252
const v = sql.identifier('val');
246-
const result = containsOp!.resolve(i, v, null, null, {
253+
const input = { type: 'Point', coordinates: [-122.4194, 37.7749] };
254+
const result = containsOp!.resolve(i, v, input, null, {
247255
fieldName: null,
248256
operatorName: 'contains'
249257
});
250258
const compiled = sql.compile(result);
251-
expect(compiled.text).toBe('"public"."st_contains"("col", "val")');
259+
expect(compiled.text).toBe(
260+
'"public"."st_contains"("col", "public"."st_geomfromgeojson"($1::text))'
261+
);
262+
expect(compiled.values).toEqual([JSON.stringify(input)]);
252263
});
253264

254-
it('generates schema-qualified SQL for non-public schema', () => {
265+
it('wraps input with ST_GeomFromGeoJSON (non-public schema)', () => {
255266
const { registered } = runFactory({ schemaName: 'postgis' });
256267
const containsOp = registered.find(r => r.operatorName === 'contains');
257268
expect(containsOp).toBeDefined();
258269

259270
const i = sql.identifier('col');
260271
const v = sql.identifier('val');
261-
const result = containsOp!.resolve(i, v, null, null, {
272+
const input = { type: 'Point', coordinates: [-122.4194, 37.7749] };
273+
const result = containsOp!.resolve(i, v, input, null, {
262274
fieldName: null,
263275
operatorName: 'contains'
264276
});
265277
const compiled = sql.compile(result);
266-
expect(compiled.text).toBe('"postgis"."st_contains"("col", "val")');
278+
expect(compiled.text).toBe(
279+
'"postgis"."st_contains"("col", "postgis"."st_geomfromgeojson"($1::text))'
280+
);
281+
expect(compiled.values).toEqual([JSON.stringify(input)]);
267282
});
268283

269284
it('lowercases function names in SQL', () => {
@@ -273,89 +288,86 @@ describe('PostGIS operator factory (createPostgisOperatorFactory)', () => {
273288

274289
const i = sql.identifier('a');
275290
const v = sql.identifier('b');
276-
const result = op3d!.resolve(i, v, null, null, {
291+
const input = { type: 'Point', coordinates: [-122.4194, 37.7749, 254] };
292+
const result = op3d!.resolve(i, v, input, null, {
277293
fieldName: null,
278294
operatorName: 'intersects3D'
279295
});
280296
const compiled = sql.compile(result);
281-
expect(compiled.text).toBe('"public"."st_3dintersects"("a", "b")');
297+
expect(compiled.text).toBe(
298+
'"public"."st_3dintersects"("a", "public"."st_geomfromgeojson"($1::text))'
299+
);
282300
});
283-
});
284301

285-
describe('SQL operator-based operators', () => {
286-
it('generates correct SQL for = operator', () => {
302+
it('casts to geography when the operator is registered on a geography type', () => {
287303
const { registered } = runFactory();
288-
const exactOp = registered.find(r => r.operatorName === 'exactlyEquals');
289-
expect(exactOp).toBeDefined();
304+
// `intersects` is registered for both geometry and geography. We
305+
// want the geography variant to append `::geography` after the
306+
// ST_GeomFromGeoJSON wrap so PostGIS picks the geography overload.
307+
const geogIntersects = registered.find(
308+
r => r.operatorName === 'intersects' && r.typeName === 'GeographyPoint'
309+
);
310+
expect(geogIntersects).toBeDefined();
290311

291312
const i = sql.identifier('col');
292313
const v = sql.identifier('val');
293-
const result = exactOp!.resolve(i, v, null, null, {
314+
const input = { type: 'Point', coordinates: [-122.4194, 37.7749] };
315+
const result = geogIntersects!.resolve(i, v, input, null, {
294316
fieldName: null,
295-
operatorName: 'exactlyEquals'
317+
operatorName: 'intersects'
296318
});
297319
const compiled = sql.compile(result);
298-
expect(compiled.text).toBe('"col" = "val"');
320+
expect(compiled.text).toBe(
321+
'"public"."st_intersects"("col", "public"."st_geomfromgeojson"($1::text)::"public"."geography")'
322+
);
299323
});
324+
});
300325

301-
it('generates correct SQL for && operator', () => {
326+
describe('SQL operator-based operators', () => {
327+
const runOp = (operatorName: string) => {
302328
const { registered } = runFactory();
303-
const bboxOp = registered.find(r => r.operatorName === 'bboxIntersects2D');
304-
expect(bboxOp).toBeDefined();
329+
const op = registered.find(r => r.operatorName === operatorName);
330+
expect(op).toBeDefined();
305331

306-
const i = sql.identifier('col');
307-
const v = sql.identifier('val');
308-
const result = bboxOp!.resolve(i, v, null, null, {
309-
fieldName: null,
310-
operatorName: 'bboxIntersects2D'
311-
});
312-
const compiled = sql.compile(result);
313-
expect(compiled.text).toBe('"col" && "val"');
332+
const input = { type: 'Point', coordinates: [-122.4194, 37.7749] };
333+
const result = op!.resolve(
334+
sql.identifier('col'),
335+
sql.identifier('val'),
336+
input,
337+
null,
338+
{ fieldName: null, operatorName }
339+
);
340+
return sql.compile(result);
341+
};
342+
343+
it('generates correct SQL for = operator', () => {
344+
expect(runOp('exactlyEquals').text).toBe(
345+
'"col" = "public"."st_geomfromgeojson"($1::text)'
346+
);
314347
});
315348

316-
it('generates correct SQL for ~ operator', () => {
317-
const { registered } = runFactory();
318-
const bboxContainsOp = registered.find(r => r.operatorName === 'bboxContains');
319-
expect(bboxContainsOp).toBeDefined();
349+
it('generates correct SQL for && operator', () => {
350+
expect(runOp('bboxIntersects2D').text).toBe(
351+
'"col" && "public"."st_geomfromgeojson"($1::text)'
352+
);
353+
});
320354

321-
const i = sql.identifier('col');
322-
const v = sql.identifier('val');
323-
const result = bboxContainsOp!.resolve(i, v, null, null, {
324-
fieldName: null,
325-
operatorName: 'bboxContains'
326-
});
327-
const compiled = sql.compile(result);
328-
expect(compiled.text).toBe('"col" ~ "val"');
355+
it('generates correct SQL for ~ operator', () => {
356+
expect(runOp('bboxContains').text).toBe(
357+
'"col" ~ "public"."st_geomfromgeojson"($1::text)'
358+
);
329359
});
330360

331361
it('generates correct SQL for ~= operator', () => {
332-
const { registered } = runFactory();
333-
const bboxEqOp = registered.find(r => r.operatorName === 'bboxEquals');
334-
expect(bboxEqOp).toBeDefined();
335-
336-
const i = sql.identifier('col');
337-
const v = sql.identifier('val');
338-
const result = bboxEqOp!.resolve(i, v, null, null, {
339-
fieldName: null,
340-
operatorName: 'bboxEquals'
341-
});
342-
const compiled = sql.compile(result);
343-
expect(compiled.text).toBe('"col" ~= "val"');
362+
expect(runOp('bboxEquals').text).toBe(
363+
'"col" ~= "public"."st_geomfromgeojson"($1::text)'
364+
);
344365
});
345366

346367
it('generates correct SQL for &&& operator', () => {
347-
const { registered } = runFactory();
348-
const ndOp = registered.find(r => r.operatorName === 'bboxIntersectsND');
349-
expect(ndOp).toBeDefined();
350-
351-
const i = sql.identifier('col');
352-
const v = sql.identifier('val');
353-
const result = ndOp!.resolve(i, v, null, null, {
354-
fieldName: null,
355-
operatorName: 'bboxIntersectsND'
356-
});
357-
const compiled = sql.compile(result);
358-
expect(compiled.text).toBe('"col" &&& "val"');
368+
expect(runOp('bboxIntersectsND').text).toBe(
369+
'"col" &&& "public"."st_geomfromgeojson"($1::text)'
370+
);
359371
});
360372
});
361373
});

graphile/graphile-postgis/src/plugins/connection-filter-operators.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory
221221
const { inflection } = build;
222222
const { schemaName, geometryCodec, geographyCodec } = postgisInfo;
223223

224+
const sqlGeomFromGeoJSON = sql.identifier(schemaName, 'st_geomfromgeojson');
225+
const sqlGeographyType = sql.identifier(schemaName, 'geography');
226+
224227
// Collect all GQL type names for geometry and geography
225228
const gqlTypeNamesByBase: Record<string, string[]> = {
226229
geometry: [],
@@ -254,6 +257,7 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory
254257
typeNames: string[];
255258
operatorName: string;
256259
description: string;
260+
baseType: 'geometry' | 'geography';
257261
resolve: (i: SQL, v: SQL) => SQL;
258262
}
259263
const allSpecs: InternalSpec[] = [];
@@ -267,6 +271,7 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory
267271
typeNames: gqlTypeNamesByBase[baseType],
268272
operatorName,
269273
description,
274+
baseType: baseType as 'geometry' | 'geography',
270275
resolve: (i: SQL, v: SQL) => sql.fragment`${sqlGisFunction}(${i}, ${v})`
271276
});
272277
}
@@ -281,6 +286,7 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory
281286
typeNames: gqlTypeNamesByBase[baseType],
282287
operatorName,
283288
description,
289+
baseType: baseType as 'geometry' | 'geography',
284290
resolve: (i: SQL, v: SQL) => buildOperatorExpr(capturedOp, i, v)
285291
});
286292
}
@@ -292,23 +298,40 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory
292298
// Convert to ConnectionFilterOperatorRegistration format.
293299
// Each InternalSpec may target multiple type names; we expand each
294300
// into individual registrations keyed by typeName.
301+
//
302+
// The default operatorApply pipeline binds the filter value as a raw
303+
// text parameter cast to the column codec's sqlType (geometry /
304+
// geography). PostgreSQL's geometry_in / geography_in parsers reject
305+
// GeoJSON text, so we must wrap the input with ST_GeomFromGeoJSON
306+
// ourselves — see within-distance-operator.ts for the pattern.
307+
//
308+
// We disable the default bind via `resolveSqlValue: () => sql.null`
309+
// and construct the geometry value from `input` inside resolve(),
310+
// mirroring the ST_DWithin implementation.
295311
const registrations: ConnectionFilterOperatorRegistration[] = [];
296312
for (const spec of allSpecs) {
313+
const geographyCast = spec.baseType === 'geography'
314+
? sql.fragment`::${sqlGeographyType}`
315+
: sql.fragment``;
316+
297317
for (const typeName of spec.typeNames) {
298318
registrations.push({
299319
typeNames: typeName,
300320
operatorName: spec.operatorName,
301321
spec: {
302322
description: spec.description,
303323
resolveType: (fieldType) => fieldType,
324+
resolveSqlValue: () => sql.null,
304325
resolve(
305326
sqlIdentifier: SQL,
306-
sqlValue: SQL,
307-
_input: unknown,
327+
_sqlValue: SQL,
328+
input: unknown,
308329
_$where: any,
309330
_details: { fieldName: string | null; operatorName: string }
310331
) {
311-
return spec.resolve(sqlIdentifier, sqlValue);
332+
const geoJsonStr = sql.value(JSON.stringify(input));
333+
const geomSql = sql.fragment`${sqlGeomFromGeoJSON}(${geoJsonStr}::text)${geographyCast}`;
334+
return spec.resolve(sqlIdentifier, geomSql);
312335
}
313336
} satisfies ConnectionFilterOperatorSpec,
314337
});

0 commit comments

Comments
 (0)