PostGIS
Spatial types that stay spatial — geometry and geography, kept apart.
Opt-in, and separate#
PostGIS support is its own package. A project that does not import it never sees a spatial API, and the root ORM knows nothing about geometry:
import "github.com/AlexAli29/orm/postgis"Everything in it composes through the one extension boundary the root package
exposes. There is no second query compiler and no second expression model — a
spatial predicate is an orm.Predicate like any other, and it nests into
composed queries, CTEs and derived tables unchanged.
The two distinctions that are never blurred#
geometry is Cartesian, in whatever units the SRID's coordinate system uses. geography is on the spheroid, with distances and lengths in metres.
They are different PostgreSQL types with different index behaviour and different answers, so they are different Go types here. Converting between them is something you write, not something that happens to you.
And two facts travel with every value and every column:
- the shape — Point, LineString, Polygon, and the multi forms
- the SRID — which coordinate system the numbers are in
Losing either is how a query comes to compare metres with degrees and get a number back.
Declaring a spatial column#
The pgtype tag carries the shape and the coordinate system, because neither is
derivable from the Go type:
//orm:table public.places
type Place struct {
ID int64 `orm:"pk,identity"`
Name string
// On the spheroid. Distances come back in metres.
Spot postgis.Geography `orm:"pgtype:geography(Point,4326)"`
// Cartesian, in WGS 84 degrees.
Location postgis.Geometry `orm:"pgtype:geometry(Point,4326)"`
// The same place in web Mercator. Relating it to Location without
// transforming first is a mistake the SRID makes visible.
Projected *postgis.Geometry `orm:"pgtype:geometry(Point,3857)"`
Footprint *postgis.Geometry `orm:"pgtype:geometry(Polygon,4326)"`
}A pointer is a nullable column, as everywhere else. The generator emits
GeomCol, GeogCol and their nullable forms, each carrying the SRID, the kind
and the dimension it was declared with.
Querying#
postgis.Of lifts a geometry column into a spatial expression;
postgis.OfGeog does the same for geography:
// Everything within 5 km of a point, on the spheroid — metres, because
// geography measures in metres.
here := postgis.GeographyPoint(-0.1276, 51.5072)
places, err := db.Places.Query().
Where(postgis.OfGeog(Places.Spot).
DWithin(postgis.GeogValue[Place](here), 5000)).
All(ctx)// Cartesian relationships, on geometry.
db.Places.Query().Where(postgis.Of(Places.Location).Intersects(v))
db.Places.Query().Where(postgis.Of(Places.Location).Within(v))
db.Places.Query().Where(postgis.Of(Places.Location).Contains(v))Bounding-box operators are named as such#
postgis.Of(Places.Location).BBoxIntersects(v) // &&
postgis.Of(Places.Location).BBoxContains(v) // ~
postgis.Of(Places.Location).BBoxWithin(v) // @&& is not ST_Intersects. It compares bounding boxes, which is cheaper and
answers a different question — so it gets a different name rather than being
presented as a faster version of the exact one.
Measurements and transformations#
These return ordinary orm.Value, so they go in projections and orderings like
anything else:
distance := postgis.OfGeog(Places.Spot).Distance(postgis.GeogValue[Place](here))
type Near struct {
Name string
Metres float64
}
var near = orm.Project2(
Places.Name, distance,
func(name string, m float64) Near { return Near{name, m} },
)
rows, err := orm.Select(db.Places, near).
OrderBy(distance.Asc()).
Limit(20).
All(ctx)Also available on an expression: Area, Length, Centroid, Buffer,
Boundary, Azimuth, AsText, AsEWKT, AsGeoJSON, AsBinary, AsEWKB,
and AsGeography for the conversion you write deliberately. Each has a …Null
form for the nullable column, because a measurement of a NULL geometry is NULL.
Aggregates#
postgis.Collect(g) // ST_Collect -> *Geometry
postgis.UnionAgg(g) // ST_Union -> *Geometry
postgis.Extent(g) // ST_Extent -> *Box2D
postgis.Extent3D(g) // ST_3DExtent -> *Box3DRegistering the types#
pgx needs to be told about the PostGIS types on each connection:
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
return postgis.Register(ctx, conn)
}RegisterIfPresent is the tolerant form — it reports whether the extension was
there rather than failing, which is what a binary that runs against both spatial
and plain databases wants.
Versions this is proved against#
PostgreSQL 17 with PostGIS 3.5, 16 with 3.4, and 14 with 3.4.
The spatial suite skips when the extension is unavailable, which is right on a
developer's machine and wrong in CI — so CI sets ORM_REQUIRE_POSTGIS=1, which
turns the skip into a failure. A support claim nothing exercises is a claim
nobody should believe.
The ORM never creates the extension. CREATE EXTENSION postgis is a privileged
operation belonging to whoever owns the database.
Worked examples#
Shops near me#
here := postgis.GeographyPoint(lon, lat)
type Near struct {
Name string
Metres float64
}
distance := postgis.OfGeog(Shops.Spot).Distance(postgis.GeogValue[Shop](here))
var near = orm.Project2(
Shops.Name, distance,
func(name string, m float64) Near { return Near{name, m} },
)
rows, err := orm.Select(db.Shops, near).
Where(postgis.OfGeog(Shops.Spot).DWithin(postgis.GeogValue[Shop](here), 2000)).
OrderBy(distance.Asc()).
Limit(10).
All(ctx)DWithin before Distance matters: the first can use a spatial index, the
second cannot. Filtering then sorting is the difference between a query and a
full scan.
Which delivery zone covers an address#
zone, err := db.Zones.Query().
Where(postgis.Of(Zones.Area).Contains(postgis.Of(Addresses.Point))).
One(ctx)A bounding box for a map viewport#
box := postgis.MakeEnvelope(west, south, east, north, 4326)
pins, err := db.Pins.Query().
Where(postgis.Of(Pins.Location).BBoxIntersects(box)).
Limit(500).
All(ctx)BBoxIntersects is &&, which compares bounding boxes. For a rectangular
viewport that is the exact question, and it is the cheap one.
Exporting for a map client#
var geo = orm.Project2(
Zones.Name, postgis.Of(Zones.Area).AsGeoJSON(),
func(name, geom string) Feature { return Feature{name, geom} },
)Registering the types#
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
return postgis.Register(ctx, conn)
}