Skip to content

feat(graphile-postgis): PostgisSpatialRelationsPlugin — cross-table spatial filters via @spatialRelation smart tags#993

Merged
pyramation merged 4 commits intomainfrom
feat/postgis-spatial-relations-plugin
Apr 17, 2026
Merged

feat(graphile-postgis): PostgisSpatialRelationsPlugin — cross-table spatial filters via @spatialRelation smart tags#993
pyramation merged 4 commits intomainfrom
feat/postgis-spatial-relations-plugin

Conversation

@pyramation
Copy link
Copy Markdown
Contributor

@pyramation pyramation commented Apr 17, 2026

Summary

Adds PostgisSpatialRelationsPlugin — a smart-tag-driven plugin that synthesises first-class relation + filter fields whose join predicate is a PostGIS spatial function. Enables cross-table and self-relation spatial filtering through the generated ORM without hand-written PG functions.

Closes the v1 scope of constructive-io/constructive-planning#728.

Tag grammar (PG-native snake_case):

COMMENT ON COLUMN telemedicine_clinics.location IS
  E'@spatialRelation county counties.geom st_within\n'
  '@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';

Filter shapes:

# 2-arg operator
filter: { county: { some: { name: { equalTo: "California County" } } } }

# Parametric st_dwithin
filter: { nearbyClinic: { distance: 5000, some: { specialty: { equalTo: "pediatrics" } } } }

Supported operators (v1): st_contains, st_within, st_covers, st_coveredby, st_intersects, st_equals, st_bbox_intersects (2-arg) + st_dwithin (3-arg, parametric).

Implementation highlights:

  • Emits EXISTS (…) subqueries with ST_<op>(owner, target) as the join predicate, reusing connection-filter's some/every/none plumbing. Operand order matches the tag's reading direction ("owner <op> target"), which is required for directional operators (st_within, st_contains, st_covers, st_coveredby).
  • Self-join exclusion (single-column PK → other.id <> self.id; composite PK → IS DISTINCT FROM tuple). Self-relations on tables without a PK are rejected at init.
  • Validates operator names against pg_proc at schema build.
  • Emits a non-fatal GIST-index warning when the target column has no GIST index.
  • Works with non-public PostGIS schemas (uses sql.identifier, never literals).
  • Parametric value (distance) is read in the parent relation's apply so it is available before some/every/none children run — does not rely on input-field iteration order.

Files:

  • graphile/graphile-postgis/src/plugins/spatial-relations.ts (new plugin, ~870 lines)
  • graphile/graphile-postgis/src/preset.ts, src/index.ts (wire + export)
  • graphile/graphile-postgis/__tests__/spatial-relations.test.ts (31 unit tests: registry, tag parsing, collection, validation errors, plugin metadata)
  • graphile/graphile-postgis/__tests__/{index,preset}.test.ts (update export/plugin-count assertions)
  • graphql/orm-test/__fixtures__/seed/postgis-spatial-seed.sql (adds counties + telemedicine_clinics fixture with 4 @spatialRelation tags)
  • graphql/orm-test/__tests__/postgis-spatial-relations.test.ts (new E2E file: 17 tests — schema shape + live-PG filter semantics)
  • README updated with tag grammar, operator table, filter shapes, self-relation + GIST notes

Updates since last revision

  • Argument-order fix: initial buildSpatialJoinFragment emitted ST_<op>(target, owner), which silently "worked" for symmetric ops (st_intersects, st_dwithin, st_equals, &&) but inverted the match set for the directional ones. Now emits ST_<op>(owner, target) to match the tag grammar's reading direction. Caught by the E2E suite: st_within / st_coveredby returned 0 rows; st_intersects (same fixture) returned the expected 3 — the discrepancy made the root cause obvious.
  • Scope-collision fix: removed isPgConnectionFilterMany: true from the per-relation spatial filter type registration. That flag caused ConnectionFilterBackwardRelationsPlugin to auto-register some/every/none fields with FK-join semantics, colliding with the ones this plugin registers. Intent documented inline where the flag is intentionally omitted.
  • E2E filter operator: test uses { equalTo: "…" } (graphile-connection-filter idiom) rather than { eq: "…" }.
  • ORM key: test uses orm.telemedicineClinic (singular — what the codegen emits) rather than orm.telemedicineClinics.

