Skip to content

Commit 13f8293

Browse files
committed
docs(graphile-postgis): restore generic PostGIS intro; scope spatial relations as its own feature
1 parent 5094bbe commit 13f8293

1 file changed

Lines changed: 104 additions & 45 deletions

File tree

graphile/graphile-postgis/README.md

Lines changed: 104 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,36 @@
1414

1515
PostGIS support for PostGraphile v5.
1616

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.).
17+
Automatically generates GraphQL types for PostGIS `geometry` and
18+
`geography` columns — GeoJSON scalars, dimension-aware interfaces,
19+
subtype-specific fields (coordinates, points, rings, etc.), plus
20+
measurement / transformation / aggregate fields — so spatial data
21+
flows through your API with the same ergonomics as every other column.
1822

1923
## The problem
2024

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.
25+
PostGIS is a first-class spatial database, but the default PostGraphile
26+
schema doesn't know what to do with it: a `geometry` column looks
27+
opaque to the generated GraphQL types, there's no scalar for GeoJSON,
28+
no concrete type for `Point` vs `Polygon`, no way to read back
29+
coordinates, lengths, areas, or bounding boxes without writing your
30+
own computed columns. You end up hand-rolling GraphQL types,
31+
client-side parsers, and one-off SQL helpers for every spatial column.
32+
33+
`graphile-postgis` closes that gap. The plugin detects the PostGIS
34+
extension (even when installed in a non-`public` schema), registers a
35+
`GeoJSON` scalar, and generates a full type hierarchy — `Geometry` /
36+
`Geography` interfaces, dimension-aware interfaces (`XY`, `XYZ`, `XYM`,
37+
`XYZM`), and concrete subtypes (`Point`, `LineString`, `Polygon`,
38+
`MultiPoint`, `MultiLineString`, `MultiPolygon`, `GeometryCollection`)
39+
— with subtype-specific fields, measurement fields, transformation
40+
fields, and aggregate fields already wired up. If PostGIS isn't
41+
installed, the plugin degrades gracefully instead of failing the
42+
schema build.
43+
44+
Cross-table spatial joins — the "which clinics are inside this county"
45+
shape — are a separate feature built on top of the type layer; see
46+
[Spatial relations](#spatial-relations).
4347

4448
## Installation
4549

@@ -60,24 +64,79 @@ const preset = {
6064
## Features
6165

6266
- GeoJSON scalar type for input/output
63-
- GraphQL interfaces for geometry and geography base types
64-
- Dimension-aware interfaces (XY, XYZ, XYM, XYZM)
65-
- Concrete types for all geometry subtypes: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection
66-
- Subtype-specific fields (x/y/z for Points, points for LineStrings, exterior/interiors for Polygons, etc.)
67-
- Geography-aware field naming (longitude/latitude/height instead of x/y/z)
68-
- Cross-table spatial relations via `@spatialRelation` smart tags (see below)
69-
- Graceful degradation when PostGIS is not installed
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
67+
- GraphQL interfaces for `geometry` and `geography` base types
68+
- Dimension-aware interfaces (`XY`, `XYZ`, `XYM`, `XYZM`)
69+
- Concrete types for all geometry subtypes: `Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`, `MultiPolygon`, `GeometryCollection`
70+
- Subtype-specific fields (`x` / `y` / `z` for points, `points` for line strings, `exterior` / `interiors` for polygons, etc.)
71+
- Geography-aware field naming (`longitude` / `latitude` / `height` instead of `x` / `y` / `z`)
72+
- Measurement fields (`length`, `area`, `perimeter`) computed geodesically from GeoJSON
73+
- Transformation fields (`centroid`, `bbox`, `numPoints`)
74+
- PostGIS aggregate fields (`stExtent`, `stUnion`, `stCollect`, `stConvexHull`)
75+
- Cross-table [spatial relations](#spatial-relations) via `@spatialRelation` smart tags
76+
- Auto-detects PostGIS in non-`public` schemas and degrades gracefully when the extension is missing
77+
78+
## Core PostGIS type support
79+
80+
Out of the box, the preset turns every `geometry` / `geography` column
81+
into a real, typed GraphQL field:
82+
83+
- **GeoJSON scalar + subtype objects.** Values are serialised as
84+
GeoJSON. Each concrete subtype (`Point`, `Polygon`, …) is its own
85+
object type and exposes its natural accessor fields — e.g. `Point`
86+
has `x` / `y` / `z` (or `longitude` / `latitude` / `height` on a
87+
`geography` column), `Polygon` has `exterior` and `interiors`,
88+
`LineString` has `points`.
89+
- **Dimension-aware interfaces.** `XY`, `XYZ`, `XYM`, and `XYZM`
90+
interfaces let clients request coordinates without caring about the
91+
specific subtype.
92+
- **Measurement, transformation, and aggregate fields.** Geometry
93+
types expose `length`, `area`, `perimeter`, `centroid`, `bbox`, and
94+
`numPoints`; aggregate types expose `stExtent`, `stUnion`,
95+
`stCollect`, and `stConvexHull`.
96+
- **Graceful degradation.** If the `postgis` extension isn't installed
97+
in the target database, the plugin skips type registration instead
98+
of breaking the schema build.
99+
100+
For cross-table spatial joins — e.g. "clinics inside a county" — see
101+
[Spatial relations](#spatial-relations).
102+
103+
## Spatial relations
104+
105+
### The problem
106+
107+
Even with GeoJSON types on every column, working with PostGIS from an
108+
app is usually painful for one specific reason: **you end up juggling
109+
large amounts of GeoJSON across tables on the client**. You fetch
110+
every clinic as GeoJSON, fetch every county polygon as GeoJSON, and
111+
then — in the browser — loop through them yourself to figure out
112+
which clinic sits inside which county. Every query, every count,
113+
every page of results becomes a client-side geometry problem.
114+
115+
An ORM generated automatically from your database schema can't fix
116+
this on its own. It sees a `geometry` column and stops there — it has
117+
no idea that "clinics inside a county" is the question you actually
118+
want to ask. Foreign keys tell it how tables relate by equality;
119+
nothing tells it how tables relate *spatially*.
120+
121+
So we added the missing primitive: a **spatial relation**. You
122+
declare, on the database column, that `clinics.location` is "inside"
123+
`counties.geom`, and the generated GraphQL schema + ORM gain a
124+
first-class `where: { county: { some: { … } } }` shape that runs the
125+
join server-side, in one SQL query, using PostGIS and a GIST index.
126+
No GeoJSON on the wire, no client-side geometry, and the relation
127+
composes with the rest of your `where:` the same way a foreign-key
128+
relation would.
129+
130+
### Smart-tag overview
131+
132+
You declare a spatial relation with a `@spatialRelation` smart tag on
133+
a `geometry` or `geography` column. The plugin turns that tag into a
75134
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.
135+
generated `where` input that runs a PostGIS join server-side. You
136+
write one line of SQL once; the generated ORM and GraphQL schema pick
137+
it up automatically.
79138

80-
### At a glance
139+
#### At a glance
81140

82141
**Before** — GeoJSON juggling on the client:
83142

@@ -114,7 +173,7 @@ two columns.
114173

115174
### Declaring a relation
116175

117-
#### Tag grammar
176+
**Tag grammar**
118177

119178
```
120179
@spatialRelation <relationName> <targetRef> <operator> [<paramName>]
@@ -137,7 +196,7 @@ Both sides of the relation must be `geometry` or `geography`, and they
137196
must share the **same** base codec — you cannot mix `geometry` and
138197
`geography`.
139198

140-
#### Multiple relations on one column
199+
**Multiple relations on one column**
141200

142201
Stack tags. Each line becomes its own field on the owning table's
143202
`where` input:
@@ -179,7 +238,7 @@ share a `<relationName>`.
179238
180239
### Using the generated `where` shape
181240

182-
#### Through the ORM
241+
**Through the ORM**
183242

184243
```ts
185244
// "Clinics inside any county named 'Bay County'"
@@ -191,7 +250,7 @@ await orm.telemedicineClinic
191250
.execute();
192251
```
193252

194-
#### Through GraphQL
253+
**Through GraphQL**
195254

196255
The connection argument is `where:` at the GraphQL layer too — same
197256
name, same tree. Only the generated input **type** keeps the word
@@ -207,7 +266,7 @@ name, same tree. Only the generated input **type** keeps the word
207266
}
208267
```
209268

210-
#### `some` / `every` / `none`
269+
**`some` / `every` / `none`**
211270

212271
Every 2-argument relation exposes three modes. They mean what you'd
213272
expect, backed by `EXISTS` / `NOT EXISTS`:
@@ -225,7 +284,7 @@ row exists, any row will do" — so for `@spatialRelation county …
225284
st_within`, clinics whose point is inside zero counties are correctly
226285
excluded.
227286

228-
#### Parametric operators (`st_dwithin` + `distance`)
287+
**Parametric operators (`st_dwithin` + `distance`)**
229288

230289
Parametric relations add a **required** `distance: Float!` field next
231290
to `some` / `every` / `none`. The distance parametrises the join

0 commit comments

Comments
 (0)