diff --git a/graphile/graphile-postgis/README.md b/graphile/graphile-postgis/README.md index 2a55711d2..00fdd80a8 100644 --- a/graphile/graphile-postgis/README.md +++ b/graphile/graphile-postgis/README.md @@ -12,34 +12,27 @@

-PostGIS support for PostGraphile v5. - -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.). - -## The problem - -Working with PostGIS from an app is usually painful for one specific -reason: **you end up juggling large amounts of GeoJSON across tables on -the client**. You fetch every clinic as GeoJSON, fetch every county -polygon as GeoJSON, and then — in the browser — loop through them -yourself to figure out which clinic sits inside which county. Every -query, every count, every page of results becomes a client-side -geometry problem. - -An ORM generated automatically from your database schema can't fix this -on its own. It sees a `geometry` column and stops there — it has no -idea that "clinics inside a county" is the question you actually want -to ask. Foreign keys tell it how tables relate by equality; nothing -tells it how tables relate *spatially*. - -So we added the missing primitive: a **spatial relation**. You declare, -on the database column, that `clinics.location` is "inside" -`counties.geom`, and the generated GraphQL schema + ORM gain a -first-class `where: { county: { some: { … } } }` shape that runs the -join server-side, in one SQL query, using PostGIS and a GIST index. No -GeoJSON on the wire, no client-side geometry, and the relation composes -with the rest of your `where:` the same way a foreign-key relation -would. +A full PostGIS integration for PostGraphile v5. Turns every +`geometry` / `geography` column into a typed, introspectable GraphQL +field — with GeoJSON scalars, subtype-specific fields, measurement +helpers, spatial filters, aggregates, and cross-table **spatial +relations** — and wires the whole thing into the generated ORM so you +can query spatial data the same way you query anything else. + +## Table of contents + +- [Installation](#installation) +- [Usage](#usage) +- [Features at a glance](#features-at-a-glance) +- [GeoJSON scalar and typed geometry columns](#geojson-scalar-and-typed-geometry-columns) +- [Dimension-aware interfaces and subtype fields](#dimension-aware-interfaces-and-subtype-fields) +- [Measurement fields (`length`, `area`, `perimeter`)](#measurement-fields-length-area-perimeter) +- [Transformation fields (`centroid`, `bbox`, `numPoints`)](#transformation-fields-centroid-bbox-numpoints) +- [Per-column spatial filters](#per-column-spatial-filters) +- [PostGIS aggregate fields](#postgis-aggregate-fields) +- [Spatial relations (`@spatialRelation`)](#spatial-relations-spatialrelation) +- [Graceful degradation](#graceful-degradation) +- [License](#license) ## Installation @@ -49,98 +42,279 @@ npm install graphile-postgis ## Usage -```typescript +```ts import { GraphilePostgisPreset } from 'graphile-postgis'; const preset = { - extends: [GraphilePostgisPreset] + extends: [GraphilePostgisPreset], }; ``` -## Features +The preset bundles every plugin listed below. You can also import each +plugin individually (`PostgisCodecPlugin`, `PostgisRegisterTypesPlugin`, +`PostgisGeometryFieldsPlugin`, `PostgisMeasurementFieldsPlugin`, +`PostgisTransformationFieldsPlugin`, `PostgisAggregatePlugin`, +`PostgisSpatialRelationsPlugin`, …) if you prefer à-la-carte. + +## Features at a glance + +- **GeoJSON scalar** for input and output on every `geometry` / + `geography` column. +- **Full type hierarchy** — `Geometry` / `Geography` interfaces, + dimension-aware interfaces (`XY`, `XYZ`, `XYM`, `XYZM`), and + concrete subtype objects (`Point`, `LineString`, `Polygon`, + `MultiPoint`, `MultiLineString`, `MultiPolygon`, + `GeometryCollection`). +- **Subtype-specific accessors** — `x` / `y` / `z` on points + (`longitude` / `latitude` / `height` on `geography`), `points` on + line strings, `exterior` / `interiors` on polygons, etc. +- **Measurement fields** — `length`, `area`, `perimeter`, computed + geodesically from GeoJSON on the server. +- **Transformation fields** — `centroid`, `bbox`, `numPoints`. +- **Per-column spatial filters** — every PostGIS topological + predicate (`intersects`, `contains`, `within`, `dwithin`, …) and + every bounding-box operator (`bboxIntersects2D`, `bboxContains`, + `bboxLeftOf`, …) wired into the generated `where:` shape. +- **Aggregate fields** — `stExtent`, `stUnion`, `stCollect`, + `stConvexHull` exposed on every aggregate type for a geometry + column. +- **Spatial relations** — a `@spatialRelation` smart tag that + declares cross-table spatial joins as first-class relations (ORM + + GraphQL), backed by PostGIS predicates and GIST indexes. +- **Auto-detects PostGIS** in any schema (not just `public`) and + **degrades gracefully** when the extension isn't installed. + +## GeoJSON scalar and typed geometry columns + +A `geometry` / `geography` column is exposed as a typed GraphQL object +with a `geojson` field carrying the GeoJSON payload. You select it the +same way you select any nested object: -- GeoJSON scalar type for input/output -- GraphQL interfaces for geometry and geography base types -- Dimension-aware interfaces (XY, XYZ, XYM, XYZM) -- Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection -- Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.) -- Geography-aware field naming (longitude/latitude/height instead of x/y/z) -- Cross-table spatial relations via `@spatialRelation` smart tags (see below) -- Graceful degradation when PostGIS is not installed +```ts +// Read a location column as GeoJSON through the ORM +const result = await orm.location + .findMany({ + select: { name: true, geom: { select: { geojson: true } } }, + where: { name: { equalTo: 'Central Park Cafe' } }, + }) + .execute(); +``` -## Spatial relations via smart tags +Input values (mutations, filters) accept GeoJSON directly — any of +`Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`, +`MultiPolygon`, or `GeometryCollection`. -You declare a **spatial relation** with a `@spatialRelation` smart tag -on a `geometry` or `geography` column. The plugin turns that tag into a -virtual relation on the owning table: a new field on the table's -generated `where` input that runs a PostGIS join server-side. You write -one line of SQL once; the generated ORM and GraphQL schema pick it up -automatically. +## Dimension-aware interfaces and subtype fields -### At a glance +Each concrete subtype is its own GraphQL object with fields that make +sense for that subtype: -**Before** — GeoJSON juggling on the client: +| Subtype | Notable fields | +|---------------------|------------------------------------------------------| +| `Point` | `x` / `y` / `z` (or `longitude` / `latitude` / `height` on `geography`) | +| `LineString` | `points: [Point!]` | +| `Polygon` | `exterior: LineString`, `interiors: [LineString!]` | +| `MultiPoint` | `points: [Point!]` | +| `MultiLineString` | `lineStrings: [LineString!]` | +| `MultiPolygon` | `polygons: [Polygon!]` | +| `GeometryCollection`| `geometries: [Geometry!]` | -```ts -// 1. Pull every clinic's location as GeoJSON. -const clinics = await gql(`{ telemedicineClinics { nodes { id name location } } }`); -// 2. Pull the polygon of the one county you care about. -const { geom } = await gql(`{ countyByName(name: "Bay County") { geom } }`); -// 3. Run point-in-polygon on the client for each clinic. -const inBay = clinics.telemedicineClinics.nodes.filter((c) => - booleanPointInPolygon(c.location, geom), -); +On top of those, every geometry type also exposes the `XY` / `XYZ` / +`XYM` / `XYZM` dimension interfaces so a client can ask for +coordinates without branching on the specific subtype. + +```graphql +# Example GraphQL selection on a Polygon column +{ + counties { + nodes { + name + geom { + geojson + exterior { + points { x y } + } + } + } + } +} ``` -**After** — server-side, one trip: +## Measurement fields (`length`, `area`, `perimeter`) -```sql -COMMENT ON COLUMN telemedicine_clinics.location IS - E'@spatialRelation county counties.geom st_within'; +Subtype-appropriate measurement fields are added automatically, using +geodesic math on the GeoJSON payload (Haversine for distance, +spherical excess for area, WGS84 / SRID 4326 assumed): + +| Subtype | Fields added | +|-------------------------------------------------|-------------------------| +| `LineString`, `MultiLineString` | `length` | +| `Polygon`, `MultiPolygon` | `area`, `perimeter` | + +Values are `Float` in meters (length / perimeter) and square meters +(area). + +```graphql +{ + counties { + nodes { + name + geom { area perimeter } + } + } + routes { + nodes { + id + path { length } + } + } +} +``` + +For exact server-side PostGIS measurements (e.g. `ST_Area` with a +specific SRID projection), define a computed column in SQL — these +fields are client-facing conveniences, not a replacement for +projection-aware analytics. + +## Transformation fields (`centroid`, `bbox`, `numPoints`) + +Every geometry object also gets three lightweight transformation +fields: + +- `centroid: [Float!]` — coordinate-mean centroid. +- `bbox: [Float!]` — `[minX, minY, maxX, maxY]` bounding box. +- `numPoints: Int!` — total coordinate count. + +```graphql +{ + parks { + nodes { + name + geom { centroid bbox numPoints } + } + } +} ``` +For `ST_Transform` / `ST_Buffer` / `ST_Simplify` / `ST_MakeValid`, +which all take parameters, declare a custom SQL function or computed +column — the object-level transformation fields intentionally stick +to parameter-free helpers. + +## Per-column spatial filters + +Every PostGIS predicate is registered as a filter operator on the +column's `where:` entry, both for `geometry` and `geography` codecs: + +- Topological: `intersects`, `contains`, `containsProperly`, `within`, + `covers`, `coveredBy`, `touches`, `crosses`, `disjoint`, `overlaps`, + `equals`, `orderingEquals`. +- Distance: `dwithin` (parametric). +- 2D / ND bounding-box: `bboxIntersects2D`, `bboxIntersectsND`, + `bboxContains`, `bboxEquals`. +- Directional bounding-box: `bboxLeftOf`, `bboxRightOf`, `bboxAbove`, + `bboxBelow`, `bboxOverlapsOrLeftOf`, `bboxOverlapsOrRightOf`, + `bboxOverlapsOrAbove`, `bboxOverlapsOrBelow`. + +All of them take GeoJSON as input — the plugin wraps the value with +`ST_GeomFromGeoJSON(...)::` before it hits PostgreSQL, so +`Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`, +`MultiPolygon`, and `GeometryCollection` inputs all work uniformly. + ```ts -const inBay = await orm.telemedicineClinic +// Cities whose location is inside a polygon +const inBayArea = await orm.citiesGeom + .findMany({ + select: { id: true, name: true }, + where: { loc: { intersects: BAY_AREA_POLYGON } }, + }) + .execute(); + +// Cities whose bbox sits strictly west of a reference point +const westOfCentral = await orm.citiesGeom .findMany({ select: { id: true, name: true }, - where: { county: { some: { name: { equalTo: 'Bay County' } } } }, + where: { loc: { bboxLeftOf: { type: 'Point', coordinates: [-100.0, 37.77] } } }, }) .execute(); ``` -No polygon crosses the wire. The join happens in a single -`EXISTS (…)` subquery on the server, using a PostGIS predicate on the -two columns. +## PostGIS aggregate fields + +On every aggregate type for a geometry / geography column, the plugin +adds four SQL-level aggregate fields that run in-database: + +- `stExtent` — `ST_Extent(...)` — bounding box of all rows as a + GeoJSON Polygon. +- `stUnion` — `ST_Union(...)` — union of all rows as GeoJSON. +- `stCollect` — `ST_Collect(...)` — collect into a + `GeometryCollection`. +- `stConvexHull` — `ST_ConvexHull(ST_Collect(...))` — convex hull of + all rows as a GeoJSON Polygon. + +```graphql +{ + citiesGeoms { + aggregates { + stExtent { loc { geojson } } + stConvexHull { loc { geojson } } + } + } +} +``` + +## Spatial relations (`@spatialRelation`) + +Spatial relations are the plugin's cross-table feature: a way to +declare, directly on a database column, that two tables are related +*spatially* — "clinics inside a county", "parcels touching a road", +"events within 5 km of a user" — and get a first-class relation in +the generated ORM and GraphQL schema for free. + +### Why a dedicated primitive + +Without this, spatial joins from an app usually devolve into shipping +GeoJSON across the wire: every clinic as GeoJSON, every county +polygon as GeoJSON, a point-in-polygon loop on the client. An +auto-generated ORM can't do better on its own — it sees a `geometry` +column and stops there. Foreign keys describe equality; nothing +describes *containment* or *proximity*. + +A `@spatialRelation` tag declares that `clinics.location` is +"within" `counties.geom`, and the generated schema + ORM gain a +first-class `where: { county: { some: { … } } }` shape that runs the +join server-side, in one SQL query, using PostGIS and a GIST index. +No GeoJSON on the wire; the relation composes with the rest of your +`where:` the same way a foreign-key relation would. ### Declaring a relation -#### Tag grammar +Put the tag on the owning geometry / geography column: + +```sql +COMMENT ON COLUMN telemedicine_clinics.location IS + E'@spatialRelation county counties.geom st_within'; +``` + +Tag grammar: ``` @spatialRelation [] ``` -- `` — user-chosen name for the new field on the owning - table's `where` input. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. The - name is preserved as-written — `county` stays `county`, - `nearbyClinic` stays `nearbyClinic`. +- `` — user-chosen name for the new field on the + owner's `where` input. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. - `` — `table.column` (defaults to the owning column's - schema) or `schema.table.column` (for references in another schema, - e.g. a shared `geo` schema). -- `` — one of the eight PG-native snake_case tokens listed in - [Operator reference](#operator-reference). -- `` — required if and only if the operator is parametric. - Today that's `st_dwithin`, which needs a parameter name (typically - `distance`). - -Both sides of the relation must be `geometry` or `geography`, and they -must share the **same** base codec — you cannot mix `geometry` and -`geography`. + schema) or `schema.table.column`. +- `` — one of the eight PG-native snake_case tokens listed + below. +- `` — required if the operator is parametric. Today that + is only `st_dwithin` (use `distance`). -#### Multiple relations on one column +Both sides must be `geometry` or `geography`, and share the **same** +codec — mixing is rejected at schema build. -Stack tags. Each line becomes its own field on the owning table's -`where` input: +Stack multiple relations on one column by separating tags with `\n`: ```sql COMMENT ON COLUMN telemedicine_clinics.location IS @@ -150,115 +324,98 @@ COMMENT ON COLUMN telemedicine_clinics.location IS '@spatialRelation nearbyClinic telemedicine_clinics.location st_dwithin distance'; ``` -The four relations above all exist in the integration test suite and -can be used in the same query. Two relations on the same owner cannot -share a ``. - ### Operator reference -| Tag operator | PostGIS function | Parametric? | Symmetric? | Typical use | -|---|---|---|---|---| -| `st_contains` | `ST_Contains(A, B)` | no | **no** (A contains B) | polygon containing a point / line / polygon | -| `st_within` | `ST_Within(A, B)` | no | **no** (A within B) | point-in-polygon, line-in-polygon | -| `st_covers` | `ST_Covers(A, B)` | no | **no** | like `st_contains` but boundary-inclusive | -| `st_coveredby` | `ST_CoveredBy(A, B)` | no | **no** | dual of `st_covers` | -| `st_intersects` | `ST_Intersects(A, B)`| no | yes | any overlap at all | -| `st_equals` | `ST_Equals(A, B)` | no | yes | exact geometry match | -| `st_bbox_intersects` | `A && B` (infix) | no | yes | fast bounding-box prefilter | -| `st_dwithin` | `ST_DWithin(A, B, d)`| **yes** (`d`) | yes | radius / proximity search | - -> The tag reads left-to-right as **"owner op target"**, and the emitted -> SQL is exactly `ST_(owner_col, target_col[, distance])`. For -> symmetric operators (`st_intersects`, `st_equals`, `st_dwithin`, -> `st_bbox_intersects`) argument order doesn't matter. For directional -> operators (`st_within`, `st_contains`, `st_covers`, `st_coveredby`), -> flipping the two columns inverts the result set. Rule of thumb: put -> the relation on the column whose type makes the sentence true — -> `clinics.location st_within counties.geom` reads naturally; the -> reverse does not. - -### Using the generated `where` shape - -#### Through the ORM +| Tag operator | PostGIS function | Parametric? | Symmetric? | Typical use | +|----------------------|-----------------------|----------------|-----------------------|--------------------------------------------------| +| `st_contains` | `ST_Contains(A, B)` | no | **no** (A contains B) | polygon containing a point / line / polygon | +| `st_within` | `ST_Within(A, B)` | no | **no** (A within B) | point-in-polygon, line-in-polygon | +| `st_covers` | `ST_Covers(A, B)` | no | **no** | like `st_contains`, boundary-inclusive | +| `st_coveredby` | `ST_CoveredBy(A, B)` | no | **no** | dual of `st_covers` | +| `st_intersects` | `ST_Intersects(A, B)` | no | yes | any overlap at all | +| `st_equals` | `ST_Equals(A, B)` | no | yes | exact geometry match | +| `st_bbox_intersects` | `A && B` (infix) | no | yes | fast bounding-box prefilter | +| `st_dwithin` | `ST_DWithin(A, B, d)` | **yes** (`d`) | yes | radius / proximity search | + +The tag reads left-to-right as **"owner op target"**, and the emitted +SQL is exactly `ST_(owner_col, target_col[, distance])`. For +directional operators (`st_within`, `st_contains`, `st_covers`, +`st_coveredby`), flipping the two columns inverts the result set; put +the relation on the column whose type makes the sentence true. + +### Using a spatial relation from the ORM + +Every 2-argument relation exposes `some` / `every` / `none` against +the target table's full `where` input: ```ts -// "Clinics inside any county named 'Bay County'" +// "Clinics inside LA County" — st_within, one SQL query, no GeoJSON on the wire. await orm.telemedicineClinic .findMany({ select: { id: true, name: true }, - where: { county: { some: { name: { equalTo: 'Bay County' } } } }, + where: { county: { some: { name: { equalTo: 'LA County' } } } }, }) .execute(); -``` - -#### Through GraphQL -The connection argument is `where:` at the GraphQL layer too — same -name, same tree. Only the generated input **type** keeps the word -"Filter" in it (e.g. `TelemedicineClinicFilter`): +// "Clinics NOT in NYC County" — negation via `none`. +await orm.telemedicineClinic + .findMany({ + select: { id: true }, + where: { county: { none: { name: { equalTo: 'NYC County' } } } }, + }) + .execute(); -```graphql -{ - telemedicineClinics( - where: { county: { some: { name: { equalTo: "Bay County" } } } } - ) { - nodes { id name } - } -} +// "Any clinic that sits inside at least one county" — empty inner +// clause still excludes points that fall outside every county. +await orm.telemedicineClinic + .findMany({ + select: { id: true, name: true }, + where: { county: { some: {} } }, + }) + .execute(); ``` -#### `some` / `every` / `none` - -Every 2-argument relation exposes three modes. They mean what you'd -expect, backed by `EXISTS` / `NOT EXISTS`: - -- `some: { }` — the row matches if at least one related - target row passes the where clause. -- `none: { }` — the row matches if no related target row - passes. -- `every: { }` — the row matches when every related - target row passes (i.e. "no counter-example exists"). Note that - `every: {}` on an empty target set is vacuously true. - -An empty inner clause (`some: {}`) means "at least one related target -row exists, any row will do" — so for `@spatialRelation county … -st_within`, clinics whose point is inside zero counties are correctly -excluded. - -#### Parametric operators (`st_dwithin` + `distance`) - -Parametric relations add a **required** `distance: Float!` field next -to `some` / `every` / `none`. The distance parametrises the join -itself, not the inner `some:` clause: +Parametric relations (today: `st_dwithin`) add a required `distance` +field alongside `some` / `every` / `none`: ```ts +// "Clinics within 10 SRID units of any cardiology clinic" — self-relation +// with parametric distance; a row never matches itself. await orm.telemedicineClinic .findMany({ select: { id: true, name: true }, where: { nearbyClinic: { - distance: 5000, - some: { specialty: { equalTo: 'pediatrics' } }, + distance: 10.0, + some: { specialty: { equalTo: 'cardiology' } }, }, }, }) .execute(); ``` -Distance units follow PostGIS semantics: +### Using a spatial relation from GraphQL -| Owner codec | `distance` units | -|---|---| -| `geography` | meters | -| `geometry` | SRID coordinate units (degrees for SRID 4326) | +The same tree, same field names — just under `where:` on the +connection argument: -#### Composition with `and` / `or` / `not` and scalar where clauses +```graphql +{ + telemedicineClinics( + where: { county: { some: { name: { equalTo: "Bay County" } } } } + ) { + nodes { id name } + } +} +``` + +### Composition Spatial relations live in the same `where:` tree as every scalar -predicate and compose the same way: +predicate and compose identically: ```ts -// AND — Bay County clinics that are cardiology +// Bay County clinics that are cardiology where: { and: [ { county: { some: { name: { equalTo: 'Bay County' } } } }, @@ -266,7 +423,7 @@ where: { ], } -// OR — Bay County clinics OR the one named "LA Pediatrics" +// Bay County clinics OR the one named "LA Pediatrics" where: { or: [ { county: { some: { name: { equalTo: 'Bay County' } } } }, @@ -274,30 +431,24 @@ where: { ], } -// NOT — clinics that are NOT in Bay County +// Clinics NOT in Bay County where: { not: { county: { some: { name: { equalTo: 'Bay County' } } } }, } ``` -Inside `some` / `every` / `none`, the inner where clause is the target -table's full `where` input — every scalar predicate the target exposes -is available. - -### Self-relations +### Self-relations and self-exclusion When the owner and target columns are the same column, the plugin emits a self-exclusion predicate so a row never matches itself: -- Single-column primary key: `other. <> self.` -- Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)` +- Single-column primary key: `other. <> self.`. +- Composite primary key: `(other.a, other.b) IS DISTINCT FROM (self.a, self.b)`. +- Tables without a primary key are rejected at schema build. -Tables without a primary key are rejected at schema build — a -self-relation there would match every row against itself. - -One concrete consequence: with `st_dwithin`, a self-relation at -`distance: 0` matches zero rows, because the only candidate at -distance 0 is the row itself, which is excluded. +One consequence: with `st_dwithin`, a self-relation at `distance: 0` +matches zero rows, because the only candidate at distance 0 is the +row itself — and it is excluded. ### Generated SQL shape @@ -313,19 +464,20 @@ WHERE EXISTS ( ); ``` -The EXISTS lives inside the owner's generated `where` input, so it -composes with pagination, ordering, and the rest of the outer plan. -`st_bbox_intersects` compiles to infix `&&` rather than a function call. -PostGIS functions are called with whichever schema PostGIS is installed -in, so non-`public` installs work without configuration. +The `EXISTS` sits inside the owner's generated `where` input, so it +composes cleanly with pagination, ordering, and the rest of the outer +plan. `st_bbox_intersects` compiles to infix `&&` rather than a +function call. PostGIS functions are called with whichever schema +PostGIS is installed in, so non-`public` installs work without extra +configuration. ### Indexing Spatial predicates without a GIST index fall back to sequential scans, which is almost never what you want. The plugin checks your target -columns at schema-build time and emits a non-fatal warning when a GIST -index is missing, including the recommended `CREATE INDEX ... USING -GIST(...)` in the warning text. +columns at schema-build time and emits a non-fatal warning when a +GIST index is missing, including the recommended `CREATE INDEX … +USING GIST(...)` in the warning text: ```sql CREATE INDEX ON telemedicine_clinics USING GIST(location); @@ -333,8 +485,8 @@ CREATE INDEX ON counties USING GIST(geom); ``` If a particular column is a known exception (e.g. a small prototype -table), set `@spatialRelationSkipIndexCheck` on that column to suppress -the warning. +table), set `@spatialRelationSkipIndexCheck` on that column to +suppress the warning. ### `geometry` vs `geography` @@ -348,24 +500,31 @@ single relation. ### FAQ -- **"Why doesn't `some: {}` return every row?"** — because `some` means - "at least one related target row exists". Rows whose column has no - match on the other side are correctly excluded. -- **"Why does `distance: 0` on a self-relation return nothing?"** — the - self-exclusion predicate removes the row's match with itself, so at - distance 0 no candidates remain. +- **"Why doesn't `some: {}` return every row?"** — because `some` + means "at least one related target row exists". Rows whose column + has no match on the other side are correctly excluded. +- **"Why does `distance: 0` on a self-relation return nothing?"** — + the self-exclusion predicate removes the row's match with itself, + so at distance 0 no candidates remain. - **"Can I reuse a `relationName` across tables?"** — yes; uniqueness is scoped to the owning table. - **"Can I declare the relation from the polygon side instead of the point side?"** — yes. Flip owner and target and use the inverse operator (`st_contains` in place of `st_within`). Same rows, same SQL, different `where` location. -- **"Does this work with PostGIS installed in a non-`public` schema?"** - — yes. +- **"Does this work with PostGIS installed in a non-`public` + schema?"** — yes. - **"Can I use a spatial relation in `orderBy` or on a connection - field?"** — no; it's a where-only construct. Use PostGIS measurement - fields (see the `geometry-fields` / `measurement-fields` plugins) for - values you want to sort on. + field?"** — no; it's a where-only construct. Use the measurement / + transformation fields for values you want to sort on. + +## Graceful degradation + +If the `postgis` extension isn't installed in the target database, +the plugin detects that at schema-build time and skips type, filter, +aggregate, and spatial-relation registration instead of breaking the +build. Turning PostGIS on later only requires restarting the server +(or invalidating the schema cache) — no config change. ## License