Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spatial / Geo

patinaDB has a first-class Point type with Neo4j’s four coordinate reference systems (CRSs), the point() / distance() / point.distance.ellipsoidal() / point.withinBBox() functions, and a CREATE POINT INDEX statement. Points are stored, indexed on disk with a space-filling-curve key, and queried correctly, and a planner fast path turns a radius/bbox query (and a kNN ORDER BY distance(...) LIMIT k) into a curve-range seek instead of a full scan.

The Point type

A point carries a CRS (identified by a Neo4j SRID) and 2 or 3 coordinates:

CRSSRIDDimCoordinates
cartesian72032[x, y]
cartesian-3d91573[x, y, z]
wgs-8443262[longitude, latitude]
wgs-84-3d49793[longitude, latitude, height]

For geographic (wgs-84) points, x is the longitude and y is the latitude (Neo4j’s convention), so p.latitude reads the second coordinate.

Two points are equal iff they have the same SRID and coordinates — a cartesian and a wgs-84 point are never equal even with identical numbers. < / > on points are undefined (they return null, matching Neo4j); ORDER BY uses a deterministic total order (the space-filling-curve order).

point({...}) — constructing points

CRS is inferred from the map keys, or set explicitly with crs / srid:

// cartesian (x/y) — 2D and 3D
RETURN point({x: 3.0, y: 4.0})
RETURN point({x: 1.0, y: 2.0, z: 3.0})

// geographic (latitude/longitude) — 2D and 3D
RETURN point({latitude: 52.52, longitude: 13.405})
RETURN point({latitude: 52.52, longitude: 13.405, height: 100.0})

// explicit override
RETURN point({x: 13.4, y: 52.5, crs: 'wgs-84'})
RETURN point({x: 1.0, y: 2.0, srid: 4326})

Out-of-range latitude (|lat| > 90) or longitude (|lon| > 180) raises an ArgumentError. point(null) returns null.

Points can be stored on nodes and relationships like any other property:

CREATE (:City {name: 'Berlin', loc: point({latitude: 52.52, longitude: 13.405})})

Accessors

Field access reads a coordinate or CRS component:

WITH point({x: 3.0, y: 4.0, z: 5.0}) AS p
RETURN p.x, p.y, p.z, p.crs, p.srid

MATCH (c:City)
RETURN c.loc.latitude, c.loc.longitude

.x/.longitude → axis 0, .y/.latitude → axis 1, .z/.height → axis 2, .crs → the CRS name string, .srid → the integer SRID. Accessing a missing component (e.g. .z on a 2-D point) returns null.

distance() / point.distance()

Both spellings work. Returns metres for geographic points (spherical Haversine) and Euclidean distance for cartesian points:

// Euclidean → 5.0
RETURN distance(point({x: 0, y: 0}), point({x: 3, y: 4}))

// Haversine, Berlin → Paris ≈ 878 km (metres)
RETURN point.distance(
  point({latitude: 52.52,  longitude: 13.405}),
  point({latitude: 48.8566, longitude: 2.3522})
)

// radius query (served by a curve-range seek when a POINT INDEX exists)
MATCH (c:City)
WHERE distance(c.loc, point({latitude: 52.5, longitude: 13.4})) < 5000
RETURN c.name

Mixed CRS (or mismatched dimensionality) returns null, matching Neo4j. wgs-84 distance is spherical (mean Earth radius 6 371 009 m), ~0.3 % off the true geoid — fine for radius search, not survey work.

point.distance.ellipsoidal() — accurate WGS-84 geodesic

When you need survey-grade accuracy, use the ellipsoidal variant, which computes the true WGS-84 geodesic distance via the Vincenty-inverse formula (accounting for the Earth’s oblateness). distance() stays spherical Haversine (Neo4j parity); point.distance.ellipsoidal() is the distinct, more-accurate spelling:

// WGS-84 ellipsoidal (Vincenty), Berlin → Paris ≈ 878 km — a distinct value
// from Haversine, but within 0.5 %.
RETURN point.distance.ellipsoidal(
  point({latitude: 52.52,  longitude: 13.405}),
  point({latitude: 48.8566, longitude: 2.3522})
)

