Chapter 20 · Content types, static files and the other contrib apps

20.19 Spatial fields: the geometry fields and RasterField

The model fields of django.contrib.gis.db.models: BaseSpatialField, which gives every spatial field its coordinate system, column type and way into a statement; the geometry fields, which hold a GEOSGeometry; RasterField, which holds a GDALRaster; and SpatialProxy, the descriptor that lets a model’s attribute take a string and give back a geometry.

A geometry column asks three things of its field: a column type that only a spatial database has, and the coordinate system its values are in; a value in Python that is an object over a C library; and a way to it from the text people write, WKT or GeoJSON. BaseSpatialField, a django.db.models.Field, and the classes built on it answer them: GeometryField, for vector geometries, and RasterField, for grids of values.

The column and the SRID

Where django.db.models.Field.db_type looks the internal type up in the backend’s table of column types (A field: the base class), BaseSpatialField.db_type returns what the connection’s operations object writes with geo_db_type. A plain backend has no such method, so a spatial field needs a spatial backend. PostGIS’s writes the field’s geometry type, with "Z" for three dimensions, and its SRID. For the "postgis" connection, with no server behind it, and SQLite’s "default":

PointField().db_type(postgis)  ->  'geometry(POINT,4326)'
LineStringField(geography=True).db_type(postgis)  ->  'geography(LINESTRING,4326)'
RasterField().db_type(postgis)  ->  'raster'
PointField(geography=True, srid=32633).db_type(postgis)  ->  raises NotSupportedError: PostGIS only supports geography columns with an SRID of 4326.
PointField().db_type(default)  ->  raises AttributeError: 'DatabaseOperations' object has no attribute 'geo_db_type'

The SRID, BaseSpatialField.srid, is the number of the field’s coordinate system, usually an EPSG code, by default 4326: longitude and latitude in degrees. BaseSpatialField.deconstruct writes it into every migration, for “less fragility” in its comment’s words, and spatial_index only when it is false. That argument stands in for db_index because a spatial index, the documentation says, is typically a variant of the R-tree; the backend’s schema editor makes it (Spatial backends: PostGIS, SpatiaLite, MySQL and Oracle).

What an SRID means is the database’s to say. BaseSpatialField.geodetic, BaseSpatialField.units_name and their siblings ask get_srid_info, which the first time reads the SRID’s row of "spatial_ref_sys", or Oracle’s "CS_SRS", and keeps its units, its spheroid and whether it is geographic in _srid_cache, by alias and SRID, for the life of the process; on MySQL, whose backend has no model of such a table, GDAL’s SpatialReference answers. The cache’s comment says why: so that the database is not queried each time a distance query is constructed (Spatial lookups, functions and distances).

On the instance: SpatialProxy

GeometryField.contribute_to_class puts a SpatialProxy on the model class under the field’s attname. It is a DeferredAttribute with a __set__, so a data descriptor: every assignment goes through it, those of Model.__init__ included, and every read, which fetches a deferred value through the instance’s fetch mode (Deferred fields and fetch modes in a queryset).

SpatialProxy.__set__ checks a value’s type and stores it unchanged in the instance’s __dict__. It takes None, a string or a memoryview without looking inside, and an object of the field’s geom_class, any GEOSGeometry for GeometryField itself, to which it gives the field’s SRID if it has none. Anything else raises TypeError. SpatialProxy.__get__ parses a string or a memoryview at the first read, with GEOSGeometry and the field’s max_geom_collections, and assigns the result back through __set__, so the string is parsed once and its type checked then. A mooring made in Python:

m = Mooring(name="Customs House", depth="7.5", position="POINT (14.5143 35.8957)")
m.__dict__["position"]  ->  'POINT (14.5143 35.8957)'
m.position.ewkt  ->  'SRID=4326;POINT (14.5143 35.8957)'
m.position = '{"type": "Point", "coordinates": [14.5196, 35.8922]}'
m.position.ewkt  ->  'SRID=4326;POINT (14.5196 35.8922)'
p = Point(14.5143, 35.8957)
m.position = p
p.srid  ->  4326
anchorage = Polygon(((14.51, 35.89), (14.53, 35.89), (14.53, 35.90), (14.51, 35.90), (14.51, 35.89)))
setattr(m, "position", anchorage)  ->  raises TypeError: Cannot set Mooring SpatialProxy (POINT) with value of type: <class 'django.contrib.gis.geos.polygon.Polygon'>
m.position = anchorage.wkt
m.position  ->  raises TypeError: Cannot set Mooring SpatialProxy (POINT) with value of type: <class 'django.contrib.gis.geos.polygon.Polygon'>

