Status: Normative · Platform version: 0.3.1 · Display label: ADL v0.3
This chapter specifies ADL’s native support for geographic / spatial data: the
geometry field type, its storage on SQLite and PostgreSQL (PostGIS), the
spatial query operators, and the patterns for serving large geometry layers to a
map UI (viewport, level-of-detail, server-side simplification).
Spatial support was added in v0.3.1 (issues #43 and #53). It is additive — domains with no geometry fields are unaffected.
41.1 Overview
A geometry field holds a GeoJSON geometry value (a Point, LineString,
Polygon, etc.). The design has two layers:
- Portable layer (every backend): the GeoJSON is validated and stored as
JSON (
TEXTon SQLite,JSONBon PostgreSQL). The REST API accepts and emits GeoJSON objects. This works with no special database. - Spatial layer (PostgreSQL + PostGIS): each geometry field additionally
gets a database-maintained PostGIS
geometry(<type>,<srid>)column and a GiST index, which back server-side spatial queries (bbox,intersects,within) andST_Simplify.
The GeoJSON column is always the source of truth; the PostGIS column is a generated sidecar. This keeps the wire contract identical on every backend and the CRUD path untouched — you only gain spatial querying on PostGIS.
41.2 The geometry field type
Declare a geometry field with type: geometry (alias geojson):
resources:
TerrainFeature:
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true }
kind: { type: string }
geom: { type: geometry, required: true, geometryType: polygon, srid: 4326 }
Attributes
| Attribute | Default | Meaning |
|---|---|---|
geometryType | (any) | Constrains the accepted geometry kind: point, linestring, polygon, multipoint, multilinestring, multipolygon, geometrycollection (case-insensitive). Omit to accept any. |
srid | 4326 | Spatial reference id. GeoJSON is WGS84 (4326) by specification; other values are honored on the PostGIS column but no reprojection is performed. |
A geometry field is otherwise a normal field — it may be required, used in
object: composition, etc.
Wire format
- Accepted on create/update as a GeoJSON object (
{"type":"Point","coordinates":[lon,lat]}) or a JSON string of one. - Emitted in responses as a GeoJSON object (never a string), on every backend.
POST /api/v1/atlas/terrain-features
{ "name": "Hill 12", "geom": { "type": "Point", "coordinates": [10.0, 20.0] } }
Validation
A geometry value must be a well-formed GeoJSON geometry:
- an object with a string
typefrom the set above; - a
coordinatesarray (or ageometriesarray forGeometryCollection); - and, when
geometryTypeis declared, the value’stypemust match it.
Failures return 422 VALIDATION_ERROR with one of: invalid_geometry,
geometry_type_mismatch. Validation runs on both create and update.
41.3 Storage model
| Backend | Stored as | Spatial column | Index |
|---|---|---|---|
| SQLite | TEXT (GeoJSON) | — | — |
| PostgreSQL (no PostGIS) | JSONB (GeoJSON) | — | — |
| PostgreSQL + PostGIS | JSONB (GeoJSON, source of truth) | generated <field>__geom geometry(<Type>,<srid>) | GiST |
On store.postgres with the postgis extension, the DDL for a geometry field
geom emits, in addition to the geom JSONB column:
"geom__geom" geometry(POLYGON,4326)
GENERATED ALWAYS AS (ST_SetSRID(ST_GeomFromGeoJSON("geom"::text), 4326)) STORED;
CREATE INDEX IF NOT EXISTS gix_atlas_terrain_features_geom
ON atlas_terrain_features USING GIST ("geom__geom");
The __geom column is internal — it is kept in sync by the database and is
hidden from API responses (the API returns only the GeoJSON field).
Capability probe
At domain submit, when the model has geometry fields and the dialect is
PostgreSQL, the runtime probes for the postgis extension
(SELECT 1 FROM pg_extension WHERE extname='postgis'). If absent it falls back
to JSONB-only and logs a clear warning — no hard failure. The portable layer
(validation, storage, GeoJSON I/O) still works; only spatial querying is
unavailable until the extension is installed.
Enable PostGIS once per database:
CREATE EXTENSION IF NOT EXISTS postgis;
The bundled docker-compose.yml ships postgis/postgis:17-3.5, which provides
the extension.
41.4 Spatial query operators
On PostgreSQL + PostGIS, list and count accept spatial filters on geometry
fields, expressed in the standard filter[field][op]=value grammar (Chapter 16).
They compile to PostGIS predicates against the GiST-indexed <field>__geom
column.
| Operator | Query parameter | Compiles to |
|---|---|---|
| bbox | filter[geom][bbox]=minLon,minLat,maxLon,maxLat | geom__geom && ST_MakeEnvelope(…, 4326) |
| intersects | filter[geom][intersects]=<GeoJSON> | ST_Intersects(geom__geom, ST_GeomFromGeoJSON(…)) |
| within | filter[geom][within]=<GeoJSON> | ST_Within(geom__geom, ST_GeomFromGeoJSON(…)) |
bboxuses the&&bounding-box-overlap operator — the fast map-viewport query; it returns every feature whose bounding box overlaps the envelope.intersects/withintake a GeoJSON geometry value and run the full PostGIS predicate.- Spatial filters apply to both
GET /{domain}/{resource}and…/{resource}/count.
# Features overlapping the current map viewport:
GET /api/v1/atlas/terrain-features?filter[geom][bbox]=10,20,30,40
# Features inside a polygon:
GET /api/v1/atlas/terrain-features?filter[geom][within]={"type":"Polygon","coordinates":[[[0,0],[30,0],[30,30],[0,30],[0,0]]]}
Errors. Using a spatial operator on a non-geometry field, or on a SQLite
store, returns 400 INVALID_QUERY with a clear message (not a raw database
error). A malformed bbox (not four numbers) also returns 400.
SRID note: the envelope is built at SRID
4326. If a field declares a non-4326srid, mix it with care — no reprojection is performed.
41.5 Serving large layers: viewport, LOD, simplify
Three composable techniques let a map client fetch only what it needs:
Viewport
Filter to the visible map rectangle with bbox (§41.4). This is the primary
mechanism and is GiST-indexed, so it scales to large collections.
Level of detail (LOD)
There is no special LOD type — model it as an ordinary indexed integer and filter on it. Tag each feature with a minimum zoom and index it:
minZoom: { type: integer }
# ...
indexes:
- fields: [minZoom]
# Wide zoom: only coarse features.
GET /api/v1/atlas/terrain-features?filter[minZoom][lte]=5&filter[geom][bbox]=...
Server-side simplification
GET /{domain}/{resource}?simplify=<tolerance> thins each geometry field with
ST_Simplify(<field>__geom, <tolerance>) and returns the simplified GeoJSON
in place of the full geometry — reducing payload for wide-zoom rendering.
GET /api/v1/atlas/terrain-features?filter[geom][bbox]=...&simplify=0.01
simplify requires PostgreSQL + PostGIS and a geometry field; otherwise (bad
tolerance, SQLite, no geometry field) it returns 400 INVALID_QUERY. Tolerance
is in the layer’s coordinate units (degrees for WGS84).
Combine all three for an efficient tiled map: bbox (viewport) + minZoom
filter (LOD) + simplify (geometry thinning).
41.6 OpenAPI
A geometry field is published in the OpenAPI schema as a GeoJSON geometry object
(type: object with a type enum and coordinates/geometries), so generated
typed clients model it correctly.
41.7 Worked example
domain:
name: atlas
version: "1.0.0"
api: { prefix: /api/v1/atlas }
resources:
TerrainFeature:
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true }
kind: { type: string }
minZoom: { type: integer }
geom: { type: geometry, required: true, geometryType: polygon, srid: 4326 }
indexes:
- fields: [minZoom]
permissions: { list: ["*"], read: ["*"], create: [admin], update: [admin], delete: [admin] }
# Create (GeoJSON object in):
curl -sk -X POST https://host/api/v1/atlas/terrain-features \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Ridge","kind":"obstacle","minZoom":4,
"geom":{"type":"Polygon","coordinates":[[[10,20],[11,20],[11,21],[10,20]]]}}'
# Viewport + LOD + simplify (GeoJSON object out):
curl -sk "https://host/api/v1/atlas/terrain-features?filter[geom][bbox]=0,0,30,30&filter[minZoom][lte]=5&simplify=0.05"
For bulk-loading reference/geo layers efficiently, see the bulk-create
endpoint (POST /{resource}/bulk, with upsert for idempotent reloads) — it
runs in a single transaction and is configurable via adl.bulk.max_items.
41.8 Limitations & notes
- Migration-added geometry fields. The generated PostGIS column + GiST index
are emitted on
CREATE TABLE. A geometry field added to an existing domain via a later migration (ALTER TABLE) currently gets only the JSONB column; recreate the table (or await a follow-up that teaches the migration generator the sidecar) to get spatial indexing for it. dwithin(distance-within) is not yet implemented;bbox,intersects, andwithinare.- No reprojection. GeoJSON is WGS84/4326; a declared non-4326
sridlabels the PostGIS column but does not reproject. - SQLite stores and serves GeoJSON but has no spatial index or operators;
spatial filters /
simplifyreturn400there. For large static reference geography you may instead serve from a dedicated tile/feature server (e.g. Martin / pg_featureserv) outside ADL.
41.9 Related chapters
- Chapter 7 — Type System (the geometry type sits alongside the other field types)
- Chapter 16 — Query Parameters (the
filter[field][op]=…grammar) - Chapter 19 — Indexes (declare the
minZoom/ GiST-eligible indexes) - Chapter 5 — Store Adapters (SQLite vs PostgreSQL)