Same argument conventions as distance(): null/non-point operand → null, mixed CRS/dimensionality → null. It is WGS-84 only — a cartesian argument falls back to plain Euclidean (there is no ellipsoid without a geoid). Near-antipodal pairs (where Vincenty does not converge) fall back to the always-finite spherical distance rather than emitting NaN. It is an exact scalar only — it does not drive the radius/bbox seek (which uses the spherical bounding math); a radius filter should still use distance().

point.withinBBox()

Bool — whether a point lies inside an axis-aligned bounding box:

MATCH (c:City)
WHERE point.withinBBox(c.loc,
        point({x: -1, y: -1}),
        point({x: 10, y: 10}))
RETURN c.name

A geographic box whose lowerLeft.longitude > upperRight.longitude wraps the antimeridian — inc 1–3 reject it with a clear error rather than silently returning false (full antimeridian/pole handling is inc-4).

CREATE POINT INDEX

A point index is a planner-enablement marker. The on-disk curve keys are written for every point property unconditionally (see below), so registering an index is a lightweight no-op backfill; the def tells the planner it may use a curve-range seek for radius/bbox queries over that property (increment 3).

CREATE POINT INDEX city_loc [IF NOT EXISTS] FOR (n:City) ON (n.loc)
DROP POINT INDEX city_loc [IF EXISTS]
SHOW POINT INDEXES        -- also folds into SHOW INDEXES

Like every other index, it replicates across a Raft cluster (re-run deterministically on each node) and is carried in snapshots.

How points are stored (the curve key)

Every point property lands in the same label-scoped value index as every other scalar, under an order-preserving Morton / Z-order key (encode_for_index tag 0x07):

0x07 ++ srid(u32 BE) ++ morton_interleave(order-preserving axis codes) ++ exact axis codes

Each axis’s f64 runs through the same order-preserving u64 transform the Float index uses; the per-axis codes are bit-interleaved MSB-first into a fixed-width big-endian string (16 bytes for 2-D, 24 for 3-D) so byte order equals Z-curve order. The SRID leads the key, so points of different CRS occupy disjoint ranges. The exact coordinates are appended so the point decodes back exactly. A 2-D point key is 37 bytes. This is the final on-disk format — it ships in increment 1 so there is never a migration when the seek arrives.

Accelerating radius / bbox queries: the point-index seek

Create a point index so radius and bounding-box queries seek the Morton curve instead of scanning the whole label:

CREATE POINT INDEX loc_idx FOR (n:City) ON (n.loc)

Once the index exists, a query of the form

MATCH (c:City) WHERE distance(c.loc, point({latitude: 48.85, longitude: 2.35})) < 5000
RETURN c

or

MATCH (c:City) WHERE point.withinBBox(c.loc, point({x: 0, y: 0}), point({x: 10, y: 10}))
RETURN c

runs as a bounded set of curve-range seeks plus an exact post-filter: the query region is decomposed into a few contiguous Morton ranges (a superset of the answer), each is seeked in the value index, and the exact distance() / withinBBox filter trims the false positives. Results are identical to the full scan — the index only changes speed. EXPLAIN shows entry.spatial_seek in the Physical: footer when the seek is used. Without a point index the query still runs correctly, just as a label scan (Neo4j’s declare-to-accelerate model). A radius seek is roughly an order of magnitude faster than the full scan once the label is large and the region is selective.

Geographic edge cases are handled: a radius that crosses the ±180° antimeridian is split into two boxes and unioned; one that reaches a pole widens to all longitudes (a correct over-approximation the post-filter trims); an antimeridian-wrapping withinBBox (lower-left longitude greater than upper-right) covers [ll.lon, 180] ∪ [-180, ur.lon].

The seek also composes with a (non-kNN) ORDER BY — e.g.

MATCH (c:City) WHERE distance(c.loc, point({latitude: 48.85, longitude: 2.35})) < 5000
RETURN c ORDER BY c.name

