From 44bdbbc316e4e0b2f5d8c7767d04924c9e00f2da Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 23 Jan 2015 22:45:57 +0100 Subject: [PATCH] Added Spatialite support to GIS functions --- .../contrib/gis/db/backends/base/features.py | 3 +- .../gis/db/backends/spatialite/operations.py | 21 ++++++++ django/contrib/gis/db/models/functions.py | 51 +++++++++++++++++-- tests/gis_tests/distapp/tests.py | 6 ++- tests/gis_tests/geoapp/test_functions.py | 22 ++++++-- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/django/contrib/gis/db/backends/base/features.py b/django/contrib/gis/db/backends/base/features.py index 1c8a472a4d..ebd26af431 100644 --- a/django/contrib/gis/db/backends/base/features.py +++ b/django/contrib/gis/db/backends/base/features.py @@ -26,8 +26,9 @@ class BaseSpatialFeatures(object): supports_real_shape_operations = True # Can geometry fields be null? supports_null_geometries = True - # Can the `distance` GeoQuerySet method be applied on geodetic coordinate systems? + # Can the `distance`/`length` functions be applied on geodetic coordinate systems? supports_distance_geodetic = True + supports_length_geodetic = True # Is the database able to count vertices on polygons (with `num_points`)? supports_num_points_poly = True diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py index 6c89849caf..1413f2227b 100644 --- a/django/contrib/gis/db/backends/spatialite/operations.py +++ b/django/contrib/gis/db/backends/spatialite/operations.py @@ -1,3 +1,9 @@ +""" +SQL functions reference lists: +http://www.gaia-gis.it/spatialite-2.4.0/spatialite-sql-2.4.html +http://www.gaia-gis.it/spatialite-3.0.0-BETA/spatialite-sql-3.0.0.html +http://www.gaia-gis.it/gaia-sins/spatialite-sql-4.2.1.html +""" import re import sys @@ -74,6 +80,21 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations): 'distance_lte': SpatialOperator(func='Distance', op='<='), } + function_names = { + 'Length': 'ST_Length', + 'Reverse': 'ST_Reverse', + 'Scale': 'ScaleCoords', + 'Translate': 'ST_Translate', + 'Union': 'ST_Union', + } + + @cached_property + def unsupported_functions(self): + unsupported = {'BoundingCircle', 'ForceRHR', 'GeoHash', 'MemSize'} + if self.spatial_version < (4, 0, 0): + unsupported.add('Reverse') + return unsupported + @cached_property def spatial_version(self): """Determine the version of the SpatiaLite library.""" diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py index 3669c15360..b23ad10d20 100644 --- a/django/contrib/gis/db/models/functions.py +++ b/django/contrib/gis/db/models/functions.py @@ -79,6 +79,9 @@ class GeomValue(Value): self.value = connection.ops.Adapter(self.value) return super(GeomValue, self).as_sql(compiler, connection) + def as_sqlite(self, compiler, connection): + return 'GeomFromText(%%s, %s)' % self.srid, [connection.ops.Adapter(self.value)] + class GeoFuncWithGeoParam(GeoFunc): def __init__(self, expression, geom, *expressions, **extra): @@ -94,6 +97,18 @@ class GeoFuncWithGeoParam(GeoFunc): super(GeoFuncWithGeoParam, self).__init__(expression, geom, *expressions, **extra) +class SQLiteDecimalToFloatMixin(object): + """ + By default, Decimal values are converted to str by the SQLite backend, which + is not acceptable by the GIS functions expecting numeric values. + """ + def as_sqlite(self, compiler, connection): + for expr in self.get_source_expressions(): + if hasattr(expr, 'value') and isinstance(expr.value, Decimal): + expr.value = float(expr.value) + return super(SQLiteDecimalToFloatMixin, self).as_sql(compiler, connection) + + class Area(GeoFunc): def as_sql(self, compiler, connection): if connection.ops.oracle: @@ -143,7 +158,10 @@ class AsGML(GeoFunc): class AsKML(AsGML): - pass + def as_sqlite(self, compiler, connection): + # No version parameter + self.source_expressions.pop(0) + return super(AsKML, self).as_sql(compiler, connection) class AsSVG(GeoFunc): @@ -261,6 +279,15 @@ class Length(DistanceResultMixin, GeoFunc): self.function = connection.ops.length3d return super(Length, self).as_sql(compiler, connection) + def as_sqlite(self, compiler, connection): + geo_field = GeometryField(srid=self.srid) + if geo_field.geodetic(connection): + if self.spheroid: + self.function = 'GeodesicLength' + else: + self.function = 'GreatCircleLength' + return super(Length, self).as_sql(compiler, connection) + class MemSize(GeoFunc): output_field_class = IntegerField @@ -273,6 +300,11 @@ class NumGeometries(GeoFunc): class NumPoints(GeoFunc): output_field_class = IntegerField + def as_sqlite(self, compiler, connection): + if self.source_expressions[self.geom_param_pos].output_field.geom_type != 'LINESTRING': + raise TypeError("Spatialite NumPoints can only operate on LineString content") + return super(NumPoints, self).as_sql(compiler, connection) + class Perimeter(DistanceResultMixin, GeoFunc): output_field_class = FloatField @@ -292,7 +324,7 @@ class Reverse(GeoFunc): pass -class Scale(GeoFunc): +class Scale(SQLiteDecimalToFloatMixin, GeoFunc): def __init__(self, expression, x, y, z=0.0, **extra): expressions = [ expression, @@ -304,7 +336,7 @@ class Scale(GeoFunc): super(Scale, self).__init__(*expressions, **extra) -class SnapToGrid(GeoFunc): +class SnapToGrid(SQLiteDecimalToFloatMixin, GeoFunc): def __init__(self, expression, *args, **extra): nargs = len(args) expressions = [expression] @@ -342,9 +374,20 @@ class Transform(GeoFunc): # Make srid the resulting srid of the transformation return self.source_expressions[self.geom_param_pos + 1].value + def convert_value(self, value, expression, connection, context): + value = super(Transform, self).convert_value(value, expression, connection, context) + if not connection.ops.postgis and not value.srid: + # Some backends do not set the srid on the returning geometry + value.srid = self.srid + return value + class Translate(Scale): - pass + def as_sqlite(self, compiler, connection): + # Always provide the z parameter + if len(self.source_expressions) < 4: + self.source_expressions.append(Value(0)) + return super(Translate, self).as_sqlite(compiler, connection) class Union(GeoFuncWithGeoParam): diff --git a/tests/gis_tests/distapp/tests.py b/tests/gis_tests/distapp/tests.py index f69b66a801..c59772a782 100644 --- a/tests/gis_tests/distapp/tests.py +++ b/tests/gis_tests/distapp/tests.py @@ -617,13 +617,15 @@ class DistanceFunctionsTests(TestCase): len_m1 = 473504.769553813 len_m2 = 4617.668 - if connection.features.supports_distance_geodetic: + if connection.features.supports_length_geodetic: qs = Interstate.objects.annotate(length=Length('path')) tol = 2 if oracle else 3 self.assertAlmostEqual(len_m1, qs[0].length.m, tol) + # TODO: test with spheroid argument (True and False) else: # Does not support geodetic coordinate systems. - self.assertRaises(ValueError, Interstate.objects.annotate(length=Length('path'))) + with self.assertRaises(ValueError): + Interstate.objects.annotate(length=Length('path')) # Now doing length on a projected coordinate system. i10 = SouthTexasInterstate.objects.annotate(length=Length('path')).get(name='I-10') diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py index 1ce722269c..fb2a88be09 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -172,15 +172,22 @@ class GISFunctionsTests(TestCase): @skipUnlessDBFeature("has_Difference_function") def test_difference(self): geom = Point(5, 23, srid=4326) - qs = Country.objects.annotate(difference=functions.Difference('mpoly', geom)) + qs = Country.objects.annotate(diff=functions.Difference('mpoly', geom)) + # For some reason SpatiaLite does something screwy with the Texas geometry here. + if spatialite: + qs = qs.exclude(name='Texas') + for c in qs: - self.assertEqual(c.mpoly.difference(geom), c.difference) + self.assertEqual(c.mpoly.difference(geom), c.diff) @skipUnlessDBFeature("has_Difference_function") def test_difference_mixed_srid(self): """Testing with mixed SRID (Country has default 4326).""" geom = Point(556597.4, 2632018.6, srid=3857) # Spherical mercator qs = Country.objects.annotate(difference=functions.Difference('mpoly', geom)) + # For some reason SpatiaLite does something screwy with the Texas geometry here. + if spatialite: + qs = qs.exclude(name='Texas') for c in qs: self.assertEqual(c.mpoly.difference(geom), c.difference) @@ -220,7 +227,12 @@ class GISFunctionsTests(TestCase): geom = Point(5, 23, srid=4326) qs = Country.objects.annotate(inter=functions.Intersection('mpoly', geom)) for c in qs: - self.assertEqual(c.mpoly.intersection(geom), c.inter) + if spatialite: + # When the intersection is empty, Spatialite returns None + expected = None + else: + expected = c.mpoly.intersection(geom) + self.assertEqual(c.inter, expected) @skipUnlessDBFeature("has_MemSize_function") def test_memsize(self): @@ -416,8 +428,8 @@ class GISFunctionsTests(TestCase): union=functions.Union('mpoly', geom), ) - # XXX For some reason SpatiaLite does something screwey with the Texas geometry here. Also, - # XXX it doesn't like the null intersection. + # For some reason SpatiaLite does something screwey with the Texas geometry here. + # Also, it doesn't like the null intersection. if spatialite: qs = qs.exclude(name='Texas') else: