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, а не
при первой вставке.