Review & Testing Checklist for Human

  • Operand ordering — sanity-check ST_<op>(owner, target) against the tag grammar's reading. Owner is the side the tag sits on (telemedicine_clinics.location); target is the side named after the operator (counties.geom). Tag location st_within counties.geomST_Within(location, counties.geom) ⇒ "is the point inside the polygon?". If the intended semantic for any op should read the other way, say so before this lands.
  • Self-relation section (test D) — particularly finds clinics near SF cardiology clinics within 10 SRID units. The assertion that SF_CARDIO is not in the result set depends on self-exclusion excluding SF_CARDIO from its own radius match when the inner filter narrows to cardiology. Please sanity-check this reasoning.
  • Smart-tag grammar is new surface area — once merged, changing it is a breaking change for anyone who adopts it. Worth one pass to confirm you're happy with @spatialRelation <name> <table>.<col> <op> [<param>] and the PG-native snake_case operator names.

Notes

  • All 49 CI jobs green on the latest commit, including graphile/graphile-postgis (250+ unit tests) and graphql/orm-test (17 new live-PG E2E tests, all passing).
  • Local: graphile/graphile-postgis fully green; graphql/orm-test -- postgis-spatial-relations → 17/17 passing against postgres-plus:18 + postgis.
  • The plugin is tag-agnostic — a future metaschema spatial_relation node (Phase 2 in feat(codegen): add docs generation for ORM, React Query, and multi-target support #728) can emit these tags without any plugin change.
  • Out of scope for v1 (deliberate, per the planning issue): Phase 2 metaschema node, auto-bidirectional inference, st_overlaps/st_touches/st_crosses, DE-9IM st_relate.

Link to Devin session: https://app.devin.ai/sessions/c5eeee65a3c546c4ac6753bb05fa03e0
Requested by: @pyramation

…patial filters via @spatialRelation smart tags

Adds PostgisSpatialRelationsPlugin: a smart-tag-driven plugin that
synthesises cross-table and self-relation filter fields whose join
predicate is a PostGIS spatial function (ST_Contains, ST_DWithin, …).

Tag grammar:
  COMMENT ON COLUMN owner.col IS
    E'@spatialRelation <name> <table>.<col> <operator> [<param>]';

Supported operators (v1, PG-native snake_case):
  2-arg:  st_contains, st_within, st_covers, st_coveredby,
          st_intersects, st_equals, st_bbox_intersects
  3-arg:  st_dwithin (parametric distance, meters for geography,
          SRID units for geometry)

Filter shapes:
  2-arg: { county: { some: { name: { eq: 'California County' } } } }
  dwithin: { nearbyClinic: { distance: 5000, some: {...} } }

Features:
  - Emits EXISTS (…) subqueries with ST_<op>(target, owner) join
    predicate, reusing connection-filter's some/every/none plumbing.
  - Auto self-join exclusion for self-relations (single- or composite-PK).
  - GIST-index warning at schema build time when the target column
    has no GIST index.
  - pg_proc validation: operators must resolve against the PostGIS
    schema at build time.
  - Works with non-public PostGIS schemas (uses sql.identifier, not
    literals).

Tests:
  - 31 unit tests: tag parsing, registry, collection, validation
    errors (graphile-postgis/__tests__/spatial-relations.test.ts).
  - E2E tests via ORM with counties + telemedicine_clinics fixture
    (orm-test/__tests__/postgis-spatial-relations.test.ts).
  - Seed adds counties + telemedicine_clinics tables with four
    @spatialRelation tags exercising cross-table and self-relation
    paths.

Refs: constructive-io/constructive-planning#728
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

… some/every/none collision

Setting isPgConnectionFilterMany: true on our per-relation spatial
filter type caused ConnectionFilterBackwardRelationsPlugin to
auto-register some/every/none fields with FK-join semantics — colliding
with (and semantically different from) the ones we register. Our
plugin fully owns those fields for spatial-relation filter types, so
we leave that flag off.
…and order

Tag grammar reads as '<owner_col> <op> <target_col>' so the PostGIS call must be ST_<op>(owner, target). Symmetric operators hid the bug; st_within/st_contains/st_covers/st_coveredby inverted the match set. Also corrects test filter operator name to equalTo (graphile idiom) instead of eq.
@pyramation pyramation merged commit 6b91099 into main Apr 17, 2026
51 checks passed
@pyramation pyramation deleted the feat/postgis-spatial-relations-plugin branch April 17, 2026 23:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant