Fixed #27497 -- Improved support of geodetic coordinates on SpatiaLite.

Area function, Distance function, and distance lookups now work with
geodetic coordinates on SpatiaLite.
This commit is contained in:
Sergey Fedoseev 2016-11-16 17:07:36 +05:00 committed by Tim Graham
parent 38a6df555f
commit 986c7d522a
9 changed files with 105 additions and 37 deletions

View File

@ -32,6 +32,7 @@ class BaseSpatialFeatures(object):
supports_distance_geodetic = True supports_distance_geodetic = True
supports_length_geodetic = True supports_length_geodetic = True
supports_perimeter_geodetic = False supports_perimeter_geodetic = False
supports_area_geodetic = True
# Is the database able to count vertices on polygons (with `num_points`)? # Is the database able to count vertices on polygons (with `num_points`)?
supports_num_points_poly = True supports_num_points_poly = True

View File

@ -8,6 +8,7 @@ class DatabaseFeatures(BaseSpatialFeatures, MySQLDatabaseFeatures):
supports_add_srs_entry = False supports_add_srs_entry = False
supports_distance_geodetic = False supports_distance_geodetic = False
supports_length_geodetic = False supports_length_geodetic = False
supports_area_geodetic = False
supports_distances_lookups = False supports_distances_lookups = False
supports_transform = False supports_transform = False
supports_real_shape_operations = False supports_real_shape_operations = False

View File

@ -6,7 +6,6 @@ from django.utils.functional import cached_property
class DatabaseFeatures(BaseSpatialFeatures, SQLiteDatabaseFeatures): class DatabaseFeatures(BaseSpatialFeatures, SQLiteDatabaseFeatures):
supports_3d_storage = True supports_3d_storage = True
supports_distance_geodetic = False
# SpatiaLite can only count vertices in LineStrings # SpatiaLite can only count vertices in LineStrings
supports_num_points_poly = False supports_num_points_poly = False
@ -16,3 +15,7 @@ class DatabaseFeatures(BaseSpatialFeatures, SQLiteDatabaseFeatures):
# which can result in a significant performance improvement when # which can result in a significant performance improvement when
# creating the database. # creating the database.
return self.connection.ops.spatial_version >= (4, 1, 0) return self.connection.ops.spatial_version >= (4, 1, 0)
@cached_property
def supports_area_geodetic(self):
return bool(self.connection.ops.lwgeom_version())

View File

@ -19,6 +19,20 @@ from django.utils import six
from django.utils.functional import cached_property from django.utils.functional import cached_property
class SpatiaLiteDistanceOperator(SpatialOperator):
def as_sql(self, connection, lookup, template_params, sql_params):
if lookup.lhs.output_field.geodetic(connection):
# SpatiaLite returns NULL instead of zero on geodetic coordinates
sql_template = 'COALESCE(%(func)s(%(lhs)s, %(rhs)s, %%s), 0) %(op)s %(value)s'
template_params.update({
'op': self.op,
'func': connection.ops.spatial_function_name('Distance'),
})
sql_params.insert(1, len(lookup.rhs) == 3 and lookup.rhs[-1] == 'spheroid')
return sql_template % template_params, sql_params
return super(SpatiaLiteDistanceOperator, self).as_sql(connection, lookup, template_params, sql_params)
class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations): class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
name = 'spatialite' name = 'spatialite'
spatialite = True spatialite = True
@ -79,10 +93,10 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
'exact': SpatialOperator(func='Equals'), 'exact': SpatialOperator(func='Equals'),
# Distance predicates # Distance predicates
'dwithin': SpatialOperator(func='PtDistWithin'), 'dwithin': SpatialOperator(func='PtDistWithin'),
'distance_gt': SpatialOperator(func='Distance', op='>'), 'distance_gt': SpatiaLiteDistanceOperator(func='Distance', op='>'),
'distance_gte': SpatialOperator(func='Distance', op='>='), 'distance_gte': SpatiaLiteDistanceOperator(func='Distance', op='>='),
'distance_lt': SpatialOperator(func='Distance', op='<'), 'distance_lt': SpatiaLiteDistanceOperator(func='Distance', op='<'),
'distance_lte': SpatialOperator(func='Distance', op='<='), 'distance_lte': SpatiaLiteDistanceOperator(func='Distance', op='<='),
} }
disallowed_aggregates = (aggregates.Extent3D,) disallowed_aggregates = (aggregates.Extent3D,)
@ -140,19 +154,19 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
def get_distance(self, f, value, lookup_type, **kwargs): def get_distance(self, f, value, lookup_type, **kwargs):
""" """
Returns the distance parameters for the given geometry field, Returns the distance parameters for the given geometry field,
lookup value, and lookup type. SpatiaLite only supports regular lookup value, and lookup type.
cartesian-based queries (no spheroid/sphere calculations for point
geometries like PostGIS).
""" """
if not value: if not value:
return [] return []
value = value[0] value = value[0]
if isinstance(value, Distance): if isinstance(value, Distance):
if f.geodetic(self.connection): if f.geodetic(self.connection):
raise ValueError('SpatiaLite does not support distance queries on ' if lookup_type == 'dwithin':
'geometry fields with a geodetic coordinate system. ' raise ValueError(
'Distance objects; use a numeric value of your ' 'Only numeric values of degree units are allowed on '
'distance in degrees instead.') 'geographic DWithin queries.'
)
dist_param = value.m
else: else:
dist_param = getattr(value, Distance.unit_attname(f.units_name(self.connection))) dist_param = getattr(value, Distance.unit_attname(f.units_name(self.connection)))
else: else:

