# PostGIS

> Пространственные типы, которые остаются пространственными: geometry и geography порознь.

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

---
## Подключается отдельно

Поддержка PostGIS — отдельный пакет. Проект, который его не импортирует, никогда
не увидит пространственного API, а корневая библиотека ничего не знает о
геометрии:

```go
import "github.com/AlexAli29/orm/postgis"
```

Всё в нём собирается через ту единственную границу расширения, которую открывает
корневой пакет. Нет ни второго компилятора запросов, ни второй модели выражений:
пространственный предикат — это такой же `orm.Predicate`, и он вкладывается в
составные запросы, CTE и производные таблицы без изменений.

## Два различения, которые никогда не смешиваются

**geometry** — декартова, в тех единицах, которые задаёт система координат SRID.
**geography** — на сфероиде, расстояния и длины в метрах.

Это разные типы PostgreSQL с разным поведением индексов и разными ответами,
поэтому здесь это разные типы Go. Преобразование между ними — то, что вы пишете
сами, а не то, что случается с вами.

И два факта путешествуют вместе с каждым значением и каждой колонкой:

- **форма** — Point, LineString, Polygon и их множественные варианты;
- **SRID** — в какой системе координат эти числа.

Потеря любого из них — это способ получить запрос, сравнивающий метры с
градусами и возвращающий число.

## Объявление пространственной колонки

Тег `pgtype` несёт форму и систему координат, потому что из Go-типа не выводится
ни то, ни другое:

```go
//orm:table public.places
type Place struct {
    ID   int64  `orm:"pk,identity"`
    Name string

    // На сфероиде. Расстояния приходят в метрах.
    Spot postgis.Geography `orm:"pgtype:geography(Point,4326)"`

    // Декартова, в градусах WGS 84.
    Location postgis.Geometry `orm:"pgtype:geometry(Point,4326)"`

    // То же место в web Mercator. Соотнести его с Location без преобразования —
    // ошибка, которую SRID делает видимой.
    Projected *postgis.Geometry `orm:"pgtype:geometry(Point,3857)"`

    Footprint *postgis.Geometry `orm:"pgtype:geometry(Polygon,4326)"`
}
```

Указатель — nullable-колонка, как и везде. Генератор выдаёт `GeomCol`, `GeogCol`
и их nullable-формы, каждая несёт объявленные SRID, вид и размерность.

## Запросы

`postgis.Of` поднимает колонку geometry в пространственное выражение,
`postgis.OfGeog` — то же для geography:

```go
// Всё в пределах 5 км от точки, на сфероиде — в метрах, потому что geography
// измеряет в метрах.
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
// Декартовы отношения, на 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))
```

### Операторы по ограничивающей рамке названы именно так

```go
postgis.Of(Places.Location).BBoxIntersects(v)  // &&
postgis.Of(Places.Location).BBoxContains(v)    // ~
postgis.Of(Places.Location).BBoxWithin(v)      // @
```

`&&` — это не `ST_Intersects`. Он сравнивает ограничивающие рамки: это дешевле и
отвечает на другой вопрос, поэтому у него другое имя, а не вид «быстрой версии»
точного оператора.

## Измерения и преобразования

Они возвращают обычный `orm.Value`, поэтому годятся в проекции и сортировки как
всё остальное:

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

На выражении доступны также `Area`, `Length`, `Centroid`, `Buffer`, `Boundary`,
`Azimuth`, `AsText`, `AsEWKT`, `AsGeoJSON`, `AsBinary`, `AsEWKB` и
`AsGeography` — то самое преобразование, которое вы делаете осознанно. У каждого
есть форма `…Null` для nullable-колонки, потому что измерение NULL-геометрии —
это NULL.

## Агрегаты

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

## Регистрация типов

pgx нужно рассказать о типах PostGIS на каждом соединении:

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

`RegisterIfPresent` — терпимая форма: она сообщает, было ли расширение, вместо
того чтобы падать. Это то, что нужно бинарнику, который работает и с
пространственными базами, и с обычными.

## На каких версиях это доказано

PostgreSQL 17 с PostGIS 3.5, 16 с 3.4 и 14 с 3.4.

Пространственный набор тестов пропускается, когда расширения нет, — правильно на
машине разработчика и неправильно в CI, поэтому CI ставит
`ORM_REQUIRE_POSTGIS=1`, что превращает пропуск в падение. Заявленной поддержке,
которую ничто не проверяет, верить нельзя.

Библиотека никогда не создаёт расширение. `CREATE EXTENSION postgis` —
привилегированная операция того, кто владеет базой.

## Разобранные примеры

### Магазины рядом

```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` идёт до `Distance`: первый может воспользоваться
пространственным индексом, второй — нет. Сначала отфильтровать, потом
отсортировать — это разница между запросом и полным сканированием.

### Какая зона доставки покрывает адрес

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

### Прямоугольник видимой области карты

```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` — это `&&`, сравнение ограничивающих рамок. Для прямоугольной
области это ровно тот вопрос, который нужен, и он дешёвый.

### Выгрузка для картового клиента

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

### Регистрация типов

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