still uses entry.spatial_seek to produce the candidate set, then applies the ORDER BY c.name as an ordinary post-sort over just those matches (byte-identical to a full scan + sort). Only a kNN ORDER BY distance(...) LIMIT k is served by its own path (above).

Nearest-neighbour (kNN)

MATCH (c:City) RETURN c ORDER BY distance(c.loc, point({latitude: 48.85, longitude: 2.35})) LIMIT 10

With a point index on c.loc, this runs an expanding-ring search over the curve: it grows a search box until the k-th nearest is provably confirmed (no un-searched point can be closer), then orders just that candidate set — identical to the full sort, but without touching every row. Without an index it falls back to the exact full sort.

Polygons & geometry (geometry MVP)

Beyond points, patinaDB has a first-class Polygon value and a small set of areal predicates — a scoped geometry MVP, not full PostGIS.

Constructing a polygon

// Exterior ring from a list of points (auto-closed if the last ≠ first):
RETURN polygon([point({x: 0, y: 0}), point({x: 10, y: 0}),
                point({x: 10, y: 10}), point({x: 0, y: 10})]) AS square

// With holes — a list of rings, ring 0 = exterior, rings 1.. = holes:
RETURN polygon([
  [point({x: 0, y: 0}), point({x: 10, y: 0}), point({x: 10, y: 10}), point({x: 0, y: 10})],
  [point({x: 4, y: 4}), point({x: 6, y: 4}), point({x: 6, y: 6}), point({x: 4, y: 6})]
]) AS ring_with_hole

The CRS is inherited from the points (all points must share one CRS — a mixed-CRS set is an error). A ring needs ≥ 3 distinct vertices. A polygon can be stored as a node/relationship property (it round-trips through storage) but is not spatially indexed (see limitations).

Predicates

// Point-in-polygon (ray casting, correct for holes):
MATCH (p:Place) WHERE within(p.loc, $region) RETURN p
// contains() is the same predicate with arguments swapped:
RETURN polygon.contains($region, point({x: 5, y: 5}))     // → true/false
// Polygon–polygon intersection (overlap or touch):
RETURN intersects($regionA, $regionB)                     // → true/false
  • within(point, polygon) / contains(polygon, point) — even-odd ray-casting point-in-polygon: inside the exterior ring and outside every hole. A point exactly on an edge/vertex is reported inside (a documented boundary convention). Also available as the namespaced polygon.contains(polygon, point) / polygon.within(point, polygon).
  • intersects(polyA, polyB) — a bounding-box reject fast path, then an edge-segment-crossing test, then a vertex-containment test (so containment with no crossing edges still counts). Returns true when the polygons overlap or touch.
  • Mixed-CRS operands → null (parity with distance()).

Limitations (current increments)

  • Geometry is a scoped MVP — a Polygon type with within / contains / intersects only. No linestrings, no multipolygon, no ST_* OGC function library, no spatial joins beyond the point predicates above.
  • No polygon spatial index — polygon predicates are always a full scan + exact filter (a polygon is not added to the tag-0x07 point curve index; a polygon-column BVH / R-tree is an honest follow-on). A “points within a constant query polygon” optimization could later reuse the point bbox-seek.
  • Polygons are 2-D — a polygon’s footprint is [x, y]; a 3-D point’s height is dropped on construction.
  • wgs-84 polygons are treated as planar lon/lat — no antimeridian-crossing and no polar geometry (a polygon spanning the ±180° seam is out of MVP scope). Ray casting / intersection assume a flat plane, which is fine for local regions but not for large geodesic areas.
  • intersects assumes well-formed input — degenerate or self-intersecting polygons are undefined (not asserted).
  • distance() is spherical Haversine, not ellipsoidal (Neo4j’s own default; ~0.3% off the true geoid — fine for radius search, not survey work). Use point.distance.ellipsoidal() when you need the accurate WGS-84 geodesic.
  • 3-D seeks are less selective than 2-D (interleaving three axes has worse curve locality), but still correct — the exact post-filter always runs.