Chapter 41 · Volume V — Reference

GIS & Spatial Data

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 (TEXT on SQLite, JSONB on 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) and ST_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

AttributeDefaultMeaning
geometryType(any)Constrains the accepted geometry kind: point, linestring, polygon, multipoint, multilinestring, multipolygon, geometrycollection (case-insensitive). Omit to accept any.
srid4326Spatial 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 type from the set above;
  • a coordinates array (or a geometries array for GeometryCollection);
  • and, when geometryType is declared, the value’s type must 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

BackendStored asSpatial columnIndex
SQLiteTEXT (GeoJSON)
PostgreSQL (no PostGIS)JSONB (GeoJSON)
PostgreSQL + PostGISJSONB (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.

OperatorQuery parameterCompiles to
bboxfilter[geom][bbox]=minLon,minLat,maxLon,maxLatgeom__geom && ST_MakeEnvelope(…, 4326)
intersectsfilter[geom][intersects]=<GeoJSON>ST_Intersects(geom__geom, ST_GeomFromGeoJSON(…))
withinfilter[geom][within]=<GeoJSON>ST_Within(geom__geom, ST_GeomFromGeoJSON(…))
  • bbox uses the && bounding-box-overlap operator — the fast map-viewport query; it returns every feature whose bounding box overlaps the envelope.
  • intersects / within take 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-4326 srid, 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, and within are.
  • No reprojection. GeoJSON is WGS84/4326; a declared non-4326 srid labels the PostGIS column but does not reproject.
  • SQLite stores and serves GeoJSON but has no spatial index or operators; spatial filters / simplify return 400 there. For large static reference geography you may instead serve from a dedicated tile/feature server (e.g. Martin / pg_featureserv) outside ADL.

  • 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)