Make GeoDjango+DRF as flexible as you wish by binding Raw Query to Serializers

When building API with Django, Django Rest Framework is generally a good choice. If you need to extend the requirements to support GIS query then you will probably find the extension DRF-GIS convenient as it nicely fill the gap for models. However if you wish to keep the class based models, views and serializers while having the flexibility to issue raw PostGIS query then you land in a unknown area where there is no documentation to drive you safely at destination. That’s the purpose of this article, provide a deeper insight on the technique than provided in this question of mine on SO when I found a way to make the binding and I asked for peer reviewing.

Use case

We have a table called Location which stores points in EPSG:4326 (classical longitude latitude used by the GPS over the ellipsoid WSG84) all over the world and we want to create a circular buffer of 10,000 meters around those points returned as a plain and valid GeoJSON.

Constraints

We want to keep the usual and clean flow of Class Based Views (and Serializers) provided by Django in order to keep all automatic features working seamlessly.

GIS features are in some extend implemented in GeoDjango and allow the user to build query as usual to annotate and filter records, but it is impossible to provide all GIS operations offered by PostGIS without having to resort to Raw Query or Python GEOS object post processing.

In this article we choose the first option as writing queries in SQL with PostGIS will not be defeated by any Python post processing logic.

Raw Data

A basic raw dataset to shoulder our discussion would look like:

CREATE TABLE location (
   id INTEGER PRIMARY KEY,
   key VARCHAR
);

SELECT AddGeometryColumn('public', 'location', 'geom', 4326, 'POINT', 2);

INSERT INTO location(id, key, geom) VALUES
(1, 'Brussels', ST_GeomFromText('POINT(4.351721 50.850346)', 4326)),
(2, 'Paris', ST_GeomFromText('POINT(2.352222 48.856613)', 4326)),
(3, 'Los Angeles', ST_GeomFromText('POINT(-118.243683 34.052235)', 4326))
(4, 'Buenos Aire', ST_GeomFromText('POINT(-58.381557 -34.603683)', 4326));

And renders as follow:

Spherical coordinates

Because we need to think world wide our points are stored in a Spherical Coordinate system. In such a coordinate system, Euclidean distance (eg.: a radius of 10 km around a point over the sphere) does not have clean and simple expression.

We have two choices to solve our problem: perform all our operations in Spherical Coordinate or make a conversion in Planar Coordinate System and convert back to Spherical Coordinate System after the circle computation.

Planar coordinates

Let’s say we want to perform our operations in a Cartesian world where meters are obvious and distance behaves naturally. Then we need to choose a SRID to project our points, we draw the circle and convert back into original SRID.

SELECT
	L.*,
	ST_Transform(ST_Buffer(ST_Transform(L.geom, 102013), 10000), 4326) AS circle
FROM location AS L;

This will work nicely for Brussels and Paris as the chosen Coordinate System stands for Europe, it also renders not so bad for Los Angeles which stands at same Latitude, but definitely fails for Buenos Aires where our circle becomes an ellipse.

Reinvent the Wheel

We also don’t want to implement spherical conversion mathematics – which will requires to implement the Haversine formula – in our queries which is a source of errors and break the DRW principle. That would be an unnecessary painful-to-write-or-read and thus hard-to-debug query.

Using geography

What we should not do is to perform our operations on geometry (either with PostGIS or GEOS) because it will requires SRID conversion with respect to each location of point over the world. That would require a lot of unnecessary extra logic where there exists an ad hoc concept to deal with such a situation: Geography.

SELECT
	L.*,
	ST_Buffer(L.geom::Geography, 10000)::Geometry AS circle
FROM location AS L;

Which performs exactly what we expected:

And voilà, our use case is solved at least at the DB side perspective, now it is time to bind everything together.

GeoDjango+DRF Bindings

Our goal is to retrieve a plain and valid GeoJSON of circle around points sample all over the world while only using the Class Based Views/Serializers pattern of Django+DRF.

The binding is extremely simple and stick to the pattern, it only requires some final casting that is missing in Django DRF GIS package.

Model

Assume we have the following model which almost map the Raw SQL query in the introduction, except for NULL constraints:

from django.contrib.gis.db import models

class Location(Model):
    key = models.CharField(max_length=64)
    # ... A lot of nice properties Location might have
    geom = models.PointField()

View

The API view returns a list of Location and serializes it with our custom Serializer:

from rest_framework import generics

class CircleViewSet(generics.ListAPIView):

    queryset = Location.objects.raw("""
    SELECT id, ST_Buffer(L.geom::Geography, 10000)::Geometry AS circle
    FROM location AS L;
    """)
    serializer_class = CircleSerializer

This View returns a Raw Query from the Location model, introducing a new column called circle with the geometry of interest.

Serializer

The custom Serializer is a GIS Serializer that returns GeoJSON natively, all we need to do is to map the extra column that is not part of the model and is introduced by the Raw Query:

from django.contrib.gis.geos import GEOSGeometry
from rest_framework import serializers
from rest_framework_gis.serializers import GeoFeatureModelSerializer, GeometrySerializerMethodField

class CircleSerializer(GeoFeatureModelSerializer):

    id = serializers.IntegerField(required=True)
    circle = GeometrySerializerMethodField()

    def get_circle(self, obj):
        if obj.circle:
            return GEOSGeometry(obj.circle)

    class Meta:
        model = Location
        geo_field = "circle"
        fields = ('id',)

At this point everything is setup, it should return a valid GeoJSON with circles as polygons. Mind the call to GEOSGeometry object that is required to make the flow complete as Django RawQuery returns a string with Binary representation of the Geometry instead of a GEOS Geometry object (see issue on repository).

Internal API call

In case it is useful, Django DRF allows its resource to be used internally just by rendering the original request as well:

import json

layer = json.loads(CircleViewSet.as_view()(request._request).render().content)

Which is very handy when to need to join a GeoJSON as well as another resources together to make you response complete (eg. : you want to render this GeoJSON as an extra layer into an interactive map.

jlandercy
jlandercy
Articles: 24