PostGIS and vectors

Eight geometry kinds drawn and edited on a hand-rolled map, spatial filters that know metres from degrees, and pgvector similarity search.

Available since v0.2.0

FastFort detects PostGIS and pgvector columns without importing GeoAlchemy2 or pgvector at the spec layer. A project with no spatial data pays nothing for spatial support existing; a project with spatial data needs no system packages beyond the database extension itself.

Geometry columns

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
    )

All eight kinds draw and edit: POINT, LINESTRING, POLYGON (including with holes), MULTIPOINT, MULTILINESTRING, MULTIPOLYGON, GEOMETRYCOLLECTION, and the untyped GEOMETRY.

The format hint under each field is chosen from the column’s own kind — “latitude and longitude” is exactly right above a POINT and actively misleading above a POLYGON.

Turning the map on

Off by default, and that is a decision rather than an oversight: turning it on means the admin fetches images from somebody else’s server. That host learns which rows are being looked at and roughly where they are, and most tile services have terms about it. Naming a URL is your project saying it has read them.

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

That host is the one origin the admin’s CSP img-src is widened by — which is why it is configuration and not something a template can inject.

With no map_tile_url, the field is the text input it always was. Nothing breaks; there is simply no picture behind it.

How deep it goes

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

The deepest level to request, which is a property of the tile source rather than of the widget. Asking past what a server has returns a 404, and a failed tile is a blank square.

The view keeps going two levels further and scales the last one it has, the way every map application does past its own imagery — a + button that stops responding reads as the widget having broken rather than as the map having run out of detail.

What the map is

Hand-rolled Web Mercator, not Leaflet. 256-pixel tiles positioned by transform, one layer per zoom level so a zoom is instant and the previous level stays underneath until the new tiles have loaded.

It writes into the text input beside it rather than replacing it. With JavaScript off the field is still a box you can paste WKT, EWKT or GeoJSON into, and the form still submits — which is the same rule every other control follows.

Two details that are only obvious once they have gone wrong:

  • A polygon’s closing vertex is pinned to its first. Move one and not the other and the ring tears open on save.
  • The map is one of exactly two places in the stylesheet written in physical directions rather than logical ones, because it is a coordinate space placed by script in physical pixels. (The other is the checkbox tick — a tick is a tick in any language.)

Geography is not geometry

They classify to the same field type and draw the same control. A distance means metres on a geography and SRID units — degrees, on 4326 — on a geometry.

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

5000 metres, because Vehicle.last_seen is a Geography. The same filter on a Geometry column would read 5000 as degrees, which is a radius of roughly half the planet.

GeometrySpec.geography exists so the two cannot be collapsed, and the query casts where it has to.

Spatial filters

?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

Also overlaps, touches and crosses.

The value is parsed by the same parser the form uses, so anything the field accepts, the filter accepts.

In a list cell

A geometry renders as a summary — Polygon · 14 points, Point · 41.31, 69.28 — rather than the WKB hex blob that reads as corruption to whoever opened the page.

An export is deliberately different. A list cell summarises; a file is read by a program at least as often as by a person, and the promise import makes is that a file this wrote is a file it can take back. So a geometry exports as the same text its own form control shows: a point as lat, lng, everything else as EWKT.

from pgvector.sqlalchemy import Vector


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

Read-only in the admin. Nobody types an embedding; what makes the column useful is the query:

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

Five nearest, ranked. That ordering beats whatever else the page was asked for — “the nearest, and break ties by name” rather than the alphabet, because a similarity search whose results are then re-sorted alphabetically is not a similarity search.

?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 carries the dimension count and the kind (vector, halfvec, sparsevec, bit), so a value of the wrong length is a validation error rather than a database one.

Getting the extensions

FastFort never runs DDL. Enabling extensions is your project’s job, and that is deliberate — an admin framework that quietly installs database extensions is one nobody can review.

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 and vector each add a type, so a CREATE TABLE naming one fails on the DDL rather than on the first insert.