The SRID went to the point assigned, not to a copy. A point of another SRID keeps its own, and PostGIS transforms it when it is written.

Into a statement and out of a row

Of the four methods that carry a value into a statement (A field: the base class), a spatial field overrides the inner two. BaseSpatialField.get_prep_value, which a lookup calls when it is made, keeps a GEOSGeometry or a GDALRaster, makes a geometry of a string, and of bytes a raster if GDAL can read one or else a geometry; then BaseSpatialField.get_srid gives a value with no SRID, or with -1, the field’s, and keeps any other unless the field’s is -1. BaseSpatialField.get_db_prep_value wraps the result in the backend’s Adapter, on PostGIS a PostGISAdapter, there told geography=True for a geography field. BaseSpatialField.get_placeholder_sql hands the compiler’s question, what to write where the value goes, to the backend’s get_geom_placeholder_sql, which on PostGIS transforms a value of another SRID. Two moorings, made with a WKT string and with a point in 3857, compiled for PostGIS with no server behind it:

INSERT INTO "charts_mooring" ("name", "position", "depth") VALUES (%s, %s, %s)
params: ('Customs House', a PostGISAdapter of SRID 4326, Decimal('7.5'))
INSERT INTO "charts_mooring" ("name", "position", "depth") VALUES (%s, ST_Transform(%s, %s), %s)
params: ('Fort St Angelo', a PostGISAdapter of SRID 3857, 4326, Decimal('9.0'))

A string could also name a raster, and GDAL opens more than files: a path beginning “/vsi” reaches its virtual file systems, URLs among them, and a dictionary makes a raster in write mode. So GDALRaster.check_raster_lookup_value refuses a dictionary, a string or a path with DisallowedRasterLookup, a SuspiciousOperation, which a request answers with 400 (How an exception becomes a response). For a string the refusal waits while GEOSGeometry has its turn, so WKT still works; it is raised if that fails on a string that begins “/vsi” or looks like JSON, as a filter of moorings by “/vsicurl/https://charts.example/harbour.tif” did.

On the way out a geometry field has no from_db_value: the backend adds a converter for every expression whose output field is a geometry field. PostGIS’s reads the WKB and gives the result the field’s geom_class without asking GEOS its type; for GeometryField, whose geom_class is None, it asks. GeometryField.select_format wraps the column, outside a subquery, in the backend’s select, for the backends that must ask for WKB, SpatiaLite and Oracle among them.

The geometry classes

PointField, LineStringField, PolygonField, MultiPointField, MultiLineStringField, MultiPolygonField and GeometryCollectionField, the subclasses of GeometryField, set three class attributes and a description: GeometryField.geom_type, the type’s OpenGIS name, which PostGIS and MySQL write into the column’s type; GeometryField.geom_class, the GEOS class the proxy demands and the converter makes; and GeometryField.form_class, the form field of the same name. GeometryField itself has "GEOMETRY" and no class.

Its other arguments but max_geom_collections are the backends’: dim, 2 or 3; geography, which makes on PostGIS a column of the geography type, whose measurements, the documentation says, follow great circles and come out in linear units; extent and tolerance, which only Oracle’s schema editor reads. GeometryField.deconstruct writes each only when it differs from its default; GeometryField.formfield gives the form field the type, the SRID and the limit (GeoDjango’s forms, admin, feeds and data tools).

The lookups, and some functions that serve as transforms, are registered on BaseSpatialField itself when django.contrib.gis.db.models is imported; "exact" is another name for "same_as", which PostGIS writes as ~= (Spatial lookups, functions and distances).

RasterField

RasterField holds a GDALRaster; its geom_type is "RASTER". Its db_type first calls RasterField._check_connection, which refuses a backend that is not spatial or, unlike PostGIS, lacks raster support. Its SpatialProxy takes None, a string, a dictionary or a raster and makes the raster at the first read: making every raster of a large queryset, the comment says, could be expensive. Unlike a geometry field it has a from_db_value, which hands the column to the backend’s parse_raster, and RasterField.get_transform reads a number as a band.

ExtentField is no model’s field but the output field of the aggregates Extent and Extent3D.