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.
Vector search
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.