PostGIS и векторы

Восемь видов геометрии, которые рисуются и редактируются на самодельной карте, пространственные фильтры, различающие метры и градусы, и поиск по сходству через pgvector.

Доступно с версии v0.2.0

FastFort определяет колонки PostGIS и pgvector, не импортируя GeoAlchemy2 или pgvector на уровне spec. Проект без пространственных данных ничего не платит за само существование поддержки пространственных данных; проекту с пространственными данными не нужны системные пакеты сверх самого расширения базы данных.

Геометрические колонки

from geoalchemy2 import Geography, Geometry, WKBElement


class Hub(Base):
    location: Mapped[WKBElement | None] = mapped_column(
        Geography(geometry_type="POINT", srid=4326), default=None
    )
    catchment: Mapped[WKBElement | None] = mapped_column(
        Geometry(geometry_type="POLYGON", srid=4326), default=None
    )

Рисуются и редактируются все восемь видов: POINT, LINESTRING, POLYGON (включая с отверстиями), MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION и нетипизированный GEOMETRY.

Подсказка о формате под каждым полем выбирается на основе собственного kind колонки — «широта и долгота» абсолютно верна над POINT и откровенно вводит в заблуждение над POLYGON.

Включение карты

По умолчанию выключено, и это осознанное решение, а не недосмотр: включение означает, что админка начинает загружать изображения с чужого сервера. Этот хост узнаёт, какие строки просматриваются и примерно где они находятся, а у большинства тайловых сервисов на этот счёт есть условия использования. Указание URL — это заявление вашего проекта о том, что он их прочитал.

FastFortSettings(
    ui={
        "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        "map_attribution": "© OpenStreetMap contributors",
        "map_center": "41.3111, 69.2797",
    },
)

Этот хост — тот единственный источник, на который расширяется img-src в CSP админки, поэтому это настройка, а не то, что может подставить шаблон.

Без map_tile_url поле остаётся тем же текстовым полем ввода, каким и было всегда. Ничего не ломается — просто за ним нет картинки.

Насколько глубоко это заходит

ui={"map_max_zoom": 19}    # the default

Самый глубокий уровень, который можно запросить — это свойство источника тайлов, а не виджета. Запрос глубже того, что есть у сервера, возвращает 404, а неудавшийся тайл — это пустой квадрат.

Само отображение идёт ещё на два уровня дальше и масштабирует последний доступный тайл — так же, как делает любое картографическое приложение за пределами собственных снимков: кнопка «+», которая перестаёт реагировать, воспринимается как поломка виджета, а не как исчерпание детализации карты.

Что представляет собой карта

Самодельная реализация Web Mercator, а не Leaflet. Тайлы размером 256 пикселей позиционируются через transform, по одному слою на каждый уровень масштабирования, поэтому масштабирование происходит мгновенно, а предыдущий уровень остаётся снизу, пока новые тайлы не загрузятся.

Она записывает значение в текстовое поле рядом с собой, а не заменяет его. При отключённом JavaScript поле остаётся тем же полем, куда можно вставить WKT, EWKT или GeoJSON, и форма по-прежнему отправляется — это то же правило, которому следует любой другой элемент управления.

Две детали, очевидные только после того, как что-то пошло не так:

  • Замыкающая вершина полигона привязана к первой. Сдвиньте одну, не тронув другую, — и кольцо разорвётся при сохранении.
  • Карта — одно из ровно двух мест в таблице стилей, где используются физические направления, а не логические, потому что это координатное пространство, размещаемое скриптом в физических пикселях. (Второе — галочка чекбокса: галочка есть галочка на любом языке.)

Geography — это не geometry

Они классифицируются как один и тот же тип поля и отрисовывают один и тот же элемент управления. Расстояние означает метры для geography и единицы SRID — градусы, для 4326 — для geometry.

/admin/logistics.vehicle/?last_seen__dwithin=41.3111,69.2797,5000

5000 метров, потому что Vehicle.last_seen — это Geography. Тот же фильтр на колонке Geometry прочитал бы 5000 как градусы, а это радиус размером примерно в половину планеты.

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

Пространственные фильтры

?catchment__within=POLYGON((69.2 41.3, 69.3 41.3, 69.3 41.2, 69.2 41.3))
?catchment__intersects=POINT(69.28 41.31)
?catchment__contains=POINT(69.28 41.31)
?catchment__bbox=69.1,41.2,69.4,41.4
?last_seen__dwithin=41.3111,69.2797,5000

Также overlaps, touches и crosses.

Значение разбирается тем же парсером, что использует форма, поэтому фильтр принимает всё, что принимает поле.

В ячейке списка

Геометрия отображается как краткое резюме — Polygon · 14 points, Point · 41.31, 69.28 — а не как шестнадцатеричный WKB-блоб, который выглядит как повреждённые данные для того, кто открыл страницу.

Экспорт намеренно устроен иначе. Ячейка списка даёт резюме; файл читает программа как минимум так же часто, как человек, а обещание, которое даёт импорт, — это то, что файл, который система записала, она же способна прочитать обратно. Поэтому геометрия экспортируется тем же текстом, что показывает её собственный элемент управления формы: точка — как lat, lng, всё остальное — как EWKT.

Векторный поиск

from pgvector.sqlalchemy import Vector


class Product(Base):
    embedding: Mapped[object | None] = mapped_column(Vector(3), default=None)

В админке доступно только для чтения. Эмбеддинг никто не вводит вручную; ценность колонки раскрывается через запрос:

?embedding__near=[0.4,0.1,0.9]&embedding__k=5

Пять ближайших, ранжированных. Этот порядок важнее всего остального, что было запрошено у страницы — «ближайшие, а при равенстве — по имени», а не по алфавиту, потому что поиск по сходству, результаты которого затем пересортированы по алфавиту, — это уже не поиск по сходству.

?embedding__near=[...]&embedding__metric=cosine    # default
?embedding__near=[...]&embedding__metric=l2
?embedding__near=[...]&embedding__metric=l1
?embedding__near=[...]&embedding__metric=inner
?embedding__near=[...]&embedding__within=0.35      # a distance bound

VectorSpec несёт число измерений и вид (vector, halfvec, sparsevec, bit), поэтому значение неверной длины — это ошибка валидации, а не ошибка базы данных.

Подключение расширений

FastFort никогда не выполняет DDL. Включение расширений — задача вашего проекта, и это осознанное решение: фреймворк админки, который незаметно устанавливает расширения базы данных, — это то, что никто не сможет проверить.

EXTENSIONS = ("postgis", "hstore", "citext", "vector", "pg_trgm", "btree_gist")


async def create_schema() -> None:
    async with engine.begin() as connection:
        if connection.dialect.name == "postgresql":
            for extension in EXTENSIONS:
                await connection.execute(
                    sa.text(f"CREATE EXTENSION IF NOT EXISTS {extension}")
                )
        await connection.run_sync(Base.metadata.create_all)

postgis, hstore, citext и vector — каждое добавляет свой тип, поэтому CREATE TABLE, ссылающийся на такой тип, падает на этапе DDL, а не при первой вставке.