diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 099cbaa1e..7d7bd52b1 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -73,6 +73,10 @@ jobs: env: {} - package: graphile/graphile-connection-filter env: {} + - package: graphile/graphile-postgis + env: {} + - package: graphql/orm-test + env: {} - package: graphql/server-test env: {} - package: graphql/env diff --git a/graphile/graphile-postgis/__tests__/connection-filter-integration.test.ts b/graphile/graphile-postgis/__tests__/connection-filter-integration.test.ts index c95c3fbc0..8479d5698 100644 --- a/graphile/graphile-postgis/__tests__/connection-filter-integration.test.ts +++ b/graphile/graphile-postgis/__tests__/connection-filter-integration.test.ts @@ -147,6 +147,43 @@ describe('Integration: connection-filter OperatorSpec compatibility', () => { } } }); + + // Regression guard for constructive-io/constructive-planning#724. + // + // Spatial filter operators receive GeoJSON input that must be wrapped with + // ST_GeomFromGeoJSON(...):: before hitting PostgreSQL — PG's + // geometry_in / geography_in parsers do NOT accept GeoJSON text cast to + // geometry / geography directly. + // + // graphile-connection-filter's operatorApply falls through to + // `sqlValueWithCodec(resolvedInput, inputCodec)` (a raw text bind cast to + // the codec's sqlType) unless the operator spec overrides the binding via + // `resolveSqlValue`, `resolveInput`, or `resolveInputCodec`. Without one + // of those overrides the operator is broken end-to-end on both codecs. + // + // See plugins/within-distance-operator.ts for the correct pattern + // (`resolveSqlValue: () => sql.null` + manual ST_GeomFromGeoJSON wrap in + // resolve()). + it('every spec overrides value binding so GeoJSON is wrapped with ST_GeomFromGeoJSON', () => { + const { registered } = runFactory(); + + for (const { spec, operatorName, typeName } of registered) { + const hasBindingOverride = + typeof spec.resolveSqlValue === 'function' || + typeof spec.resolveInput === 'function' || + spec.resolveInputCodec !== undefined; + + expect({ + operatorName, + typeName, + hasBindingOverride, + }).toEqual({ + operatorName, + typeName, + hasBindingOverride: true, + }); + } + }); }); describe('Integration: type name generation matches graphile-postgis', () => { diff --git a/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts b/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts index e68588f68..7e8e9c068 100644 --- a/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts +++ b/graphile/graphile-postgis/__tests__/connection-filter-operators.test.ts @@ -236,34 +236,49 @@ describe('PostGIS operator factory (createPostgisOperatorFactory)', () => { describe('SQL generation', () => { describe('function-based operators', () => { - it('generates schema-qualified SQL for public schema', () => { + // The input-binding contract (regression guard for #724): every + // operator must wrap the GeoJSON input with ST_GeomFromGeoJSON(...) + // — PostgreSQL's geometry_in / geography_in parsers reject raw + // GeoJSON text. The compiled SQL must therefore always contain the + // schema-qualified function call binding the JSON-encoded input as + // `::text` before any further casting. + + it('wraps input with ST_GeomFromGeoJSON (public schema)', () => { const { registered } = runFactory({ schemaName: 'public' }); const containsOp = registered.find(r => r.operatorName === 'contains'); expect(containsOp).toBeDefined(); const i = sql.identifier('col'); const v = sql.identifier('val'); - const result = containsOp!.resolve(i, v, null, null, { + const input = { type: 'Point', coordinates: [-122.4194, 37.7749] }; + const result = containsOp!.resolve(i, v, input, null, { fieldName: null, operatorName: 'contains' }); const compiled = sql.compile(result); - expect(compiled.text).toBe('"public"."st_contains"("col", "val")'); + expect(compiled.text).toBe( + '"public"."st_contains"("col", "public"."st_geomfromgeojson"($1::text))' + ); + expect(compiled.values).toEqual([JSON.stringify(input)]); }); - it('generates schema-qualified SQL for non-public schema', () => { + it('wraps input with ST_GeomFromGeoJSON (non-public schema)', () => { const { registered } = runFactory({ schemaName: 'postgis' }); const containsOp = registered.find(r => r.operatorName === 'contains'); expect(containsOp).toBeDefined(); const i = sql.identifier('col'); const v = sql.identifier('val'); - const result = containsOp!.resolve(i, v, null, null, { + const input = { type: 'Point', coordinates: [-122.4194, 37.7749] }; + const result = containsOp!.resolve(i, v, input, null, { fieldName: null, operatorName: 'contains' }); const compiled = sql.compile(result); - expect(compiled.text).toBe('"postgis"."st_contains"("col", "val")'); + expect(compiled.text).toBe( + '"postgis"."st_contains"("col", "postgis"."st_geomfromgeojson"($1::text))' + ); + expect(compiled.values).toEqual([JSON.stringify(input)]); }); it('lowercases function names in SQL', () => { @@ -273,89 +288,86 @@ describe('PostGIS operator factory (createPostgisOperatorFactory)', () => { const i = sql.identifier('a'); const v = sql.identifier('b'); - const result = op3d!.resolve(i, v, null, null, { + const input = { type: 'Point', coordinates: [-122.4194, 37.7749, 254] }; + const result = op3d!.resolve(i, v, input, null, { fieldName: null, operatorName: 'intersects3D' }); const compiled = sql.compile(result); - expect(compiled.text).toBe('"public"."st_3dintersects"("a", "b")'); + expect(compiled.text).toBe( + '"public"."st_3dintersects"("a", "public"."st_geomfromgeojson"($1::text))' + ); }); - }); - describe('SQL operator-based operators', () => { - it('generates correct SQL for = operator', () => { + it('casts to geography when the operator is registered on a geography type', () => { const { registered } = runFactory(); - const exactOp = registered.find(r => r.operatorName === 'exactlyEquals'); - expect(exactOp).toBeDefined(); + // `intersects` is registered for both geometry and geography. We + // want the geography variant to append `::geography` after the + // ST_GeomFromGeoJSON wrap so PostGIS picks the geography overload. + const geogIntersects = registered.find( + r => r.operatorName === 'intersects' && r.typeName === 'GeographyPoint' + ); + expect(geogIntersects).toBeDefined(); const i = sql.identifier('col'); const v = sql.identifier('val'); - const result = exactOp!.resolve(i, v, null, null, { + const input = { type: 'Point', coordinates: [-122.4194, 37.7749] }; + const result = geogIntersects!.resolve(i, v, input, null, { fieldName: null, - operatorName: 'exactlyEquals' + operatorName: 'intersects' }); const compiled = sql.compile(result); - expect(compiled.text).toBe('"col" = "val"'); + expect(compiled.text).toBe( + '"public"."st_intersects"("col", "public"."st_geomfromgeojson"($1::text)::"public"."geography")' + ); }); + }); - it('generates correct SQL for && operator', () => { + describe('SQL operator-based operators', () => { + const runOp = (operatorName: string) => { const { registered } = runFactory(); - const bboxOp = registered.find(r => r.operatorName === 'bboxIntersects2D'); - expect(bboxOp).toBeDefined(); + const op = registered.find(r => r.operatorName === operatorName); + expect(op).toBeDefined(); - const i = sql.identifier('col'); - const v = sql.identifier('val'); - const result = bboxOp!.resolve(i, v, null, null, { - fieldName: null, - operatorName: 'bboxIntersects2D' - }); - const compiled = sql.compile(result); - expect(compiled.text).toBe('"col" && "val"'); + const input = { type: 'Point', coordinates: [-122.4194, 37.7749] }; + const result = op!.resolve( + sql.identifier('col'), + sql.identifier('val'), + input, + null, + { fieldName: null, operatorName } + ); + return sql.compile(result); + }; + + it('generates correct SQL for = operator', () => { + expect(runOp('exactlyEquals').text).toBe( + '"col" = "public"."st_geomfromgeojson"($1::text)' + ); }); - it('generates correct SQL for ~ operator', () => { - const { registered } = runFactory(); - const bboxContainsOp = registered.find(r => r.operatorName === 'bboxContains'); - expect(bboxContainsOp).toBeDefined(); + it('generates correct SQL for && operator', () => { + expect(runOp('bboxIntersects2D').text).toBe( + '"col" && "public"."st_geomfromgeojson"($1::text)' + ); + }); - const i = sql.identifier('col'); - const v = sql.identifier('val'); - const result = bboxContainsOp!.resolve(i, v, null, null, { - fieldName: null, - operatorName: 'bboxContains' - }); - const compiled = sql.compile(result); - expect(compiled.text).toBe('"col" ~ "val"'); + it('generates correct SQL for ~ operator', () => { + expect(runOp('bboxContains').text).toBe( + '"col" ~ "public"."st_geomfromgeojson"($1::text)' + ); }); it('generates correct SQL for ~= operator', () => { - const { registered } = runFactory(); - const bboxEqOp = registered.find(r => r.operatorName === 'bboxEquals'); - expect(bboxEqOp).toBeDefined(); - - const i = sql.identifier('col'); - const v = sql.identifier('val'); - const result = bboxEqOp!.resolve(i, v, null, null, { - fieldName: null, - operatorName: 'bboxEquals' - }); - const compiled = sql.compile(result); - expect(compiled.text).toBe('"col" ~= "val"'); + expect(runOp('bboxEquals').text).toBe( + '"col" ~= "public"."st_geomfromgeojson"($1::text)' + ); }); it('generates correct SQL for &&& operator', () => { - const { registered } = runFactory(); - const ndOp = registered.find(r => r.operatorName === 'bboxIntersectsND'); - expect(ndOp).toBeDefined(); - - const i = sql.identifier('col'); - const v = sql.identifier('val'); - const result = ndOp!.resolve(i, v, null, null, { - fieldName: null, - operatorName: 'bboxIntersectsND' - }); - const compiled = sql.compile(result); - expect(compiled.text).toBe('"col" &&& "val"'); + expect(runOp('bboxIntersectsND').text).toBe( + '"col" &&& "public"."st_geomfromgeojson"($1::text)' + ); }); }); }); diff --git a/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts b/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts index 63c6be07e..777649a22 100644 --- a/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts +++ b/graphile/graphile-postgis/src/plugins/connection-filter-operators.ts @@ -221,6 +221,9 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory const { inflection } = build; const { schemaName, geometryCodec, geographyCodec } = postgisInfo; + const sqlGeomFromGeoJSON = sql.identifier(schemaName, 'st_geomfromgeojson'); + const sqlGeographyType = sql.identifier(schemaName, 'geography'); + // Collect all GQL type names for geometry and geography const gqlTypeNamesByBase: Record = { geometry: [], @@ -254,6 +257,7 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory typeNames: string[]; operatorName: string; description: string; + baseType: 'geometry' | 'geography'; resolve: (i: SQL, v: SQL) => SQL; } const allSpecs: InternalSpec[] = []; @@ -267,6 +271,7 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory typeNames: gqlTypeNamesByBase[baseType], operatorName, description, + baseType: baseType as 'geometry' | 'geography', resolve: (i: SQL, v: SQL) => sql.fragment`${sqlGisFunction}(${i}, ${v})` }); } @@ -281,6 +286,7 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory typeNames: gqlTypeNamesByBase[baseType], operatorName, description, + baseType: baseType as 'geometry' | 'geography', resolve: (i: SQL, v: SQL) => buildOperatorExpr(capturedOp, i, v) }); } @@ -292,8 +298,22 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory // Convert to ConnectionFilterOperatorRegistration format. // Each InternalSpec may target multiple type names; we expand each // into individual registrations keyed by typeName. + // + // The default operatorApply pipeline binds the filter value as a raw + // text parameter cast to the column codec's sqlType (geometry / + // geography). PostgreSQL's geometry_in / geography_in parsers reject + // GeoJSON text, so we must wrap the input with ST_GeomFromGeoJSON + // ourselves — see within-distance-operator.ts for the pattern. + // + // We disable the default bind via `resolveSqlValue: () => sql.null` + // and construct the geometry value from `input` inside resolve(), + // mirroring the ST_DWithin implementation. const registrations: ConnectionFilterOperatorRegistration[] = []; for (const spec of allSpecs) { + const geographyCast = spec.baseType === 'geography' + ? sql.fragment`::${sqlGeographyType}` + : sql.fragment``; + for (const typeName of spec.typeNames) { registrations.push({ typeNames: typeName, @@ -301,14 +321,17 @@ export function createPostgisOperatorFactory(): ConnectionFilterOperatorFactory spec: { description: spec.description, resolveType: (fieldType) => fieldType, + resolveSqlValue: () => sql.null, resolve( sqlIdentifier: SQL, - sqlValue: SQL, - _input: unknown, + _sqlValue: SQL, + input: unknown, _$where: any, _details: { fieldName: string | null; operatorName: string } ) { - return spec.resolve(sqlIdentifier, sqlValue); + const geoJsonStr = sql.value(JSON.stringify(input)); + const geomSql = sql.fragment`${sqlGeomFromGeoJSON}(${geoJsonStr}::text)${geographyCast}`; + return spec.resolve(sqlIdentifier, geomSql); } } satisfies ConnectionFilterOperatorSpec, }); diff --git a/graphql/orm-test/__fixtures__/seed/postgis-spatial-seed.sql b/graphql/orm-test/__fixtures__/seed/postgis-spatial-seed.sql new file mode 100644 index 000000000..d6d650699 --- /dev/null +++ b/graphql/orm-test/__fixtures__/seed/postgis-spatial-seed.sql @@ -0,0 +1,214 @@ +-- PostGIS spatial filter integration seed for orm-test. +-- +-- Exercises every ORM-exposed PostGIS spatial filter operator across every +-- concrete geometry subtype plus the geography codec. Acts as a regression +-- guard for constructive-io/constructive-planning#724 (GeoJSON input of +-- spatial filters must be wrapped with ST_GeomFromGeoJSON in the generated +-- SQL). +-- +-- Requires postgres-plus image with the `postgis` extension. +-- Extensions are installed via pgsql-test db.extensions config (not inline). + +CREATE SCHEMA IF NOT EXISTS postgis_test; + +GRANT USAGE ON SCHEMA postgis_test TO PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA postgis_test GRANT ALL ON TABLES TO PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA postgis_test GRANT ALL ON SEQUENCES TO PUBLIC; + +-- ============================================================================ +-- GEOMETRY CODEC — one table per concrete subtype +-- ============================================================================ + +-- Point: 6 US cities. `secondary_loc` is nullable to drive isNull tests. +CREATE TABLE postgis_test.cities_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + loc geometry(Point, 4326) NOT NULL, + secondary_loc geometry(Point, 4326) +); +CREATE INDEX idx_cities_geom_loc ON postgis_test.cities_geom USING gist(loc); +CREATE INDEX idx_cities_geom_secondary_loc ON postgis_test.cities_geom USING gist(secondary_loc); + +-- Polygon: 4 bounded regions with known containment relationships. +CREATE TABLE postgis_test.regions_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + shape geometry(Polygon, 4326) NOT NULL +); +CREATE INDEX idx_regions_geom_shape ON postgis_test.regions_geom USING gist(shape); + +-- MultiPolygon: 2 disjoint multi-region shapes (west-coast metros, east-coast metros). +CREATE TABLE postgis_test.territories_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + regions geometry(MultiPolygon, 4326) NOT NULL +); +CREATE INDEX idx_territories_geom_regions ON postgis_test.territories_geom USING gist(regions); + +-- LineString: 2 multi-vertex routes (I-5 corridor and transcontinental). +CREATE TABLE postgis_test.routes_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + path geometry(LineString, 4326) NOT NULL +); +CREATE INDEX idx_routes_geom_path ON postgis_test.routes_geom USING gist(path); + +-- MultiPoint: 2 point bags (west-coast, east-coast). +CREATE TABLE postgis_test.swarms_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + points geometry(MultiPoint, 4326) NOT NULL +); +CREATE INDEX idx_swarms_geom_points ON postgis_test.swarms_geom USING gist(points); + +-- MultiLineString: 2 multi-line networks (bay-area transit, east-coast rail). +CREATE TABLE postgis_test.networks_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + paths geometry(MultiLineString, 4326) NOT NULL +); +CREATE INDEX idx_networks_geom_paths ON postgis_test.networks_geom USING gist(paths); + +-- GeometryCollection: 2 heterogeneous bags (mixed shapes). +CREATE TABLE postgis_test.collections_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + shapes geometry(GeometryCollection, 4326) NOT NULL +); +CREATE INDEX idx_collections_geom_shapes ON postgis_test.collections_geom USING gist(shapes); + +-- PointZ: 2 altitude-aware points so intersects3D has a real column to hit. +CREATE TABLE postgis_test.towers_geom ( + id serial PRIMARY KEY, + name text NOT NULL, + loc3d geometry(PointZ, 4326) NOT NULL +); +CREATE INDEX idx_towers_geom_loc3d ON postgis_test.towers_geom USING gist(loc3d); + +-- ============================================================================ +-- GEOGRAPHY CODEC — Point + Polygon (the only codec shapes the operator set +-- registers for; see graphile-postgis FUNCTION_SPECS / OPERATOR_SPECS). +-- ============================================================================ + +CREATE TABLE postgis_test.cities_geog ( + id serial PRIMARY KEY, + name text NOT NULL, + loc geography(Point, 4326) NOT NULL +); +CREATE INDEX idx_cities_geog_loc ON postgis_test.cities_geog USING gist(loc); + +CREATE TABLE postgis_test.regions_geog ( + id serial PRIMARY KEY, + name text NOT NULL, + shape geography(Polygon, 4326) NOT NULL +); +CREATE INDEX idx_regions_geog_shape ON postgis_test.regions_geog USING gist(shape); + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Cities (geometry): id 1..6 = SF, Oakland, LA, NY, Seattle, Chicago. +-- Two rows have a non-null secondary_loc (SF, Oakland) so isNull partitions +-- the table evenly enough to be meaningful. +INSERT INTO postgis_test.cities_geom (id, name, loc, secondary_loc) VALUES + (1, 'San Francisco', ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326), + ST_SetSRID(ST_MakePoint(-122.4783, 37.8199), 4326)), + (2, 'Oakland', ST_SetSRID(ST_MakePoint(-122.2712, 37.8044), 4326), + ST_SetSRID(ST_MakePoint(-122.2585, 37.8024), 4326)), + (3, 'Los Angeles', ST_SetSRID(ST_MakePoint(-118.2437, 34.0522), 4326), NULL), + (4, 'New York', ST_SetSRID(ST_MakePoint( -74.0060, 40.7128), 4326), NULL), + (5, 'Seattle', ST_SetSRID(ST_MakePoint(-122.3321, 47.6062), 4326), NULL), + (6, 'Chicago', ST_SetSRID(ST_MakePoint( -87.6298, 41.8781), 4326), NULL); + +-- Cities (geography): same coordinates, geography codec. +INSERT INTO postgis_test.cities_geog (id, name, loc) VALUES + (1, 'San Francisco', ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography), + (2, 'Oakland', ST_SetSRID(ST_MakePoint(-122.2712, 37.8044), 4326)::geography), + (3, 'Los Angeles', ST_SetSRID(ST_MakePoint(-118.2437, 34.0522), 4326)::geography), + (4, 'New York', ST_SetSRID(ST_MakePoint( -74.0060, 40.7128), 4326)::geography), + (5, 'Seattle', ST_SetSRID(ST_MakePoint(-122.3321, 47.6062), 4326)::geography), + (6, 'Chicago', ST_SetSRID(ST_MakePoint( -87.6298, 41.8781), 4326)::geography); + +-- Regions (geometry): bounding rectangles. +-- Bay Area covers SF + Oakland, NYC Metro covers NY, West Coast Strip covers +-- SF + Oakland + LA + Seattle, Pacific Ocean contains no cities. +INSERT INTO postgis_test.regions_geom (id, name, shape) VALUES + (1, 'Bay Area', ST_GeomFromText('POLYGON((-122.55 37.70, -122.20 37.70, -122.20 37.85, -122.55 37.85, -122.55 37.70))', 4326)), + (2, 'NYC Metro', ST_GeomFromText('POLYGON((-74.15 40.60, -73.70 40.60, -73.70 40.90, -74.15 40.90, -74.15 40.60))', 4326)), + (3, 'West Coast Strip', ST_GeomFromText('POLYGON((-122.55 32.00, -117.00 32.00, -117.00 49.00, -122.55 49.00, -122.55 32.00))', 4326)), + (4, 'Pacific Ocean', ST_GeomFromText('POLYGON((-135 15, -125 15, -125 40, -135 40, -135 15))', 4326)); + +-- Regions (geography): same shapes on the geography codec. +INSERT INTO postgis_test.regions_geog (id, name, shape) VALUES + (1, 'Bay Area', ST_GeomFromText('POLYGON((-122.55 37.70, -122.20 37.70, -122.20 37.85, -122.55 37.85, -122.55 37.70))', 4326)::geography), + (2, 'NYC Metro', ST_GeomFromText('POLYGON((-74.15 40.60, -73.70 40.60, -73.70 40.90, -74.15 40.90, -74.15 40.60))', 4326)::geography), + (3, 'West Coast Strip', ST_GeomFromText('POLYGON((-122.55 32.00, -117.00 32.00, -117.00 49.00, -122.55 49.00, -122.55 32.00))', 4326)::geography), + (4, 'Pacific Ocean', ST_GeomFromText('POLYGON((-135 15, -125 15, -125 40, -135 40, -135 15))', 4326)::geography); + +-- Territories (MultiPolygon): 3-part west-coast (Bay Area + LA Basin + +-- Seattle Area) and 3-part east-coast (NYC Metro + DC area + Boston area). +INSERT INTO postgis_test.territories_geom (id, name, regions) VALUES + (1, 'West Coast Metros', ST_GeomFromText( + 'MULTIPOLYGON(((-122.55 37.70, -122.20 37.70, -122.20 37.85, -122.55 37.85, -122.55 37.70)), + ((-119.00 33.00, -117.00 33.00, -117.00 35.00, -119.00 35.00, -119.00 33.00)), + ((-123.00 47.00, -121.00 47.00, -121.00 48.00, -123.00 48.00, -123.00 47.00)))', 4326)), + (2, 'East Coast Metros', ST_GeomFromText( + 'MULTIPOLYGON(((-74.15 40.60, -73.70 40.60, -73.70 40.90, -74.15 40.90, -74.15 40.60)), + ((-77.20 38.80, -76.90 38.80, -76.90 39.00, -77.20 39.00, -77.20 38.80)), + ((-71.20 42.30, -71.00 42.30, -71.00 42.40, -71.20 42.40, -71.20 42.30)))', 4326)); + +-- Routes (LineString). +-- I-5 Corridor: SF → LA → Seattle (zig-zag, bbox spans whole west coast). +-- Transcontinental: SF → Chicago → NY (bbox spans full continental US). +INSERT INTO postgis_test.routes_geom (id, name, path) VALUES + (1, 'I-5 Corridor', ST_GeomFromText('LINESTRING(-122.4194 37.7749, -118.2437 34.0522, -122.3321 47.6062)', 4326)), + (2, 'Transcontinental', ST_GeomFromText('LINESTRING(-122.4194 37.7749, -87.6298 41.8781, -74.0060 40.7128)', 4326)); + +-- Swarms (MultiPoint). +INSERT INTO postgis_test.swarms_geom (id, name, points) VALUES + (1, 'West Coast Swarm', ST_GeomFromText('MULTIPOINT((-122.4194 37.7749), (-118.2437 34.0522), (-122.3321 47.6062))', 4326)), + (2, 'East Coast Swarm', ST_GeomFromText('MULTIPOINT(( -74.0060 40.7128), ( -77.0369 38.9072), ( -71.0589 42.3601))', 4326)); + +-- Networks (MultiLineString). +-- Bay Area Transit is a pair of short lines around SF/Oakland (both lie +-- inside the Bay Area region polygon). +-- East Coast Rail connects NY→DC and NY→Boston. +INSERT INTO postgis_test.networks_geom (id, name, paths) VALUES + (1, 'Bay Area Transit', ST_GeomFromText( + 'MULTILINESTRING((-122.4194 37.7749, -122.2712 37.8044), + (-122.4783 37.8199, -122.2585 37.8024))', 4326)), + (2, 'East Coast Rail', ST_GeomFromText( + 'MULTILINESTRING(( -74.0060 40.7128, -77.0369 38.9072), + ( -74.0060 40.7128, -71.0589 42.3601))', 4326)); + +-- Collections (GeometryCollection) — heterogeneous shape bags. +INSERT INTO postgis_test.collections_geom (id, name, shapes) VALUES + (1, 'West Coast Mix', ST_GeomFromText( + 'GEOMETRYCOLLECTION( + POINT(-122.4194 37.7749), + POLYGON((-122.55 37.70, -122.20 37.70, -122.20 37.85, -122.55 37.85, -122.55 37.70)), + LINESTRING(-122.4194 37.7749, -118.2437 34.0522) + )', 4326)), + (2, 'East Coast Mix', ST_GeomFromText( + 'GEOMETRYCOLLECTION( + POINT(-74.0060 40.7128), + POLYGON((-74.15 40.60, -73.70 40.60, -73.70 40.90, -74.15 40.90, -74.15 40.60)) + )', 4326)); + +-- Towers (PointZ): real SF structures with altitude in metres. +INSERT INTO postgis_test.towers_geom (id, name, loc3d) VALUES + (1, 'Sutro Tower', ST_SetSRID(ST_MakePoint(-122.4528, 37.7552, 254), 4326)), + (2, 'Salesforce Tower', ST_SetSRID(ST_MakePoint(-122.3975, 37.7895, 326), 4326)); + +-- Reset sequences. +SELECT setval('postgis_test.cities_geom_id_seq', 6); +SELECT setval('postgis_test.cities_geog_id_seq', 6); +SELECT setval('postgis_test.regions_geom_id_seq', 4); +SELECT setval('postgis_test.regions_geog_id_seq', 4); +SELECT setval('postgis_test.territories_geom_id_seq', 2); +SELECT setval('postgis_test.routes_geom_id_seq', 2); +SELECT setval('postgis_test.swarms_geom_id_seq', 2); +SELECT setval('postgis_test.networks_geom_id_seq', 2); +SELECT setval('postgis_test.collections_geom_id_seq', 2); +SELECT setval('postgis_test.towers_geom_id_seq', 2); diff --git a/graphql/orm-test/__tests__/postgis-spatial.test.ts b/graphql/orm-test/__tests__/postgis-spatial.test.ts new file mode 100644 index 000000000..554b00adf --- /dev/null +++ b/graphql/orm-test/__tests__/postgis-spatial.test.ts @@ -0,0 +1,873 @@ +/** + * PostGIS Spatial Operators — ORM Integration Test + * + * Regression guard for constructive-io/constructive-planning#724 and living + * documentation for every PostGIS spatial filter the ORM exposes. + * + * Exercises: + * — All 27 spatial operators registered by `graphile-postgis` (26 standard + * operators in connection-filter-operators.ts + `withinDistance` from + * within-distance-operator.ts). + * — Every concrete geometry subtype (Point, LineString, Polygon, MultiPoint, + * MultiLineString, MultiPolygon, GeometryCollection, PointZ). + * — The geography codec (Point and Polygon columns). + * — Every GeoJSON input shape as filter RHS against a point column. + * — Combinations with AND/OR/NOT logical filters and scalar filters. + * — Edge cases: nullable columns, empty polygons, CRS-qualified GeoJSON. + * + * All data is seeded via __fixtures__/seed/postgis-spatial-seed.sql — no + * inline seed strings, matching the mega-query.test.ts convention. + * + * Requires postgres-plus image with the `postgis` extension. + */ +import { join } from 'path'; +import { getConnectionsObject, seed } from 'graphile-test'; +import type { GraphQLQueryFnObj } from 'graphile-test'; +import { ConstructivePreset } from 'graphile-settings'; +import { runCodegenAndLoad } from './helpers/codegen-helper'; +import { GraphileTestAdapter } from './helpers/graphile-adapter'; + +jest.setTimeout(120000); + +const seedFile = join( + __dirname, + '..', + '__fixtures__', + 'seed', + 'postgis-spatial-seed.sql', +); +const SCHEMA = 'postgis_test'; + +// ============================================================================ +// Fixture-derived constants — every coordinate here matches the seed file. +// ============================================================================ + +/** City row IDs in the seed (serial, 1-indexed). */ +const SF = 1; +const OAKLAND = 2; +const LA = 3; +const NY = 4; +const SEATTLE = 5; +const CHICAGO = 6; + +// ---- GeoJSON input shapes ---- + +/** Exactly SF's coordinates — used for equality-flavoured operators. */ +const SF_POINT = { type: 'Point', coordinates: [-122.4194, 37.7749] }; +const OAKLAND_POINT = { type: 'Point', coordinates: [-122.2712, 37.8044] }; + +/** Bay Area rectangle: covers SF + Oakland, excludes all other cities. */ +const BAY_AREA_POLYGON = { + type: 'Polygon', + coordinates: [ + [ + [-122.55, 37.70], + [-122.20, 37.70], + [-122.20, 37.85], + [-122.55, 37.85], + [-122.55, 37.70], + ], + ], +}; + +/** NYC Metro rectangle: covers NY only. */ +const NYC_METRO_POLYGON = { + type: 'Polygon', + coordinates: [ + [ + [-74.15, 40.60], + [-73.70, 40.60], + [-73.70, 40.90], + [-74.15, 40.90], + [-74.15, 40.60], + ], + ], +}; + +/** West-coast strip: covers SF + Oakland + LA + Seattle, excludes NY + Chicago. */ +const WEST_COAST_POLYGON = { + type: 'Polygon', + coordinates: [ + [ + [-122.55, 32.00], + [-117.00, 32.00], + [-117.00, 49.00], + [-122.55, 49.00], + [-122.55, 32.00], + ], + ], +}; + +/** Pacific Ocean rectangle: contains no cities — used by `disjoint`. */ +const PACIFIC_OCEAN_POLYGON = { + type: 'Polygon', + coordinates: [ + [ + [-135, 15], + [-125, 15], + [-125, 40], + [-135, 40], + [-135, 15], + ], + ], +}; + +/** LineString from SF through Oakland — both points lie exactly on the line. */ +const SF_OAKLAND_LINE = { + type: 'LineString', + coordinates: [ + [-122.4194, 37.7749], + [-122.2712, 37.8044], + ], +}; + +/** MultiPoint listing SF and LA exactly. */ +const SF_LA_MULTIPOINT = { + type: 'MultiPoint', + coordinates: [ + [-122.4194, 37.7749], + [-118.2437, 34.0522], + ], +}; + +/** + * MultiLineString with two disjoint polylines that each include the target + * city as an *explicit vertex*. This is required because: + * - In geometry (planar) math, a segment between two points holds + * latitude constant only if both endpoints share that latitude — fine. + * - In geography (geodesic) math, the "line" between two endpoints is a + * great-circle arc, which does NOT hold latitude constant even if the + * endpoints do. A SF-longitude point at latitude 37.7749 will NOT lie + * on a great-circle arc connecting (-122.55, 37.7749) and + * (-122.10, 37.7749) — it dips south of 37.7749 at the midpoint. + * + * By placing SF and NY themselves as vertices, both codecs see the city + * points as topologically ON the linestring, so `intersects` returns the + * expected rows regardless of whether the math is planar or geodesic. + */ +const SF_NY_MULTILINESTRING = { + type: 'MultiLineString', + coordinates: [ + [ + [-122.55, 37.7749], + [-122.4194, 37.7749], // SF as explicit vertex + [-122.20, 37.7749], + ], + [ + [-74.20, 40.7128], + [-74.0060, 40.7128], // NY as explicit vertex + [-73.80, 40.7128], + ], + ], +}; + +/** MultiPolygon: three disjoint west-coast regions (Bay, LA Basin, Seattle). */ +const WEST_COAST_METROS_MULTIPOLYGON = { + type: 'MultiPolygon', + coordinates: [ + [[[-122.55, 37.70], [-122.20, 37.70], [-122.20, 37.85], [-122.55, 37.85], [-122.55, 37.70]]], + [[[-119.00, 33.00], [-117.00, 33.00], [-117.00, 35.00], [-119.00, 35.00], [-119.00, 33.00]]], + [[[-123.00, 47.00], [-121.00, 47.00], [-121.00, 48.00], [-123.00, 48.00], [-123.00, 47.00]]], + ], +}; + +/** GeometryCollection mixing an SF point with an NYC-Metro polygon. */ +const SF_NYC_COLLECTION = { + type: 'GeometryCollection', + geometries: [ + { type: 'Point', coordinates: [-122.4194, 37.7749] }, + { + type: 'Polygon', + coordinates: [ + [ + [-74.15, 40.60], + [-73.70, 40.60], + [-73.70, 40.90], + [-74.15, 40.90], + [-74.15, 40.60], + ], + ], + }, + ], +}; + +/** + * 3D LineString threading exactly through both seeded towers. Used for + * `intersects3D` — unlike a tilted polygon, a line through two known 3D + * points is unambiguously intersected by those points, so the assertion + * never depends on whether the tower's altitude happens to land on a + * tilted plane. + * + * Towers: Sutro (-122.4528, 37.7552, 254), Salesforce (-122.3975, 37.7895, 326). + */ +const TOWER_LINE_Z = { + type: 'LineString', + coordinates: [ + [-122.4528, 37.7552, 254], + [-122.3975, 37.7895, 326], + ], +}; + +/** Points deliberately shifted so directional-bbox operators return SF only. */ +const POINT_EAST_OF_SF = { type: 'Point', coordinates: [-100.0, 37.7749] }; +const POINT_WEST_OF_SF = { type: 'Point', coordinates: [-140.0, 37.7749] }; +const POINT_NORTH_OF_SF = { type: 'Point', coordinates: [-122.4194, 60.0] }; +const POINT_SOUTH_OF_SF = { type: 'Point', coordinates: [-122.4194, 10.0] }; + +/** GeoJSON with an explicit EPSG:4326 CRS hint (legacy but valid). */ +const BAY_AREA_POLYGON_WITH_CRS = { + ...BAY_AREA_POLYGON, + crs: { type: 'name', properties: { name: 'EPSG:4326' } }, +}; + +/** Empty polygon: a degenerate ring. */ +const EMPTY_POLYGON = { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0, 0], + [0, 0], + [0, 0], + ], + ], +}; + +// ============================================================================ + +/** Extract the sole connection field from the ORM response. */ +function unwrap(data: unknown): { nodes: Array<{ id: number | string }>; totalCount?: number } { + return Object.values(data as Record)[0] as any; +} + +/** Sort numeric (or numeric-string) ids ascending and return them. */ +function ids(nodes: Array<{ id: number | string }>): number[] { + return nodes.map((n) => Number(n.id)).sort((a, b) => a - b); +} + +describe('PostGIS spatial operators (ORM, live PG)', () => { + let teardown: () => Promise; + let query: GraphQLQueryFnObj; + let orm: Record; + + beforeAll(async () => { + const connections = await getConnectionsObject( + { + schemas: [SCHEMA], + preset: { extends: [ConstructivePreset] }, + useRoot: true, + db: { + extensions: ['postgis'], + }, + }, + [seed.sqlfile([seedFile])], + ); + teardown = connections.teardown; + query = connections.query; + + const { createClient } = await runCodegenAndLoad(query, 'postgis-spatial'); + const adapter = new GraphileTestAdapter(query); + orm = createClient({ adapter }); + }); + + afterAll(async () => { + if (teardown) await teardown(); + }); + + // ========================================================================== + // SECTION A — Binding smoke test + // + // `intersects` is the only topological operator supported on both codecs, + // so it's our canonical "does the GeoJSON binding work at all?" probe. We + // fire it across every GeoJSON shape the spec allows as RHS input, against + // a Point column. If any of these land a `parse error - invalid geometry`, + // the binding regression is back. + // ========================================================================== + describe('A. GeoJSON input shape binding (regression guard for #724)', () => { + const INPUT_SHAPE_CASES: Array<[string, unknown, number[]]> = [ + ['Point', SF_POINT, [SF]], + ['LineString', SF_OAKLAND_LINE, [SF, OAKLAND]], + ['Polygon', BAY_AREA_POLYGON, [SF, OAKLAND]], + ['MultiPoint', SF_LA_MULTIPOINT, [SF, LA]], + ['MultiLineString', SF_NY_MULTILINESTRING, [SF, NY]], + ['MultiPolygon', WEST_COAST_METROS_MULTIPOLYGON, [SF, OAKLAND, LA, SEATTLE]], + ['GeometryCollection', SF_NYC_COLLECTION, [SF, NY]], + ]; + + describe('geometry(Point, 4326) column', () => { + it.each(INPUT_SHAPE_CASES)( + 'intersects accepts %s input', + async (_shape, value, expectedIds) => { + const result = await orm.citiesGeom + .findMany({ + select: { id: true, name: true }, + where: { loc: { intersects: value } }, + }) + .execute(); + expect(result.ok).toBe(true); + expect(ids(unwrap(result.data).nodes)).toEqual(expectedIds); + }, + ); + }); + + describe('geography(Point, 4326) column', () => { + it.each(INPUT_SHAPE_CASES)( + 'intersects accepts %s input', + async (_shape, value, expectedIds) => { + const result = await orm.citiesGeog + .findMany({ + select: { id: true, name: true }, + where: { loc: { intersects: value } }, + }) + .execute(); + expect(result.ok).toBe(true); + expect(ids(unwrap(result.data).nodes)).toEqual(expectedIds); + }, + ); + }); + }); + + // ========================================================================== + // SECTION B.1 — Every geometry spatial operator + // + // Every operator registered by graphile-postgis for the geometry codec + // fires without error and returns semantically-correct rows against the + // seeded `cities_geom` table. Directional-bbox tests use tailored inputs + // so the expected set is never empty. + // ========================================================================== + describe('B.1. All geometry operators on cities_geom.loc', () => { + // ---- Topological functions (ST_*) ---- + + it('intersects: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { intersects: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('contains: Point col + SF point → SF only', async () => { + // On a Point column, contains is true only for the exact same point. + const r = await orm.citiesGeom.findMany({ where: { loc: { contains: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('containsProperly: Point col + SF point → SF only (point interior = the point itself)', async () => { + // ST_ContainsProperly(A, B) is TRUE iff every point of B lies in the + // interior of A. For two identical points, B = A's interior — so the + // same-point row matches. (Verified empirically against PostGIS 3.4.) + const r = await orm.citiesGeom.findMany({ where: { loc: { containsProperly: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('within: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { within: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('covers: SF point → SF only', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { covers: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('coveredBy: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { coveredBy: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('crosses: Bay Area polygon → empty (points never cross polygons per PostGIS semantics)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { crosses: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([]); + }); + + it('disjoint: Pacific Ocean polygon → all 6 cities', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { disjoint: PACIFIC_OCEAN_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA, NY, SEATTLE, CHICAGO]); + }); + + it('equals: SF point → SF only', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { equals: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('orderingEquals: SF point → SF only', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { orderingEquals: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('overlaps: Bay Area polygon → empty (points do not share interior with polygons)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { overlaps: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([]); + }); + + it('touches: Bay Area polygon → empty (interior points never touch)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { touches: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([]); + }); + + // ---- Exact equality SQL operator ---- + + it('exactlyEquals: SF point → SF only', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { exactlyEquals: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + // ---- 2D bbox operators ---- + + it('bboxIntersects2D: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxIntersects2D: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('bboxIntersectsND: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxIntersectsND: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('bboxContains: SF point → SF only (point bbox contains SF point bbox)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxContains: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('bboxEquals: SF point → SF only', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxEquals: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + // ---- Directional bbox operators ---- + // `<<` strictly-left / `>>` strictly-right / `|>>` strictly-above / + // `<<|` strictly-below. Inputs are crafted so SF's bbox is on the + // expected side of the RHS bbox. + + it('bboxLeftOf: point east of SF → SF + Oakland + Seattle + LA', async () => { + // Cities with bbox strictly to the left (west) of a point at -100° lon. + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxLeftOf: POINT_EAST_OF_SF } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA, SEATTLE]); + }); + + it('bboxRightOf: point west of SF → Chicago + NY (and all cities east of -140°)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxRightOf: POINT_WEST_OF_SF } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + // Every seeded city is east of -140°, so the whole set qualifies. + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA, NY, SEATTLE, CHICAGO]); + }); + + it('bboxAbove: point south of SF → every city (all are north of lat 10°)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxAbove: POINT_SOUTH_OF_SF } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA, NY, SEATTLE, CHICAGO]); + }); + + it('bboxBelow: point far north → every city (all are south of lat 60°)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxBelow: POINT_NORTH_OF_SF } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA, NY, SEATTLE, CHICAGO]); + }); + + it('bboxOverlapsOrLeftOf: Bay Area polygon → SF + Oakland + Seattle', async () => { + // `&<` is TRUE iff col.xmax ≤ polygon.xmax. Bay Area xmax = -122.20. + // SF (-122.42), Oakland (-122.27), Seattle (-122.33) all qualify. + // LA (-118.24) sits east of the polygon's right edge so it does NOT. + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxOverlapsOrLeftOf: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, SEATTLE]); + }); + + it('bboxOverlapsOrRightOf: Bay Area polygon → NY + Chicago + LA (all east of -122.55)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxOverlapsOrRightOf: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + // Every seeded city is east of the Bay Area's left edge (-122.55): + // SF (-122.42), Oakland (-122.27), LA (-118.24), NY (-74.01), Seattle + // (-122.33), Chicago (-87.63). + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA, NY, SEATTLE, CHICAGO]); + }); + + it('bboxOverlapsOrAbove: Bay Area polygon → SF/Oakland/NY/Seattle/Chicago (all lat ≥ 37.70)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxOverlapsOrAbove: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, NY, SEATTLE, CHICAGO]); + }); + + it('bboxOverlapsOrBelow: Bay Area polygon → SF/Oakland/LA (all lat ≤ 37.85)', async () => { + const r = await orm.citiesGeom.findMany({ where: { loc: { bboxOverlapsOrBelow: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, LA]); + }); + + // ---- withinDistance (function w/ args) ---- + // + // FIXME(#724-followup): `withinDistance` is declared by + // graphile-postgis/src/plugins/within-distance-operator.ts for both the + // `GeometryInterface` filter type and every concrete subtype, but the + // graphile-connection-filter machinery does not surface it on the + // generated `GeometryInterfaceFilter` schema type in this preset + // configuration (verified by introspecting `__type(name: + // "GeometryInterfaceFilter") { inputFields { name } }` — `withinDistance` + // and `WithinDistanceInput` are both missing). + // + // This is a separate, pre-existing schema-visibility issue; the #724 + // GeoJSON-binding fix in this PR does not affect it. Skipping these two + // cases here with a clear trail so the follow-up fix can flip them from + // `xit` back to `it` without changing the assertions. + xit('[FIXME] withinDistance: 20km around Oakland → SF + Oakland', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { loc: { withinDistance: { point: OAKLAND_POINT, distance: 20000 } } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + }); + + // ========================================================================== + // SECTION B.2 — Every geography spatial operator + // + // The 6 operators graphile-postgis registers for geography types, all + // against `cities_geog.loc`. + // ========================================================================== + describe('B.2. All geography operators on cities_geog.loc', () => { + it('intersects: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeog.findMany({ where: { loc: { intersects: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('covers: SF point → SF only', async () => { + const r = await orm.citiesGeog.findMany({ where: { loc: { covers: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('coveredBy: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeog.findMany({ where: { loc: { coveredBy: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('exactlyEquals: SF point → SF only', async () => { + const r = await orm.citiesGeog.findMany({ where: { loc: { exactlyEquals: SF_POINT } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('bboxIntersects2D: Bay Area polygon → SF + Oakland', async () => { + const r = await orm.citiesGeog.findMany({ where: { loc: { bboxIntersects2D: BAY_AREA_POLYGON } }, select: { id: true } }).execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + // See FIXME note on the geometry-side `withinDistance` case above. + xit('[FIXME] withinDistance: 20km around Oakland → SF + Oakland', async () => { + const r = await orm.citiesGeog + .findMany({ + where: { loc: { withinDistance: { point: OAKLAND_POINT, distance: 20000 } } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + }); + + // ========================================================================== + // SECTION C — Column type showcase + // + // One representative, semantically-natural filter per non-Point column + // type. Proves the ORM exposes spatial filters on every concrete geometry + // subtype end-to-end. + // ========================================================================== + describe('C. Column type showcase (non-Point subtypes)', () => { + it('geometry(Polygon) — regions within the continental US', async () => { + const US_POLYGON = { + type: 'Polygon', + coordinates: [ + [ + [-130, 24], + [-65, 24], + [-65, 50], + [-130, 50], + [-130, 24], + ], + ], + }; + const r = await orm.regionsGeom + .findMany({ where: { shape: { within: US_POLYGON } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // Bay Area, NYC Metro, West Coast Strip all fall within the US polygon. + // Pacific Ocean polygon straddles -125 lon and extends south to lat 15, + // which is outside the US rectangle — so it is excluded. + expect(ids(unwrap(r.data).nodes)).toEqual([1, 2, 3]); + }); + + it('geometry(MultiPolygon) — territories intersecting SF', async () => { + const r = await orm.territoriesGeom + .findMany({ where: { regions: { intersects: SF_POINT } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // West Coast Metros includes Bay Area (SF) → match; East Coast does not. + expect(ids(unwrap(r.data).nodes)).toEqual([1]); + }); + + it('geometry(LineString) — routes intersecting the Bay Area polygon', async () => { + const r = await orm.routesGeom + .findMany({ where: { path: { intersects: BAY_AREA_POLYGON } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // Both routes start at SF, so both intersect the Bay Area polygon. + expect(ids(unwrap(r.data).nodes)).toEqual([1, 2]); + }); + + it('geometry(MultiPoint) — swarms intersecting the West Coast polygon', async () => { + const r = await orm.swarmsGeom + .findMany({ where: { points: { intersects: WEST_COAST_POLYGON } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // West Coast Swarm = SF + LA + Seattle, all inside the strip. East Coast + // Swarm = NY + DC + Boston, all outside. + expect(ids(unwrap(r.data).nodes)).toEqual([1]); + }); + + it('geometry(MultiLineString) — networks intersecting the Bay Area polygon', async () => { + const r = await orm.networksGeom + .findMany({ where: { paths: { intersects: BAY_AREA_POLYGON } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // Bay Area Transit lies entirely in the polygon; East Coast Rail does not. + expect(ids(unwrap(r.data).nodes)).toEqual([1]); + }); + + it('geometry(GeometryCollection) — collections intersecting SF', async () => { + const r = await orm.collectionsGeom + .findMany({ where: { shapes: { intersects: SF_POINT } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([1]); + }); + + it('geometry(PointZ) — towers intersecting a 2D SF polygon (intersects)', async () => { + const r = await orm.towersGeom + .findMany({ where: { loc3D: { intersects: BAY_AREA_POLYGON } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // Both towers are in SF — 2D intersection with the Bay Area ignores Z. + expect(ids(unwrap(r.data).nodes)).toEqual([1, 2]); + }); + + it('geometry(PointZ) — intersects3D against a 3D line threading both towers', async () => { + const r = await orm.towersGeom + .findMany({ where: { loc3D: { intersects3D: TOWER_LINE_Z } }, select: { id: true, name: true } }) + .execute(); + expect(r.ok).toBe(true); + // TOWER_LINE_Z's endpoints are exactly Sutro and Salesforce in 3D, + // so both tower points lie on the line. Also pins that intersects3D + // accepts a LineStringZ input without parse errors. + expect(ids(unwrap(r.data).nodes)).toEqual([1, 2]); + }); + }); + + // ========================================================================== + // SECTION D — Combinations with logical + scalar filters + // ========================================================================== + describe('D. Combinations with logical and scalar filters', () => { + it('AND: two spatial filters (in West Coast ∩ in Bay Area) → SF + Oakland', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { + loc: { intersects: WEST_COAST_POLYGON }, + and: [{ loc: { within: BAY_AREA_POLYGON } }], + }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('OR: two spatial regions (Bay Area ∪ NYC Metro) → SF + Oakland + NY', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { + or: [ + { loc: { within: BAY_AREA_POLYGON } }, + { loc: { within: NYC_METRO_POLYGON } }, + ], + }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND, NY]); + }); + + it('NOT: negated spatial filter → all cities outside Bay Area', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { not: { loc: { within: BAY_AREA_POLYGON } } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([LA, NY, SEATTLE, CHICAGO]); + }); + + it('spatial + scalar: West Coast + name starts with "S" → SF + Seattle', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { + loc: { intersects: WEST_COAST_POLYGON }, + name: { startsWith: 'S' }, + }, + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, SEATTLE]); + }); + + it('spatial + orderBy: West Coast cities ordered by NAME_ASC', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { loc: { intersects: WEST_COAST_POLYGON } }, + orderBy: ['NAME_ASC'], + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + const names = unwrap(r.data).nodes.map((n: any) => n.name); + expect(names).toEqual(['Los Angeles', 'Oakland', 'San Francisco', 'Seattle']); + }); + }); + + // ========================================================================== + // SECTION E — Edge cases + // ========================================================================== + describe('E. Edge cases', () => { + it('isNull: true on secondary_loc → rows 3..6', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { secondaryLoc: { isNull: true } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([LA, NY, SEATTLE, CHICAGO]); + }); + + it('isNull: false on secondary_loc → rows 1..2', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { secondaryLoc: { isNull: false } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('intersects: empty polygon → empty result (no parse error)', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { loc: { intersects: EMPTY_POLYGON } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([]); + }); + + it('intersects: GeoJSON with CRS hint → SF + Oakland', async () => { + const r = await orm.citiesGeom + .findMany({ + where: { loc: { intersects: BAY_AREA_POLYGON_WITH_CRS } }, + select: { id: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + }); + + // ========================================================================== + // SECTION F — Direct regression mirrors for issue #724 + // + // 1:1 shape-match for the `it(...)` blocks in agentic-db's + // packages/integration-tests/__tests__/orm.test.ts. When the upstream fix + // lands, these flip from red → green in constructive CI; the agentic-db + // regression-guard (which asserts `ok === false`) then fails, alerting + // agentic-db maintainers to invert it. + // ========================================================================== + describe('F. #724 regression mirrors (geography codec, Bay Area polygon)', () => { + it('bboxIntersects2D: SF + Oakland', async () => { + const r = await orm.citiesGeog + .findMany({ + where: { loc: { bboxIntersects2D: BAY_AREA_POLYGON } }, + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('coveredBy: SF + Oakland', async () => { + const r = await orm.citiesGeog + .findMany({ + where: { loc: { coveredBy: BAY_AREA_POLYGON } }, + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + + it('covers: SF only (SF point covers itself)', async () => { + const r = await orm.citiesGeog + .findMany({ + where: { loc: { covers: SF_POINT } }, + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('exactlyEquals: SF only', async () => { + const r = await orm.citiesGeog + .findMany({ + where: { loc: { exactlyEquals: SF_POINT } }, + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF]); + }); + + it('intersects: SF + Oakland', async () => { + const r = await orm.citiesGeog + .findMany({ + where: { loc: { intersects: BAY_AREA_POLYGON } }, + select: { id: true, name: true }, + }) + .execute(); + expect(r.ok).toBe(true); + expect(ids(unwrap(r.data).nodes)).toEqual([SF, OAKLAND]); + }); + }); +});