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 CHANGED
@@ -16,6 +16,31 @@ PostGIS support for PostGraphile v5.
16
16
 
17
17
  Automatically generates GraphQL types for PostGIS geometry and geography columns, including GeoJSON scalar types, dimension-aware interfaces, and subtype-specific fields (coordinates, points, rings, etc.).
18
18
 
19
+ ## The problem
20
+
21
+ Working with PostGIS from an app is usually painful for one specific
22
+ reason: **you end up juggling large amounts of GeoJSON across tables on
23
+ the client**. You fetch every clinic as GeoJSON, fetch every county
24
+ polygon as GeoJSON, and then — in the browser — loop through them
25
+ yourself to figure out which clinic sits inside which county. Every
26
+ query, every count, every page of results becomes a client-side
27
+ geometry problem.
28
+
29
+ An ORM generated automatically from your database schema can't fix this
30
+ on its own. It sees a `geometry` column and stops there — it has no
31
+ idea that "clinics inside a county" is the question you actually want
32
+ to ask. Foreign keys tell it how tables relate by equality; nothing
33
+ tells it how tables relate *spatially*.
34
+
35
+ So we added the missing primitive: a **spatial relation**. You declare,
36
+ on the database column, that `clinics.location` is "inside"
37
+ `counties.geom`, and the generated GraphQL schema + ORM gain a
38
+ first-class `where: { county: { some: { … } } }` shape that runs the
39
+ join server-side, in one SQL query, using PostGIS and a GIST index. No
40
+ GeoJSON on the wire, no client-side geometry, and the relation composes
41
+ with the rest of your `where:` the same way a foreign-key relation
42
+ would.
43
+
19
44
  ## Installation
20
45
 