View File

@ -38,6 +38,10 @@ class GeoFunc(Func):
except (AttributeError, FieldError): except (AttributeError, FieldError):
return None return None
@property
def geo_field(self):
return GeometryField(srid=self.srid) if self.srid else None
def as_sql(self, compiler, connection, **extra_context): def as_sql(self, compiler, connection, **extra_context):
if self.function is None: if self.function is None:
self.function = connection.ops.spatial_function_name(self.name) self.function = connection.ops.spatial_function_name(self.name)
@ -122,26 +126,34 @@ class Area(OracleToleranceMixin, GeoFunc):
output_field_class = AreaField output_field_class = AreaField
arity = 1 arity = 1
def as_sql(self, compiler, connection): def as_sql(self, compiler, connection, **extra_context):
if connection.ops.geography: if connection.ops.geography:
self.output_field.area_att = 'sq_m' self.output_field.area_att = 'sq_m'
else: else:
# Getting the area units of the geographic field. # Getting the area units of the geographic field.
source_fields = self.get_source_fields() geo_field = self.geo_field
if len(source_fields): if geo_field.geodetic(connection):
source_field = source_fields[0] if connection.features.supports_area_geodetic:
if source_field.geodetic(connection): self.output_field.area_att = 'sq_m'
else:
# TODO: Do we want to support raw number areas for geodetic fields? # TODO: Do we want to support raw number areas for geodetic fields?
raise NotImplementedError('Area on geodetic coordinate systems not supported.') raise NotImplementedError('Area on geodetic coordinate systems not supported.')
units_name = source_field.units_name(connection) else:
units_name = geo_field.units_name(connection)
if units_name: if units_name:
self.output_field.area_att = AreaMeasure.unit_attname(units_name) self.output_field.area_att = AreaMeasure.unit_attname(units_name)
return super(Area, self).as_sql(compiler, connection) return super(Area, self).as_sql(compiler, connection, **extra_context)
def as_oracle(self, compiler, connection): def as_oracle(self, compiler, connection):
self.output_field = AreaField('sq_m') # Oracle returns area in units of meters. self.output_field = AreaField('sq_m') # Oracle returns area in units of meters.
return super(Area, self).as_oracle(compiler, connection) return super(Area, self).as_oracle(compiler, connection)
def as_sqlite(self, compiler, connection, **extra_context):
if self.geo_field.geodetic(connection):
extra_context['template'] = '%(function)s(%(expressions)s, %(spheroid)d)'
extra_context['spheroid'] = True
return self.as_sql(compiler, connection, **extra_context)
class AsGeoJSON(GeoFunc): class AsGeoJSON(GeoFunc):
output_field_class = TextField output_field_class = TextField
@ -226,7 +238,7 @@ class DistanceResultMixin(object):
def convert_value(self, value, expression, connection, context): def convert_value(self, value, expression, connection, context):
if value is None: if value is None:
return None return None
geo_field = GeometryField(srid=self.srid) # Fake field to get SRID info geo_field = self.geo_field
if geo_field.geodetic(connection): if geo_field.geodetic(connection):
dist_att = 'm' dist_att = 'm'
else: else:
@ -275,6 +287,15 @@ class Distance(DistanceResultMixin, OracleToleranceMixin, GeoFuncWithGeoParam):
self.source_expressions.pop(2) self.source_expressions.pop(2)
return super(Distance, self).as_oracle(compiler, connection) return super(Distance, self).as_oracle(compiler, connection)
def as_sqlite(self, compiler, connection, **extra_context):
if self.spheroid:
self.source_expressions.pop(2)
if self.geo_field.geodetic(connection):
# SpatiaLite returns NULL instead of zero on geodetic coordinates
extra_context['template'] = 'COALESCE(%(function)s(%(expressions)s, %(spheroid)s), 0)'
extra_context['spheroid'] = int(bool(self.spheroid))
return super(Distance, self).as_sql(compiler, connection, **extra_context)
class Envelope(GeoFunc): class Envelope(GeoFunc):
arity = 1 arity = 1

