Chapter 20 · Content types, static files and the other contrib apps
20.20 Spatial backends: PostGIS, SpatiaLite, MySQL and Oracle
A spatial backend is one of the database backends under django/, PostGIS, SpatiaLite, MySQL and Oracle: a subclass of the plain backend whose operations, a BaseSpatialOperations, answer what a geometry asks of the database, and whose features, schema editor and adapter know its spatial types.
A geometry needs from its database a column type, a way to travel as a parameter, a way back into Python, and names for its functions. GeoDjango keeps each database’s answers in a backend, chosen by "ENGINE", and the fields, lookups and functions ask the connection’s operations. On the example’s plain "postgres" alias, a schema editor asked to create the moorings’ table raised AttributeError, “‘DatabaseOperations’ object has no attribute ‘geo_db_type’”.
A subclass of the plain backend
Each backend’s base.py defines a wrapper that subclasses the plain backend’s and replaces the classes it makes its helpers from, BaseDatabaseWrapper, BaseDatabaseWrapper, BaseDatabaseWrapper and BaseDatabaseWrapper (The connection object: BaseDatabaseWrapper and what a backend provides).
The operations come in two halves: PostGISOperations has BaseSpatialOperations first among its bases and PostgreSQL’s operations second, and every backend’s operations and features, over BaseSpatialFeatures, are built so. The spatial half calls super() into the plain half: BaseSpatialOperations refuses an aggregate of BaseSpatialOperations, then asks the plain backend, and BaseSpatialOperations adds a geometry’s converter to the plain backend’s (The SQL a backend writes: BaseDatabaseOperations).
BaseSpatialOperations: what a backend answers
BaseSpatialOperations declares what every spatial backend answers, raising NotImplementedError where only the backend can, and flags such as BaseSpatialOperations say which backend it is.
The column. BaseSpatialOperations gives a field’s column type. PostGIS writes "geometry(POINT,4326)", with "Z" after the type for three dimensions and "geography" for a geography field; MySQL writes the type alone, "POINT"; Oracle writes "MDSYS for every type; SpatiaLite writes nothing.
The parameter. BaseSpatialField wraps a geometry in the backend’s adapter class, PostGISOperations and its like (Spatial fields: the geometry fields and RasterField). BaseSpatialOperations, which PostGIS replaces, writes its placeholder: the constructor from text, BaseSpatialOperations, with the field’s SRID, or with the value’s inside the transform function when the two differ. Where the features say there is no table of coordinate systems the SRID is left out, which a comment gives as compatibility with MySQL, ticket 27464.
The way back. GeometryField wraps a column selected outside a subquery in the template BaseSpatialOperations: "ST_AsBinary(%s)" on MySQL, "SDO_UTIL on Oracle, the bare column on PostGIS under psycopg 3. The converter from BaseSpatialOperations reads the value with GEOS’s WKB reader into the field’s GeometryField; MySQL’s and Oracle’s, which select plain WKB, also set the field’s SRID.
The names. BaseSpatialOperations gives a function’s name in SQL: the backend’s entry in BaseSpatialOperations, else BaseSpatialOperations before Django’s name, so that on PostGIS "AsWKT" became "ST_AsText" and "Centroid" "ST_Centroid"; BaseSpatialOperations names aggregates. A function in BaseSpatialOperations is refused with NotSupportedError; the base class lists every function there, so a backend supports none it does not claim, and PostGIS’s set is empty. The lookups’ operators and BaseSpatialOperations, which turns a distance into the SQL’s number, belong to Spatial lookups, functions and distances.
BaseSpatialFeatures: what each database can do
BaseSpatialFeatures sets the GIS flags, among them BaseSpatialFeatures, false on BaseDatabaseFeatures, which a raster field checks (What a database can do: BaseDatabaseFeatures). A flag for each function is computed on demand from the unsupported functions, "has_Centroid_function" true on PostGIS, and one for a name that is not a function raises ValueError.
| flag | PostGIS | SpatiaLite | MySQL | Oracle |
|---|---|---|---|---|
supports_ |
PostGIS yes | SpatiaLite no | MySQL no | Oracle no |
supports_ |
PostGIS yes | SpatiaLite yes | MySQL no | Oracle no |
supports_raster |
PostGIS yes | SpatiaLite no | MySQL no | Oracle no |
supports_ |
PostGIS yes | SpatiaLite yes | MySQL no | Oracle yes |
has_ |
PostGIS yes | SpatiaLite yes | MySQL no | Oracle yes |
can_ |
PostGIS yes | SpatiaLite no | MySQL yes | Oracle yes |
PostGIS
PostGISOperations is the setting POSTGIS_VERSION where it is set, (3, 5, 2) in the example. Otherwise it asks the server for "version()", so that a failure of PostgreSQL itself is not blamed on PostGIS (ticket 24862), then for "postgis_lib_version()". DatabaseWrapper, which migrate calls, runs "CREATE EXTENSION IF NOT EXISTS postgis" where "pg_extension" lacks it (The migrate command, showmigrations and sqlmigrate).
A PostGISAdapter holds a geometry’s EWKB, its WKB with the SRID inside. Under psycopg 3, DatabaseWrapper fetches on a new connection the type numbers of "geometry", "geography" and "raster", once for each alias, and registers the dumpers postgis_adapters makes for them, which send the value typed as one of the three. The placeholder is a bare "%s", inside "ST_Transform" when the SRIDs differ. A point of the Grand Harbour in SRID 4326 and in UTM zone 33N, compiled with no server:
Mooring.objects.filter(position__intersects=Point(14.515, 35.8925, srid=4326))
... WHERE ST_Intersects("charts_mooring"."position", %s)
params: (PostGISAdapter,)
Mooring.objects.filter(position__intersects=Point(456229, 3972134, srid=32633))
... WHERE ST_Intersects("charts_mooring"."position", ST_Transform(%s, %s))
params: (PostGISAdapter, 4326)
The schema editor gives a field with BaseSpatialField a GiST index, with the operator class "GIST_GEOMETRY_OPS_ND" for a geometry, not a geography, of three dimensions. For a depth sounding and a shipping lane it wrote, with no server and the key column left out:
# charts/models.py
class Sounding(models.Model):
position = models.PointField(dim=3)
class Lane(models.Model):
route = models.LineStringField(geography=True)
CREATE TABLE "charts_sounding" (..., "position" geometry(POINTZ,4326) NOT NULL);
CREATE INDEX "charts_sounding_position_0d47193e_id" ON "charts_sounding" USING GIST ("position" GIST_GEOMETRY_OPS_ND);
CREATE TABLE "charts_lane" (..., "route" geography(LINESTRING,4326) NOT NULL);
CREATE INDEX "charts_lane_route_ec6f76d6_id" ON "charts_lane" USING GIST ("route");
A geography field in another SRID than 4326 was refused with NotSupportedError, “PostGIS only supports geography columns with an SRID of 4326.”. What the backend asks the server, the version without the setting, the extension, a row of "spatial_ref_sys", the run could not ask.
SpatiaLite
DatabaseWrapper enables extension loading and loads the setting SPATIALITE_LIBRARY_PATH where it is set, or else the first that loads of "mod_spatialite, "mod_spatialite" and what ctypes finds. Its prepare_database runs "InitSpatialMetaData(1)", or "InitSpatialMetaDataFull(1)" from SpatiaLite 5, where "geometry_columns" is missing. SpatialiteSchemaEditor returns no column for a geometry field but collects calls of "AddGeometryColumn" and "CreateSpatialIndex", run once the table exists (SQLite).
MySQL and MariaDB
MySQL sends a geometry as WKT in a WKTAdapter through "ST_GeomFromText". The two databases differ in operators and functions, among them "relate" on MariaDB only and "covers" on MySQL only, and MariaDB allows the operator "coveredby" and the aggregate Collect only from 12.0.1. A spatial index is made only for a field that is not null, and only where the table’s storage engine is MyISAM, Aria or InnoDB (MySQL and MariaDB).
Oracle
SDOOperator writes most of Oracle’s lookups as a function compared with 'TRUE'. The schema editor adds a row to "USER_SDO_GEOM_METADATA" for each geometry column, with the field’s extent and tolerance, and makes an index of type "MDSYS. OracleSpatialAdapter sends WKT, after turning a polygon’s rings to the orientation its docstring says Oracle requires.
The metadata models and introspection
BaseSpatialOperations and BaseSpatialOperations return two unmanaged models of the app label "gis", over "geometry_columns" and "spatial_ref_sys" on PostGIS and SpatiaLite, "USER_SDO_GEOM_METADATA" and "CS_SRS" on Oracle; MySQL has none. The second has SpatialRefSysMixin, which makes a GDAL SpatialReference from the row’s WKT or, failing that, from the PROJ string of a PostGIS or SpatiaLite row, and answers units and spheroid from it; a field reads its units there, on MySQL from GDAL. Introspection tells the GIS inspectdb a geometry column’s type, on Oracle only the generic geometry field, and, where the metadata keeps them, its SRID and dimensions (GeoDjango’s forms, admin, feeds and data tools).