21
46
  ```bash
@@ -40,8 +65,308 @@ const preset = {
40
65
  - Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
41
66
  - Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.)
42
67
  - Geography-aware field naming (longitude/latitude/height instead of x/y/z)
68
+ - Cross-table spatial relations via `@spatialRelation` smart tags (see below)
43
69
  - Graceful degradation when PostGIS is not installed
44
70
 
71
+ ## Spatial relations via smart tags
72
+
73
+ You declare a **spatial relation** with a `@spatialRelation` smart tag
74
+ on a `geometry` or `geography` column. The plugin turns that tag into a
75
+ virtual relation on the owning table: a new field on the table's
76
+ generated `where` input that runs a PostGIS join server-side. You write
77
+ one line of SQL once; the generated ORM and GraphQL schema pick it up
78
+ automatically.
79
+
80
+ ### At a glance
81
+
82
+ **Before** — GeoJSON juggling on the client:
83
+
84
+ ```ts
85
+ // 1. Pull every clinic's location as GeoJSON.
86
+ const clinics = await gql(`{ telemedicineClinics { nodes { id name location } } }`);
87
+ // 2. Pull the polygon of the one county you care about.
88
+ const { geom } = await gql(`{ countyByName(name: "Bay County") { geom } }`);
89
+ // 3. Run point-in-polygon on the client for each clinic.
90
+ const inBay = clinics.telemedicineClinics.nodes.filter((c) =>
91
+ booleanPointInPolygon(c.location, geom),
92
+ );
93
+ ```
94
+
95
+ **After** — server-side, one trip:
96
+
97
+ ```sql
98
+ COMMENT ON COLUMN telemedicine_clinics.location IS
99
+ E'@spatialRelation county counties.geom st_within';
100
+ ```
101
+
102
+ ```ts
103
+ const inBay = await orm.telemedicineClinic
104
+ .findMany({
105
+ select: { id: true, name: true },
106
+ where: { county: { some: { name: { equalTo: 'Bay County' } } } },
107
+ })
108
+ .execute();
109
+ ```
110
+
111
+ No polygon crosses the wire. The join happens in a single
112
+ `EXISTS (…)` subquery on the server, using a PostGIS predicate on the
113
+ two columns.
114
+
115
+ ### Declaring a relation
116
+
117
+ #### Tag grammar
118
+
119
+ ```
120
+ @spatialRelation <relationName> <targetRef> <operator> [<paramName>]
121
+ ```
122
+
123
+ - `<relationName>` — user-chosen name for the new field on the owning
124
+ table's `where` input. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. The
125
+ name is preserved as-written — `county` stays `county`,
126
+ `nearbyClinic` stays `nearbyClinic`.
127
+ - `<targetRef>` — `table.column` (defaults to the owning column's
128
+ schema) or `schema.table.column` (for references in another schema,
129
+ e.g. a shared `geo` schema).
130
+ - `<operator>` — one of the eight PG-native snake_case tokens listed in
131
+ [Operator reference](#operator-reference).
132
+ - `<paramName>` — required if and only if the operator is parametric.
133
+ Today that's `st_dwithin`, which needs a parameter name (typically
134
+ `distance`).
135
+
136
+ Both sides of the relation must be `geometry` or `geography`, and they
137
+ must share the **same** base codec — you cannot mix `geometry` and
138
+ `geography`.
139
+
140
+ #### Multiple relations on one column
141
+
142
+ Stack tags. Each line becomes its own field on the owning table's
143
+ `where` input:
144
+
145
+ ```sql
146
+ COMMENT ON COLUMN telemedicine_clinics.location IS
147
+ E'@spatialRelation county counties.geom st_within\n'
148
+ '@spatialRelation intersectingCounty counties.geom st_intersects\n'
149
+ '@spatialRelation coveringCounty counties.geom st_coveredby\n'
150
+ '@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
151
+ ```
152
+
153
+ The four relations above all exist in the integration test suite and
154
+ can be used in the same query. Two relations on the same owner cannot
155
+ share a `<relationName>`.
156
+
157
+ ### Operator reference
158
+
159
+ | Tag operator | PostGIS function | Parametric? | Symmetric? | Typical use |
160
+ |---|---|---|---|---|
161
+ | `st_contains` | `ST_Contains(A, B)` | no | **no** (A contains B) | polygon containing a point / line / polygon |
162
+ | `st_within` | `ST_Within(A, B)` | no | **no** (A within B) | point-in-polygon, line-in-polygon |
163
+ | `st_covers` | `ST_Covers(A, B)` | no | **no** | like `st_contains` but boundary-inclusive |
164
+ | `st_coveredby` | `ST_CoveredBy(A, B)` | no | **no** | dual of `st_covers` |
165
+ | `st_intersects` | `ST_Intersects(A, B)`| no | yes | any overlap at all |
166
+ | `st_equals` | `ST_Equals(A, B)` | no | yes | exact geometry match |
167
+ | `st_bbox_intersects` | `A && B` (infix) | no | yes | fast bounding-box prefilter |
168
+ | `st_dwithin` | `ST_DWithin(A, B, d)`| **yes** (`d`) | yes | radius / proximity search |
169
+
170
+ > The tag reads left-to-right as **"owner op target"**, and the emitted
171
+ > SQL is exactly `ST_<op>(owner_col, target_col[, distance])`. For
172
+ > symmetric operators (`st_intersects`, `st_equals`, `st_dwithin`,
173
+ > `st_bbox_intersects`) argument order doesn't matter. For directional
174
+ > operators (`st_within`, `st_contains`, `st_covers`, `st_coveredby`),
175
+ > flipping the two columns inverts the result set. Rule of thumb: put
176
+ > the relation on the column whose type makes the sentence true —
177
+ > `clinics.location st_within counties.geom` reads naturally; the
178
+ > reverse does not.
179
+
180
+ ### Using the generated `where` shape
181
+
182
+ #### Through the ORM
183
+
184
+ ```ts
185
+ // "Clinics inside any county named 'Bay County'"
186
+ await orm.telemedicineClinic
187
+ .findMany({
188
+ select: { id: true, name: true },
189
+ where: { county: { some: { name: { equalTo: 'Bay County' } } } },
190
+ })
191
+ .execute();
192
+ ```
193
+
194
+ #### Through GraphQL
195
+
196
+ The connection argument is `where:` at the GraphQL layer too — same
197
+ name, same tree. Only the generated input **type** keeps the word
198
+ "Filter" in it (e.g. `TelemedicineClinicFilter`):
199
+
200
+ ```graphql
201
+ {
202
+ telemedicineClinics(
203
+ where: { county: { some: { name: { equalTo: "Bay County" } } } }
204
+ ) {
205
+ nodes { id name }
206
+ }
207
+ }
208
+ ```
209
+
210
+ #### `some` / `every` / `none`
211
+
212
+ Every 2-argument relation exposes three modes. They mean what you'd
213
+ expect, backed by `EXISTS` / `NOT EXISTS`:
214
+
215
+ - `some: { <where clause> }` — the row matches if at least one related
216
+ target row passes the where clause.
217
+ - `none: { <where clause> }` — the row matches if no related target row
218
+ passes.
219
+ - `every: { <where clause> }` — the row matches when every related
220
+ target row passes (i.e. "no counter-example exists"). Note that
221
+ `every: {}` on an empty target set is vacuously true.
222
+
223
+ An empty inner clause (`some: {}`) means "at least one related target
224
+ row exists, any row will do" — so for `@spatialRelation county …
225
+ st_within`, clinics whose point is inside zero counties are correctly
226
+ excluded.
227
+
228
+ #### Parametric operators (`st_dwithin` + `distance`)
229
+
230
+ Parametric relations add a **required** `distance: Float!` field next
231
+ to `some` / `every` / `none`. The distance parametrises the join
232
+ itself, not the inner `some:` clause:
233
+
234
+ ```ts
235
+ await orm.telemedicineClinic
236
+ .findMany({
237
+ select: { id: true, name: true },
238
+ where: {
239
+ nearbyClinic: {
240
+ distance: 5000,
241
+ some: { specialty: { equalTo: 'pediatrics' } },
242
+ },
243
+ },
244
+ })
245
+ .execute();
246
+ ```
247
+
248
+ Distance units follow PostGIS semantics:
249
+
250
+ | Owner codec | `distance` units |
251
+ |---|---|
252
+ | `geography` | meters |
253
+ | `geometry` | SRID coordinate units (degrees for SRID 4326) |
254
+
255
+ #### Composition with `and` / `or` / `not` and scalar where clauses
256
+
257
+ Spatial relations live in the same `where:` tree as every scalar
258
+ predicate and compose the same way:
259
+
260
+ ```ts
261
+ // AND — Bay County clinics that are cardiology
262
+ where: {
263
+ and: [
264
+ { county: { some: { name: { equalTo: 'Bay County' } } } },
265
+ { specialty: { equalTo: 'cardiology' } },
266
+ ],
267
+ }
268
+
269
+ // OR — Bay County clinics OR the one named "LA Pediatrics"
270
+ where: {
271
+ or: [
272
+ { county: { some: { name: { equalTo: 'Bay County' } } } },
273
+ { name: { equalTo: 'LA Pediatrics' } },
274
+ ],
275
+ }
276
+
277
+ // NOT — clinics that are NOT in Bay County
278
+ where: {
279
+ not: { county: { some: { name: { equalTo: 'Bay County' } } } },
280
+ }
281
+ ```
282
+
283
+ Inside `some` / `every` / `none`, the inner where clause is the target
284
+ table's full `where` input — every scalar predicate the target exposes
285
+ is available.
286
+
287
+ ### Self-relations
288
+
289
+ When the owner and target columns are the same column, the plugin
290
+ emits a self-exclusion predicate so a row never matches itself:
291
+
292
+ - Single-column primary key: `other.<pk> <> self.<pk>`
293
+ - Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`
294
+
295
+ Tables without a primary key are rejected at schema build — a
296
+ self-relation there would match every row against itself.
297
+
298
+ One concrete consequence: with `st_dwithin`, a self-relation at
299
+ `distance: 0` matches zero rows, because the only candidate at
300
+ distance 0 is the row itself, which is excluded.
301
+
302
+ ### Generated SQL shape
303
+
304
+ ```sql
305
+ SELECT ...
306
+ FROM <owner_table> self
307
+ WHERE EXISTS (
308
+ SELECT 1
309
+ FROM <target_table> other
310
+ WHERE ST_<op>(self.<owner_col>, other.<target_col>[, <distance>])
311
+ AND <self-exclusion for self-relations>
312
+ AND <nested some/every/none conditions>
313
+ );
314
+ ```
315
+
316
+ The EXISTS lives inside the owner's generated `where` input, so it
317
+ composes with pagination, ordering, and the rest of the outer plan.
318
+ `st_bbox_intersects` compiles to infix `&&` rather than a function call.
319
+ PostGIS functions are called with whichever schema PostGIS is installed
320
+ in, so non-`public` installs work without configuration.
321
+
322
+ ### Indexing
323
+
324
+ Spatial predicates without a GIST index fall back to sequential scans,
325
+ which is almost never what you want. The plugin checks your target
326
+ columns at schema-build time and emits a non-fatal warning when a GIST
327
+ index is missing, including the recommended `CREATE INDEX ... USING
328
+ GIST(...)` in the warning text.
329
+
330
+ ```sql
331
+ CREATE INDEX ON telemedicine_clinics USING GIST(location);
332
+ CREATE INDEX ON counties USING GIST(geom);
333
+ ```
334
+
335
+ If a particular column is a known exception (e.g. a small prototype
336
+ table), set `@spatialRelationSkipIndexCheck` on that column to suppress
337
+ the warning.
338
+
339
+ ### `geometry` vs `geography`
340
+
341
+ Pick one side of a relation and stick with it — mixing codecs across
342
+ the two sides is rejected at schema build. `geography` distances are
343
+ always meters; `geometry` distances follow the SRID's native units
344
+ (degrees for SRID 4326, which is rarely what you want for radius
345
+ searches). If you need meter-based proximity on a `geometry` column,
346
+ cast on ingest (`::geography`) rather than mixing codecs across a
347
+ single relation.
348
+
349
+ ### FAQ
350
+
351
+ - **"Why doesn't `some: {}` return every row?"** — because `some` means
352
+ "at least one related target row exists". Rows whose column has no
353
+ match on the other side are correctly excluded.
354
+ - **"Why does `distance: 0` on a self-relation return nothing?"** — the
355
+ self-exclusion predicate removes the row's match with itself, so at
356
+ distance 0 no candidates remain.
357
+ - **"Can I reuse a `relationName` across tables?"** — yes; uniqueness
358
+ is scoped to the owning table.
359
+ - **"Can I declare the relation from the polygon side instead of the
360
+ point side?"** — yes. Flip owner and target and use the inverse
361
+ operator (`st_contains` in place of `st_within`). Same rows, same
362
+ SQL, different `where` location.
363
+ - **"Does this work with PostGIS installed in a non-`public` schema?"**
364
+ — yes.
365
+ - **"Can I use a spatial relation in `orderBy` or on a connection
366
+ field?"** — no; it's a where-only construct. Use PostGIS measurement
367
+ fields (see the `geometry-fields` / `measurement-fields` plugins) for
368
+ values you want to sort on.
369
+
45
370
  ## License