View File

@ -546,7 +546,7 @@ class GeoQuerySet(QuerySet):
u, unit_name, s = get_srid_info(srid, connection) u, unit_name, s = get_srid_info(srid, connection)
geodetic = unit_name.lower() in geo_field.geodetic_units geodetic = unit_name.lower() in geo_field.geodetic_units
if geodetic and not connection.features.supports_distance_geodetic: if geodetic and (not connection.features.supports_distance_geodetic or connection.ops.spatialite):
raise ValueError( raise ValueError(
'This database does not support linear distance ' 'This database does not support linear distance '
'calculations on geodetic coordinate systems.' 'calculations on geodetic coordinate systems.'

View File

@ -149,6 +149,10 @@ Minor features
* Added support for the :lookup:`dwithin` lookup on SpatiaLite. * Added support for the :lookup:`dwithin` lookup on SpatiaLite.
* The :class:`~django.contrib.gis.db.models.functions.Area` function,
:class:`~django.contrib.gis.db.models.functions.Distance` function, and
distance lookups now work with geodetic coordinates on SpatiaLite.
* The OpenLayers-based form widgets now use ``OpenLayers.js`` from * The OpenLayers-based form widgets now use ``OpenLayers.js`` from
``https://cdnjs.cloudflare.com`` which is more suitable for production use ``https://cdnjs.cloudflare.com`` which is more suitable for production use
than the the old ``http://openlayers.org`` source. than the the old ``http://openlayers.org`` source.

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from unittest import skipIf
from django.contrib.gis.db.models.functions import ( from django.contrib.gis.db.models.functions import (
Area, Distance, Length, Perimeter, Transform, Area, Distance, Length, Perimeter, Transform,
) )
@ -143,6 +145,7 @@ class DistanceTest(TestCase):
self.assertAlmostEqual(m_distances[i], c.distance.m, tol) self.assertAlmostEqual(m_distances[i], c.distance.m, tol)
self.assertAlmostEqual(ft_distances[i], c.distance.survey_ft, tol) self.assertAlmostEqual(ft_distances[i], c.distance.survey_ft, tol)
@skipIf(spatialite, "distance method doesn't support geodetic coordinates on SpatiaLite.")
@skipUnlessDBFeature("has_distance_method", "supports_distance_geodetic") @skipUnlessDBFeature("has_distance_method", "supports_distance_geodetic")
@ignore_warnings(category=RemovedInDjango20Warning) @ignore_warnings(category=RemovedInDjango20Warning)
def test_distance_geodetic(self): def test_distance_geodetic(self):
@ -270,12 +273,15 @@ class DistanceTest(TestCase):
# a 100km of that line (which should exclude only Hobart & Adelaide). # a 100km of that line (which should exclude only Hobart & Adelaide).
line = GEOSGeometry('LINESTRING(144.9630 -37.8143,151.2607 -33.8870)', 4326) line = GEOSGeometry('LINESTRING(144.9630 -37.8143,151.2607 -33.8870)', 4326)
dist_qs = AustraliaCity.objects.filter(point__distance_lte=(line, D(km=100))) dist_qs = AustraliaCity.objects.filter(point__distance_lte=(line, D(km=100)))
expected_cities = [
self.assertEqual(9, dist_qs.count()) 'Batemans Bay', 'Canberra', 'Hillsdale',
self.assertEqual(['Batemans Bay', 'Canberra', 'Hillsdale',
'Melbourne', 'Mittagong', 'Shellharbour', 'Melbourne', 'Mittagong', 'Shellharbour',
'Sydney', 'Thirroul', 'Wollongong'], 'Sydney', 'Thirroul', 'Wollongong',
self.get_names(dist_qs)) ]
if spatialite:
# SpatiaLite is less accurate and returns 102.8km for Batemans Bay.
expected_cities.pop(0)
self.assertEqual(expected_cities, self.get_names(dist_qs))
# Too many params (4 in this case) should raise a ValueError. # Too many params (4 in this case) should raise a ValueError.
queryset = AustraliaCity.objects.filter(point__distance_lte=('POINT(5 23)', D(km=100), 'spheroid', '4')) queryset = AustraliaCity.objects.filter(point__distance_lte=('POINT(5 23)', D(km=100), 'spheroid', '4'))
@ -355,6 +361,7 @@ class DistanceTest(TestCase):
for i, z in enumerate(SouthTexasZipcode.objects.order_by('name').area()): for i, z in enumerate(SouthTexasZipcode.objects.order_by('name').area()):
self.assertAlmostEqual(area_sq_m[i], z.area.sq_m, tol) self.assertAlmostEqual(area_sq_m[i], z.area.sq_m, tol)
@skipIf(spatialite, "length method doesn't support geodetic coordinates on SpatiaLite.")
@skipUnlessDBFeature("has_length_method") @skipUnlessDBFeature("has_length_method")
@ignore_warnings(category=RemovedInDjango20Warning) @ignore_warnings(category=RemovedInDjango20Warning)
def test_length(self): def test_length(self):
@ -544,8 +551,9 @@ class DistanceFunctionsTests(TestCase):
40435.4335201384, 0, 68272.3896586844, 12375.0643697706, 0] 40435.4335201384, 0, 68272.3896586844, 12375.0643697706, 0]
qs = AustraliaCity.objects.annotate(distance=Distance('point', ls)).order_by('name') qs = AustraliaCity.objects.annotate(distance=Distance('point', ls)).order_by('name')
for city, distance in zip(qs, distances): for city, distance in zip(qs, distances):
# Testing equivalence to within a meter. # Testing equivalence to within a meter (kilometer on SpatiaLite).
self.assertAlmostEqual(distance, city.distance.m, 0) tol = -3 if spatialite else 0
self.assertAlmostEqual(distance, city.distance.m, tol)
@skipUnlessDBFeature("has_Distance_function", "supports_distance_geodetic") @skipUnlessDBFeature("has_Distance_function", "supports_distance_geodetic")
def test_distance_geodetic_spheroid(self): def test_distance_geodetic_spheroid(self):
@ -575,7 +583,7 @@ class DistanceFunctionsTests(TestCase):
).order_by('id') ).order_by('id')
for i, c in enumerate(qs): for i, c in enumerate(qs):
self.assertAlmostEqual(spheroid_distances[i], c.distance.m, tol) self.assertAlmostEqual(spheroid_distances[i], c.distance.m, tol)
if postgis: if postgis or spatialite:
# PostGIS uses sphere-only distances by default, testing these as well. # PostGIS uses sphere-only distances by default, testing these as well.
qs = AustraliaCity.objects.exclude(id=hillsdale.id).annotate( qs = AustraliaCity.objects.exclude(id=hillsdale.id).annotate(
distance=Distance('point', hillsdale.point) distance=Distance('point', hillsdale.point)

View File

@ -4,18 +4,20 @@ Tests for geography support in PostGIS
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
from unittest import skipUnless from unittest import skipIf, skipUnless
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.contrib.gis.db.models.functions import Area, Distance from django.contrib.gis.db.models.functions import Area, Distance
from django.contrib.gis.measure import D from django.contrib.gis.measure import D
from django.db import connection from django.db import connection
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.test import TestCase, ignore_warnings, skipUnlessDBFeature from django.test import (
TestCase, ignore_warnings, skipIfDBFeature, skipUnlessDBFeature,
)
from django.utils._os import upath from django.utils._os import upath
from django.utils.deprecation import RemovedInDjango20Warning from django.utils.deprecation import RemovedInDjango20Warning
from ..utils import oracle, postgis from ..utils import oracle, postgis, spatialite
from .models import City, County, Zipcode from .models import City, County, Zipcode
@ -27,6 +29,7 @@ class GeographyTest(TestCase):
"Ensure geography features loaded properly." "Ensure geography features loaded properly."
self.assertEqual(8, City.objects.count()) self.assertEqual(8, City.objects.count())
@skipIf(spatialite, "SpatiaLite doesn't support distance lookups with Distance objects.")
@skipUnlessDBFeature("supports_distances_lookups", "supports_distance_geodetic") @skipUnlessDBFeature("supports_distances_lookups", "supports_distance_geodetic")
def test02_distance_lookup(self): def test02_distance_lookup(self):
"Testing GeoQuerySet distance lookup support on non-point geography fields." "Testing GeoQuerySet distance lookup support on non-point geography fields."
@ -42,6 +45,7 @@ class GeographyTest(TestCase):
for cities in [cities1, cities2]: for cities in [cities1, cities2]:
self.assertEqual(['Dallas', 'Houston', 'Oklahoma City'], cities) self.assertEqual(['Dallas', 'Houston', 'Oklahoma City'], cities)
@skipIf(spatialite, "distance() doesn't support geodetic coordinates on SpatiaLite.")
@skipUnlessDBFeature("has_distance_method", "supports_distance_geodetic") @skipUnlessDBFeature("has_distance_method", "supports_distance_geodetic")
@ignore_warnings(category=RemovedInDjango20Warning) @ignore_warnings(category=RemovedInDjango20Warning)
def test03_distance_method(self): def test03_distance_method(self):
@ -97,6 +101,7 @@ class GeographyTest(TestCase):
self.assertEqual(name, c.name) self.assertEqual(name, c.name)
self.assertEqual(state, c.state) self.assertEqual(state, c.state)
@skipIf(spatialite, "area() doesn't support geodetic coordinates on SpatiaLite.")
@skipUnlessDBFeature("has_area_method", "supports_distance_geodetic") @skipUnlessDBFeature("has_area_method", "supports_distance_geodetic")
@ignore_warnings(category=RemovedInDjango20Warning) @ignore_warnings(category=RemovedInDjango20Warning)
def test06_geography_area(self): def test06_geography_area(self):
@ -136,17 +141,22 @@ class GeographyFunctionTests(TestCase):
""" """
if oracle: if oracle:
ref_dists = [0, 4899.68, 8081.30, 9115.15] ref_dists = [0, 4899.68, 8081.30, 9115.15]
elif spatialite:
# SpatiaLite returns non-zero distance for polygons and points
# covered by that polygon.
ref_dists = [326.61, 4899.68, 8081.30, 9115.15]
else: else:
ref_dists = [0, 4891.20, 8071.64, 9123.95] ref_dists = [0, 4891.20, 8071.64, 9123.95]
htown = City.objects.get(name='Houston') htown = City.objects.get(name='Houston')
qs = Zipcode.objects.annotate(distance=Distance('poly', htown.point)) qs = Zipcode.objects.annotate(distance=Distance('poly', htown.point))
for z, ref in zip(qs, ref_dists): for z, ref in zip(qs, ref_dists):
self.assertAlmostEqual(z.distance.m, ref, 2) self.assertAlmostEqual(z.distance.m, ref, 2)
# Distance function in combination with a lookup. if not spatialite:
# Distance function combined with a lookup.
hzip = Zipcode.objects.get(code='77002') hzip = Zipcode.objects.get(code='77002')
self.assertEqual(qs.get(distance__lte=0), hzip) self.assertEqual(qs.get(distance__lte=0), hzip)
@skipUnlessDBFeature("has_Area_function", "supports_distance_geodetic") @skipUnlessDBFeature("has_Area_function", "supports_area_geodetic")
def test_geography_area(self): def test_geography_area(self):
""" """
Testing that Area calculations work on geography columns. Testing that Area calculations work on geography columns.
@ -158,3 +168,9 @@ class GeographyFunctionTests(TestCase):
rounded_value = z.area.sq_m rounded_value = z.area.sq_m
rounded_value -= z.area.sq_m % 1000 rounded_value -= z.area.sq_m % 1000
self.assertEqual(rounded_value, 5439000) self.assertEqual(rounded_value, 5439000)
@skipUnlessDBFeature("has_Area_function")
@skipIfDBFeature("supports_area_geodetic")
def test_geodetic_area_raises_if_not_supported(self):
with self.assertRaisesMessage(NotImplementedError, 'Area on geodetic coordinate systems not supported.'):
Zipcode.objects.annotate(area=Area('poly')).get(code='77002')