Skip to content

Commit 6b91099

Browse files
authored
Merge pull request #993 from constructive-io/feat/postgis-spatial-relations-plugin
feat(graphile-postgis): PostgisSpatialRelationsPlugin — cross-table spatial filters via @spatialRelation smart tags
2 parents 647b999 + 4265d02 commit 6b91099

9 files changed

Lines changed: 1923 additions & 3 deletions

File tree

graphile/graphile-postgis/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,90 @@ const preset = {
4040
- Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
4141
- Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.)
4242
- Geography-aware field naming (longitude/latitude/height instead of x/y/z)
43+
- Cross-table spatial filters via `@spatialRelation` smart tags (see below)
4344
- Graceful degradation when PostGIS is not installed
4445

46+
## Spatial relations via smart tags
47+
48+
`PostgisSpatialRelationsPlugin` lets you declare a cross-table or
49+
self-relation whose join predicate is a PostGIS spatial function. The
50+
plugin emits a first-class relation + filter field on the owning codec's
51+
`Filter` type that compiles to an `EXISTS (…)` subquery using the
52+
declared operator.
53+
54+
### Tag grammar
55+
56+
```sql
57+
COMMENT ON COLUMN <owner_table>.<owner_col> IS
58+
E'@spatialRelation <relation_name> <target_table>.<target_col> <operator> [<param_name>]';
59+
```
60+
61+
- `<relation_name>` — user-chosen name for the generated field (e.g. `county`)
62+
- `<target_table>.<target_col>` — target geometry/geography column; also
63+
accepts `<schema>.<table>.<col>`
64+
- `<operator>` — PG-native `st_*` function name; resolved at schema build
65+
time against `pg_proc`
66+
- `<param_name>` — required only for parametric operators
67+
(currently `st_dwithin`)
68+
69+
### Supported operators (v1)
70+
71+
| Operator | PostGIS function | Kind | Arity |
72+
|---|---|---|---|
73+
| `st_contains` | `ST_Contains` | function | 2 |
74+
| `st_within` | `ST_Within` | function | 2 |
75+
| `st_covers` | `ST_Covers` | function | 2 |
76+
| `st_coveredby` | `ST_CoveredBy` | function | 2 |
77+
| `st_intersects` | `ST_Intersects` | function | 2 |
78+
| `st_equals` | `ST_Equals` | function | 2 |
79+
| `st_bbox_intersects` | `&&` | infix | 2 |
80+
| `st_dwithin` | `ST_DWithin` | function | 3 (parametric) |
81+
82+
### Filter shapes
83+
84+
2-arg operators use the familiar `some` / `every` / `none` shape:
85+
86+
```graphql
87+
telemedicineClinics(
88+
filter: { county: { some: { name: { eq: "California County" } } } }
89+
) { nodes { id name } }
90+
```
91+
92+
`st_dwithin` takes its distance at the relation level (it parametrises
93+
the join, not the joined row):
94+
95+
```graphql
96+
telemedicineClinics(
97+
filter: {
98+
nearbyClinic: {
99+
distance: 5000
100+
some: { specialty: { eq: "pediatrics" } }
101+
}
102+
}
103+
) { nodes { id name } }
104+
```
105+
106+
Distance units follow PostGIS semantics: **meters** for `geography`
107+
columns, **SRID coordinate units** for `geometry` columns.
108+
109+
### Self-relations
110+
111+
When `<owner_table>` equals `<target_table>`, the plugin emits an
112+
automatic self-exclusion predicate so a row is never "related to
113+
itself":
114+
115+
- Single-column PK: `other.id <> self.id`
116+
- Composite PK: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`
117+
118+
Self-relations on tables without a primary key are rejected at schema
119+
build time.
120+
121+
### GIST index warning
122+
123+
At schema build time the plugin emits a non-fatal warning when the
124+
target geometry/geography column has no GIST index — spatial predicates
125+
are typically unusable without one.
126+
45127
## License
46128

47129
MIT

graphile/graphile-postgis/__tests__/index.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('graphile-postgis exports', () => {
1414
expect(postgisExports.PostgisMeasurementFieldsPlugin).toBeDefined();
1515
expect(postgisExports.PostgisTransformationFieldsPlugin).toBeDefined();
1616
expect(postgisExports.PostgisAggregatePlugin).toBeDefined();
17+
expect(postgisExports.PostgisSpatialRelationsPlugin).toBeDefined();
1718
});
1819

1920
it('should export constants', () => {
@@ -42,6 +43,11 @@ describe('graphile-postgis exports', () => {
4243
'PostgisMeasurementFieldsPlugin',
4344
'PostgisTransformationFieldsPlugin',
4445
'PostgisAggregatePlugin',
46+
'PostgisSpatialRelationsPlugin',
47+
// Spatial-relations helpers
48+
'OPERATOR_REGISTRY',
49+
'parseSpatialRelationTag',
50+
'collectSpatialRelations',
4551
// Constants
4652
'GisSubtype',
4753
'SUBTYPE_STRING_BY_SUBTYPE',

graphile/graphile-postgis/__tests__/preset.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import { PostgisGeometryFieldsPlugin } from '../src/plugins/geometry-fields';
77
import { PostgisMeasurementFieldsPlugin } from '../src/plugins/measurement-fields';
88
import { PostgisTransformationFieldsPlugin } from '../src/plugins/transformation-functions';
99
import { PostgisAggregatePlugin } from '../src/plugins/aggregate-functions';
10+
import { PostgisSpatialRelationsPlugin } from '../src/plugins/spatial-relations';
1011

1112
describe('GraphilePostgisPreset', () => {
12-
it('should include all 8 plugins', () => {
13-
expect(GraphilePostgisPreset.plugins).toHaveLength(8);
13+
it('should include all 9 plugins', () => {
14+
expect(GraphilePostgisPreset.plugins).toHaveLength(9);
15+
});
16+
17+
it('should include PostgisSpatialRelationsPlugin', () => {
18+
expect(GraphilePostgisPreset.plugins).toContain(PostgisSpatialRelationsPlugin);
1419
});
1520

1621
it('should include PostgisCodecPlugin', () => {

0 commit comments

Comments
 (0)