46
371
 
47
372
  MIT
package/esm/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/esm/index.js CHANGED
@@ -23,6 +23,7 @@ export { PostgisGeometryFieldsPlugin } from './plugins/geometry-fields';
23
23
  export { PostgisMeasurementFieldsPlugin } from './plugins/measurement-fields';
24
24
  export { PostgisTransformationFieldsPlugin } from './plugins/transformation-functions';
25
25
  export { PostgisAggregatePlugin } from './plugins/aggregate-functions';
26
+ export { PostgisSpatialRelationsPlugin, OPERATOR_REGISTRY, parseSpatialRelationTag, collectSpatialRelations, } from './plugins/spatial-relations';
26
27
  // Connection filter operator factories (spatial operators for graphile-connection-filter)
27
28
  export { createPostgisOperatorFactory } from './plugins/connection-filter-operators';
28
29
  export { createWithinDistanceOperatorFactory } from './plugins/within-distance-operator';
@@ -207,6 +207,8 @@ export function createPostgisOperatorFactory() {
207
207
  }
208
208
  const { inflection } = build;
209
209
  const { schemaName, geometryCodec, geographyCodec } = postgisInfo;
210
+ const sqlGeomFromGeoJSON = sql.identifier(schemaName, 'st_geomfromgeojson');
211
+ const sqlGeographyType = sql.identifier(schemaName, 'geography');
210
212
  // Collect all GQL type names for geometry and geography
