# PostGIS

> Spatial types that stay spatial — geometry and geography, kept apart.

Source: https://ormgo.vercel.app/en/docs/postgis/
Symbols: https://ormgo.vercel.app/api/orm.txt — the generated list of every exported name.

---
## 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:

```go
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:

```go
//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:

```go
// 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)
```

```go
// 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

```go
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:

```go
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

```go
postgis.Collect(g)   // ST_Collect   -> *Geometry
postgis.UnionAgg(g)  // ST_Union     -> *Geometry
postgis.Extent(g)    // ST_Extent    -> *Box2D
postgis.Extent3D(g)  // ST_3DExtent  -> *Box3D
```

## Registering the types

pgx needs to be told about the PostGIS types on each connection:

```go
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

```go
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

```go
zone, err := db.Zones.Query().
    Where(postgis.Of(Zones.Area).Contains(postgis.Of(Addresses.Point))).
    One(ctx)
```

### A bounding box for a map viewport

```go
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

```go
var geo = orm.Project2(
    Zones.Name, postgis.Of(Zones.Area).AsGeoJSON(),
    func(name, geom string) Feature { return Feature{name, geom} },
)
```

### Registering the types

```go
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
    return postgis.Register(ctx, conn)
}
```