211
213
  const gqlTypeNamesByBase = {
212
214
  geometry: [],
@@ -239,6 +241,7 @@ export function createPostgisOperatorFactory() {
239
241
  typeNames: gqlTypeNamesByBase[baseType],
240
242
  operatorName,
241
243
  description,
244
+ baseType: baseType,
242
245
  resolve: (i, v) => sql.fragment `${sqlGisFunction}(${i}, ${v})`
243
246
  });
244
247
  }
@@ -252,6 +255,7 @@ export function createPostgisOperatorFactory() {
252
255
  typeNames: gqlTypeNamesByBase[baseType],
253
256
  operatorName,
254
257
  description,
258
+ baseType: baseType,
255
259
  resolve: (i, v) => buildOperatorExpr(capturedOp, i, v)
256
260
  });
257
261
  }
@@ -261,8 +265,21 @@ export function createPostgisOperatorFactory() {
261
265
  // Convert to ConnectionFilterOperatorRegistration format.
262
266
  // Each InternalSpec may target multiple type names; we expand each
263
267
  // into individual registrations keyed by typeName.
268
+ //
269
+ // The default operatorApply pipeline binds the filter value as a raw
270
+ // text parameter cast to the column codec's sqlType (geometry /
271
+ // geography). PostgreSQL's geometry_in / geography_in parsers reject
272
+ // GeoJSON text, so we must wrap the input with ST_GeomFromGeoJSON
273
+ // ourselves — see within-distance-operator.ts for the pattern.
274
+ //
275
+ // We disable the default bind via `resolveSqlValue: () => sql.null`
276
+ // and construct the geometry value from `input` inside resolve(),
277
+ // mirroring the ST_DWithin implementation.
264
278
  const registrations = [];
265
279
  for (const spec of allSpecs) {
280
+ const geographyCast = spec.baseType === 'geography'
281
+ ? sql.fragment `::${sqlGeographyType}`
282
+ : sql.fragment ``;
266
283
  for (const typeName of spec.typeNames) {
267
284
  registrations.push({
268
285
  typeNames: typeName,
@@ -270,8 +287,11 @@ export function createPostgisOperatorFactory() {
270
287
  spec: {
271
288
  description: spec.description,
272
289
  resolveType: (fieldType) => fieldType,
273
- resolve(sqlIdentifier, sqlValue, _input, _$where, _details) {
274
- return spec.resolve(sqlIdentifier, sqlValue);
290
+ resolveSqlValue: () => sql.null,
291
+ resolve(sqlIdentifier, _sqlValue, input, _$where, _details) {
292
+ const geoJsonStr = sql.value(JSON.stringify(input));
293
+ const geomSql = sql.fragment `${sqlGeomFromGeoJSON}(${geoJsonStr}::text)${geographyCast}`;
294
+ return spec.resolve(sqlIdentifier, geomSql);
275
295
  }
276
296
  },
277
297
  });
@@ -0,0 +1,130 @@
1
+ import 'graphile-build';
2
+ import 'graphile-build-pg';
3
+ import 'graphile-connection-filter';
4
+ import type { GraphileConfig } from 'graphile-config';
5
+ /**
6
+ * PostgisSpatialRelationsPlugin
7
+ *
8
+ * Adds cross-table spatial filtering to `graphile-connection-filter` by
9
+ * reading a `@spatialRelation` smart tag on geometry/geography columns and
10
+ * synthesising a virtual relation + filter field that emits an EXISTS
11
+ * subquery joined by a PostGIS predicate (e.g. `ST_Contains`, `ST_DWithin`).
12
+ *
13
+ * The regular `ConnectionFilterBackwardRelationsPlugin` is FK-driven — it
14
+ * joins on column equality. Spatial relationships are not backed by FKs, so
15
+ * this plugin hooks the same `pgCodec`-scoped filter input types and injects
16
+ * its own fields whose `apply()` emits `ST_<op>(...)` instead of `a = b`.
17
+ *
18
+ * Tag grammar:
19
+ *
20
+ * ```sql
21
+ * COMMENT ON COLUMN <owner_table>.<owner_col> IS
22
+ * E'@spatialRelation <relation_name> <target_ref> <operator> [<param_name>]';
23
+ * ```
24
+ *
25
+ * - `target_ref` — `schema.table.col` or `table.col` (same schema as owner).
26
+ * - `operator` — one of the PG-native snake_case ops in OPERATOR_REGISTRY.
27
+ * - `param_name` — required iff the operator is parametric (currently
28
+ * only `st_dwithin`, which needs a distance).
29
+ *
30
+ * Examples:
31
+ *
32
+ * ```sql
33
+ * -- Point in polygon
34
+ * COMMENT ON COLUMN telemedicine_clinics.location IS
35
+ * E'@spatialRelation county counties.geom st_contains';
36
+ *
37
+ * -- Self-referential radius search
38
+ * COMMENT ON COLUMN telemedicine_clinics.location IS
39
+ * E'@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance';
40
+ * ```
41
+ *
42
+ * Generated GraphQL (for the `st_dwithin` case):
43
+ *
44
+ * ```graphql
45
+ * telemedicineClinics(where: {
46
+ * nearbyClinic: {
47
+ * distance: 5000,
48
+ * some: { specialty: { eq: "pediatrics" } }
49
+ * }
50
+ * })
51
+ * ```
52
+ *
53
+ * The generated SQL uses the same EXISTS pattern as backward relations but
54
+ * substitutes `ST_<op>(...)` for column equality:
55
+ *
56
+ * ```sql
57
+ * WHERE EXISTS (
58
+ * SELECT 1 FROM <target_table> other
59
+ * WHERE ST_<op>(other.<target_col>, self.<owner_col>[, distance])
60
+ * AND other.<pk> <> self.<pk> -- self-relations only
61
+ * AND <nested filter conditions>
62
+ * )
63
+ * ```
64
+ */
65
+ export interface SpatialOperatorRegistration {
66
+ /** Tag-facing op name (PG-native snake_case). */
67
+ name: string;
68
+ /** Kind of PG-level operator. */
69
+ kind: 'function' | 'infix';
70
+ /**
71
+ * For `kind: 'function'`, the PG function name (snake_case) resolved
72
+ * against the PostGIS schema at SQL-emit time. For `kind: 'infix'`,
73
+ * the PG binary operator token (e.g. `&&`).
74
+ */
75
+ pgToken: string;
76
+ /** Whether this op takes an extra numeric parameter (e.g. `st_dwithin`). */
77
+ parametric: boolean;
78
+ description: string;
79
+ }
80
+ export declare const OPERATOR_REGISTRY: Record<string, SpatialOperatorRegistration>;
81
+ export interface SpatialRelationInfo {
82
+ /** GraphQL-facing relation name, derived from the tag. */
83
+ relationName: string;
84
+ /** The codec that owns the tag (outer side of the EXISTS). */
85
+ ownerCodec: any;
86
+ /** The owning attribute name (column). */
87
+ ownerAttributeName: string;
88
+ /** Qualified target resource (inner side of the EXISTS). */
89
+ targetResource: any;
90
+ /** Column name on the target resource. */
91
+ targetAttributeName: string;
92
+ /** Resolved operator. */
93
+ operator: SpatialOperatorRegistration;
94
+ /** Field name for the parametric argument, if any. */
95
+ paramFieldName: string | null;
96
+ /** Whether owner === target (self-relation needs row exclusion). */
97
+ isSelfRelation: boolean;
98
+ /**
99
+ * Cached primary-key attribute names for the owner+target codecs. Used
100
+ * to synthesise the self-exclusion predicate (`other.<pk> <> self.<pk>`).
101
+ * `null` if the codec has no discoverable PK.
102
+ */
103
+ ownerPkAttributes: string[] | null;
104
+ targetPkAttributes: string[] | null;
105
+ }
106
+ interface TagParseResult {
107
+ ok: true;
108
+ relationName: string;
109
+ targetRef: string;
110
+ operator: string;
111
+ paramName: string | null;
112
+ }
113
+ interface TagParseError {
114
+ ok: false;
115
+ error: string;
116
+ }
117
+ /**
118
+ * Parse a single `@spatialRelation` tag value.
119
+ *
120
+ * Accepts a string of the form `<name> <target> <op> [<param>]`.
121
+ */
122
+ export declare function parseSpatialRelationTag(raw: string): TagParseResult | TagParseError;
123
+ /**
124
+ * Build the full set of spatial relations from all resources.
125
+ * Validates tags and throws (at schema build) on anything malformed.
126
+ * Returns relations keyed by (owner codec identity, relation name).
127
+ */
128
+ export declare function collectSpatialRelations(build: any): SpatialRelationInfo[];
129
+ export declare const PostgisSpatialRelationsPlugin: GraphileConfig.Plugin;
130
+ export {};