summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSergey Fedoseev <fedoseev.sergey@gmail.com>2017-09-11 20:56:39 +0500
committerTim Graham <timograham@gmail.com>2017-09-11 11:56:39 -0400
commit3905cfa1a578275323bfbfbef09f5aee05b33301 (patch)
tree9311789febe66894dbe92857b4a800644e550704
parent99e65d648842d4715f32682117adc01223fef316 (diff)
Fixed #28353 -- Fixed some GIS functions when queryset is evaluated more than once.
Reverted test for refs #27603 in favor of using FuncTestMixin.
-rw-r--r--django/contrib/gis/db/backends/base/operations.py24
-rw-r--r--django/contrib/gis/db/backends/oracle/operations.py3
-rw-r--r--django/contrib/gis/db/backends/postgis/operations.py3
-rw-r--r--django/contrib/gis/db/models/functions.py79
-rw-r--r--django/contrib/gis/db/models/sql/conversion.py34
-rw-r--r--tests/gis_tests/distapp/tests.py6
-rw-r--r--tests/gis_tests/geo3d/tests.py3
-rw-r--r--tests/gis_tests/geoapp/test_functions.py9
-rw-r--r--tests/gis_tests/geogapp/tests.py4
-rw-r--r--tests/gis_tests/test_fields.py4
-rw-r--r--tests/gis_tests/test_gis_tests_utils.py52
-rw-r--r--tests/gis_tests/utils.py39
12 files changed, 175 insertions, 85 deletions
diff --git a/django/contrib/gis/db/backends/base/operations.py b/django/contrib/gis/db/backends/base/operations.py
index 170775f063..af85b83df6 100644
--- a/django/contrib/gis/db/backends/base/operations.py
+++ b/django/contrib/gis/db/backends/base/operations.py
@@ -1,5 +1,8 @@
from django.contrib.gis.db.models import GeometryField
from django.contrib.gis.db.models.functions import Distance
+from django.contrib.gis.measure import (
+ Area as AreaMeasure, Distance as DistanceMeasure,
+)
from django.utils.functional import cached_property
@@ -135,3 +138,24 @@ class BaseSpatialOperations:
'Subclasses of BaseSpatialOperations must provide a '
'get_geometry_converter() method.'
)
+
+ def get_area_att_for_field(self, field):
+ if field.geodetic(self.connection):
+ if self.connection.features.supports_area_geodetic:
+ return 'sq_m'
+ raise NotImplementedError('Area on geodetic coordinate systems not supported.')
+ else:
+ units_name = field.units_name(self.connection)
+ if units_name:
+ return AreaMeasure.unit_attname(units_name)
+
+ def get_distance_att_for_field(self, field):
+ dist_att = None
+ if field.geodetic(self.connection):
+ if self.connection.features.supports_distance_geodetic:
+ dist_att = 'm'
+ else:
+ units = field.units_name(self.connection)
+ if units:
+ dist_att = DistanceMeasure.unit_attname(units)
+ return dist_att
diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py
index e69b8b8c07..19cf2eaa06 100644
--- a/django/contrib/gis/db/backends/oracle/operations.py
+++ b/django/contrib/gis/db/backends/oracle/operations.py
@@ -212,3 +212,6 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations):
geom.srid = srid
return geom
return converter
+
+ def get_area_att_for_field(self, field):
+ return 'sq_m'
diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py
index a535b49849..51c8d5006e 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -389,3 +389,6 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
def converter(value, expression, connection):
return None if value is None else GEOSGeometryBase(read(value), geom_class)
return converter
+
+ def get_area_att_for_field(self, field):
+ return 'sq_m'
diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py
index e166c21e29..9d245b93c4 100644
--- a/django/contrib/gis/db/models/functions.py
+++ b/django/contrib/gis/db/models/functions.py
@@ -3,9 +3,6 @@ from decimal import Decimal
from django.contrib.gis.db.models.fields import BaseSpatialField, GeometryField
from django.contrib.gis.db.models.sql import AreaField, DistanceField
from django.contrib.gis.geometry.backend import Geometry
-from django.contrib.gis.measure import (
- Area as AreaMeasure, Distance as DistanceMeasure,
-)
from django.core.exceptions import FieldError
from django.db.models import (
BooleanField, FloatField, IntegerField, TextField, Transform,
@@ -121,30 +118,17 @@ class OracleToleranceMixin:
class Area(OracleToleranceMixin, GeoFunc):
- output_field_class = AreaField
arity = 1
+ @cached_property
+ def output_field(self):
+ return AreaField(self.geo_field)
+
def as_sql(self, compiler, connection, **extra_context):
- if connection.ops.geography:
- self.output_field.area_att = 'sq_m'
- else:
- # Getting the area units of the geographic field.
- if self.geo_field.geodetic(connection):
- if connection.features.supports_area_geodetic:
- self.output_field.area_att = 'sq_m'
- else:
- # TODO: Do we want to support raw number areas for geodetic fields?
- raise NotImplementedError('Area on geodetic coordinate systems not supported.')
- else:
- units_name = self.geo_field.units_name(connection)
- if units_name:
- self.output_field.area_att = AreaMeasure.unit_attname(units_name)
+ if not connection.features.supports_area_geodetic and self.geo_field.geodetic(connection):
+ raise NotImplementedError('Area on geodetic coordinate systems not supported.')
return super().as_sql(compiler, connection, **extra_context)
- def as_oracle(self, compiler, connection):
- self.output_field = AreaField('sq_m') # Oracle returns area in units of meters.
- return super().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)'
@@ -237,27 +221,13 @@ class Difference(OracleToleranceMixin, GeomOutputGeoFunc):
class DistanceResultMixin:
- output_field_class = DistanceField
+ @cached_property
+ def output_field(self):
+ return DistanceField(self.geo_field)
def source_is_geography(self):
return self.geo_field.geography and self.geo_field.srid == 4326
- def distance_att(self, connection):
- dist_att = None
- if self.geo_field.geodetic(connection):
- if connection.features.supports_distance_geodetic:
- dist_att = 'm'
- else:
- units = self.geo_field.units_name(connection)
- if units:
- dist_att = DistanceMeasure.unit_attname(units)
- return dist_att
-
- def as_sql(self, compiler, connection, **extra_context):
- clone = self.copy()
- clone.output_field.distance_att = self.distance_att(connection)
- return super(DistanceResultMixin, clone).as_sql(compiler, connection, **extra_context)
-
class Distance(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
geom_param_pos = (0, 1)
@@ -266,19 +236,19 @@ class Distance(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
def __init__(self, expr1, expr2, spheroid=None, **extra):
expressions = [expr1, expr2]
if spheroid is not None:
- self.spheroid = spheroid
- expressions += (self._handle_param(spheroid, 'spheroid', bool),)
+ self.spheroid = self._handle_param(spheroid, 'spheroid', bool)
super().__init__(*expressions, **extra)
def as_postgresql(self, compiler, connection):
+ clone = self.copy()
function = None
- expr2 = self.source_expressions[1]
+ expr2 = clone.source_expressions[1]
geography = self.source_is_geography()
if expr2.output_field.geography != geography:
if isinstance(expr2, Value):
expr2.output_field.geography = geography
else:
- self.source_expressions[1] = Cast(
+ clone.source_expressions[1] = Cast(
expr2,
GeometryField(srid=expr2.output_field.srid, geography=geography),
)
@@ -289,19 +259,12 @@ class Distance(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
# DistanceSpheroid is more accurate and resource intensive than DistanceSphere
function = connection.ops.spatial_function_name('DistanceSpheroid')
# Replace boolean param by the real spheroid of the base field
- self.source_expressions[2] = Value(self.geo_field.spheroid(connection))
+ clone.source_expressions.append(Value(self.geo_field.spheroid(connection)))
else:
function = connection.ops.spatial_function_name('DistanceSphere')
- return super().as_sql(compiler, connection, function=function)
-
- def as_oracle(self, compiler, connection):
- if self.spheroid:
- self.source_expressions.pop(2)
- return super().as_oracle(compiler, connection)
+ return super(Distance, clone).as_sql(compiler, connection, function=function)
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)'
@@ -360,18 +323,19 @@ class Length(DistanceResultMixin, OracleToleranceMixin, GeoFunc):
return super().as_sql(compiler, connection, **extra_context)
def as_postgresql(self, compiler, connection):
+ clone = self.copy()
function = None
if self.source_is_geography():
- self.source_expressions.append(Value(self.spheroid))
+ clone.source_expressions.append(Value(self.spheroid))
elif self.geo_field.geodetic(connection):
# Geometry fields with geodetic (lon/lat) coordinates need length_spheroid
function = connection.ops.spatial_function_name('LengthSpheroid')
- self.source_expressions.append(Value(self.geo_field.spheroid(connection)))
+ clone.source_expressions.append(Value(self.geo_field.spheroid(connection)))
else:
dim = min(f.dim for f in self.get_source_fields() if f)
if dim > 2:
function = connection.ops.length3d
- return super().as_sql(compiler, connection, function=function)
+ return super(Length, clone).as_sql(compiler, connection, function=function)
def as_sqlite(self, compiler, connection):
function = None
@@ -482,10 +446,11 @@ class Transform(GeomOutputGeoFunc):
class Translate(Scale):
def as_sqlite(self, compiler, connection):
+ clone = self.copy()
if len(self.source_expressions) < 4:
# Always provide the z parameter for ST_Translate
- self.source_expressions.append(Value(0))
- return super().as_sqlite(compiler, connection)
+ clone.source_expressions.append(Value(0))
+ return super(Translate, clone).as_sqlite(compiler, connection)
class Union(OracleToleranceMixin, GeomOutputGeoFunc):
diff --git a/django/contrib/gis/db/models/sql/conversion.py b/django/contrib/gis/db/models/sql/conversion.py
index eafa7b5d17..99ab51e239 100644
--- a/django/contrib/gis/db/models/sql/conversion.py
+++ b/django/contrib/gis/db/models/sql/conversion.py
@@ -10,9 +10,9 @@ from django.db import models
class AreaField(models.FloatField):
"Wrapper for Area values."
- def __init__(self, area_att=None):
+ def __init__(self, geo_field):
super().__init__()
- self.area_att = area_att
+ self.geo_field = geo_field
def get_prep_value(self, value):
if not isinstance(value, Area):
@@ -20,19 +20,21 @@ class AreaField(models.FloatField):
return value
def get_db_prep_value(self, value, connection, prepared=False):
- if value is None or not self.area_att:
- return value
- return getattr(value, self.area_att)
+ if value is None:
+ return
+ area_att = connection.ops.get_area_att_for_field(self.geo_field)
+ return getattr(value, area_att) if area_att else value
def from_db_value(self, value, expression, connection):
+ if value is None:
+ return
# If the database returns a Decimal, convert it to a float as expected
# by the Python geometric objects.
if isinstance(value, Decimal):
value = float(value)
# If the units are known, convert value into area measure.
- if value is not None and self.area_att:
- value = Area(**{self.area_att: value})
- return value
+ area_att = connection.ops.get_area_att_for_field(self.geo_field)
+ return Area(**{area_att: value}) if area_att else value
def get_internal_type(self):
return 'AreaField'
@@ -40,9 +42,9 @@ class AreaField(models.FloatField):
class DistanceField(models.FloatField):
"Wrapper for Distance values."
- def __init__(self, distance_att=None):
+ def __init__(self, geo_field):
super().__init__()
- self.distance_att = distance_att
+ self.geo_field = geo_field
def get_prep_value(self, value):
if isinstance(value, Distance):
@@ -52,14 +54,16 @@ class DistanceField(models.FloatField):
def get_db_prep_value(self, value, connection, prepared=False):
if not isinstance(value, Distance):
return value
- if not self.distance_att:
+ distance_att = connection.ops.get_distance_att_for_field(self.geo_field)
+ if not distance_att:
raise ValueError('Distance measure is supplied, but units are unknown for result.')
- return getattr(value, self.distance_att)
+ return getattr(value, distance_att)
def from_db_value(self, value, expression, connection):
- if value is None or not self.distance_att:
- return value
- return Distance(**{self.distance_att: value})
+ if value is None:
+ return
+ distance_att = connection.ops.get_distance_att_for_field(self.geo_field)
+ return Distance(**{distance_att: value}) if distance_att else value
def get_internal_type(self):
return 'DistanceField'
diff --git a/tests/gis_tests/distapp/tests.py b/tests/gis_tests/distapp/tests.py
index 395f7226ef..d162759513 100644
--- a/tests/gis_tests/distapp/tests.py
+++ b/tests/gis_tests/distapp/tests.py
@@ -9,7 +9,9 @@ from django.db import connection
from django.db.models import F, Q
from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature
-from ..utils import mysql, no_oracle, oracle, postgis, spatialite
+from ..utils import (
+ FuncTestMixin, mysql, no_oracle, oracle, postgis, spatialite,
+)
from .models import (
AustraliaCity, CensusZipcode, Interstate, SouthTexasCity, SouthTexasCityFt,
SouthTexasInterstate, SouthTexasZipcode,
@@ -262,7 +264,7 @@ Perimeter(geom1) | OK | :-(
''' # NOQA
-class DistanceFunctionsTests(TestCase):
+class DistanceFunctionsTests(FuncTestMixin, TestCase):
fixtures = ['initial']
@skipUnlessDBFeature("has_Area_function")
diff --git a/tests/gis_tests/geo3d/tests.py b/tests/gis_tests/geo3d/tests.py
index 39603d1249..d2e85f0607 100644
--- a/tests/gis_tests/geo3d/tests.py
+++ b/tests/gis_tests/geo3d/tests.py
@@ -8,6 +8,7 @@ from django.contrib.gis.db.models.functions import (
from django.contrib.gis.geos import GEOSGeometry, LineString, Point, Polygon
from django.test import TestCase, skipUnlessDBFeature
+from ..utils import FuncTestMixin
from .models import (
City3D, Interstate2D, Interstate3D, InterstateProj2D, InterstateProj3D,
MultiPoint3D, Point2D, Point3D, Polygon2D, Polygon3D,
@@ -205,7 +206,7 @@ class Geo3DTest(Geo3DLoadingHelper, TestCase):
@skipUnlessDBFeature("supports_3d_functions")
-class Geo3DFunctionsTests(Geo3DLoadingHelper, TestCase):
+class Geo3DFunctionsTests(FuncTestMixin, Geo3DLoadingHelper, TestCase):
def test_kml(self):
"""
Test KML() function with Z values.
diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py
index bb13d9e37f..cdd05d78ff 100644
--- a/tests/gis_tests/geoapp/test_functions.py
+++ b/tests/gis_tests/geoapp/test_functions.py
@@ -12,11 +12,11 @@ from django.db import connection
from django.db.models import Sum
from django.test import TestCase, skipUnlessDBFeature
-from ..utils import mysql, oracle, postgis, spatialite
+from ..utils import FuncTestMixin, mysql, oracle, postgis, spatialite
from .models import City, Country, CountryWebMercator, State, Track
-class GISFunctionsTests(TestCase):
+class GISFunctionsTests(FuncTestMixin, TestCase):
"""
Testing functions from django/contrib/gis/db/models/functions.py.
Area/Distance/Length/Perimeter are tested in distapp/tests.
@@ -127,11 +127,8 @@ class GISFunctionsTests(TestCase):
City.objects.annotate(kml=functions.AsKML('name'))
# Ensuring the KML is as expected.
- qs = City.objects.annotate(kml=functions.AsKML('point', precision=9))
- ptown = qs.get(name='Pueblo')
+ ptown = City.objects.annotate(kml=functions.AsKML('point', precision=9)).get(name='Pueblo')
self.assertEqual('<Point><coordinates>-104.609252,38.255001</coordinates></Point>', ptown.kml)
- # Same result if the queryset is evaluated again.
- self.assertEqual(qs.get(name='Pueblo').kml, ptown.kml)
@skipUnlessDBFeature("has_AsSVG_function")
def test_assvg(self):
diff --git a/tests/gis_tests/geogapp/tests.py b/tests/gis_tests/geogapp/tests.py
index 2969ca1cc6..c9986fd78b 100644
--- a/tests/gis_tests/geogapp/tests.py
+++ b/tests/gis_tests/geogapp/tests.py
@@ -11,7 +11,7 @@ from django.db import connection
from django.db.models.functions import Cast
from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature
-from ..utils import oracle, postgis, spatialite
+from ..utils import FuncTestMixin, oracle, postgis, spatialite
from .models import City, County, Zipcode
@@ -86,7 +86,7 @@ class GeographyTest(TestCase):
self.assertEqual(state, c.state)
-class GeographyFunctionTests(TestCase):
+class GeographyFunctionTests(FuncTestMixin, TestCase):
fixtures = ['initial']
@skipUnlessDBFeature("supports_extent_aggr")
diff --git a/tests/gis_tests/test_fields.py b/tests/gis_tests/test_fields.py
index fb0c953f21..27db3e1dfa 100644
--- a/tests/gis_tests/test_fields.py
+++ b/tests/gis_tests/test_fields.py
@@ -7,9 +7,9 @@ from django.test import SimpleTestCase
class FieldsTests(SimpleTestCase):
def test_area_field_deepcopy(self):
- field = AreaField()
+ field = AreaField(None)
self.assertEqual(copy.deepcopy(field), field)
def test_distance_field_deepcopy(self):
- field = DistanceField()
+ field = DistanceField(None)
self.assertEqual(copy.deepcopy(field), field)
diff --git a/tests/gis_tests/test_gis_tests_utils.py b/tests/gis_tests/test_gis_tests_utils.py
new file mode 100644
index 0000000000..32d072fd9b
--- /dev/null
+++ b/tests/gis_tests/test_gis_tests_utils.py
@@ -0,0 +1,52 @@
+from django.db import connection, models
+from django.db.models.expressions import Func
+from django.test import SimpleTestCase
+
+from .utils import FuncTestMixin
+
+
+def test_mutation(raises=True):
+ def wrapper(mutation_func):
+ def test(test_case_instance, *args, **kwargs):
+ class TestFunc(Func):
+ output_field = models.IntegerField()
+
+ def __init__(self):
+ self.attribute = 'initial'
+ super().__init__('initial', ['initial'])
+
+ def as_sql(self, *args, **kwargs):
+ mutation_func(self)
+ return '', ()
+
+ if raises:
+ msg = 'TestFunc Func was mutated during compilation.'
+ with test_case_instance.assertRaisesMessage(AssertionError, msg):
+ getattr(TestFunc(), 'as_' + connection.vendor)(None, None)
+ else:
+ getattr(TestFunc(), 'as_' + connection.vendor)(None, None)
+
+ return test
+ return wrapper
+
+
+class FuncTestMixinTests(FuncTestMixin, SimpleTestCase):
+ @test_mutation()
+ def test_mutated_attribute(func):
+ func.attribute = 'mutated'
+
+ @test_mutation()
+ def test_mutated_expressions(func):
+ func.source_expressions.clear()
+
+ @test_mutation()
+ def test_mutated_expression(func):
+ func.source_expressions[0].name = 'mutated'
+
+ @test_mutation()
+ def test_mutated_expression_deep(func):
+ func.source_expressions[1].value[0] = 'mutated'
+
+ @test_mutation(raises=False)
+ def test_not_mutated(func):
+ pass
diff --git a/tests/gis_tests/utils.py b/tests/gis_tests/utils.py
index 6eb029c1d5..b30da7e40d 100644
--- a/tests/gis_tests/utils.py
+++ b/tests/gis_tests/utils.py
@@ -1,8 +1,11 @@
+import copy
import unittest
from functools import wraps
+from unittest import mock
from django.conf import settings
from django.db import DEFAULT_DB_ALIAS, connection
+from django.db.models.expressions import Func
def skipUnlessGISLookup(*gis_lookups):
@@ -56,3 +59,39 @@ elif spatialite:
from django.contrib.gis.db.backends.spatialite.models import SpatialiteSpatialRefSys as SpatialRefSys
else:
SpatialRefSys = None
+
+
+class FuncTestMixin:
+ """Assert that Func expressions aren't mutated during their as_sql()."""
+ def setUp(self):
+ def as_sql_wrapper(original_as_sql):
+ def inner(*args, **kwargs):
+ func = original_as_sql.__self__
+ # Resolve output_field before as_sql() so touching it in
+ # as_sql() won't change __dict__.
+ func.output_field
+ __dict__original = copy.deepcopy(func.__dict__)
+ result = original_as_sql(*args, **kwargs)
+ msg = '%s Func was mutated during compilation.' % func.__class__.__name__
+ self.assertEqual(func.__dict__, __dict__original, msg)
+ return result
+ return inner
+
+ def __getattribute__(self, name):
+ if name != vendor_impl:
+ return __getattribute__original(self, name)
+ try:
+ as_sql = __getattribute__original(self, vendor_impl)
+ except AttributeError:
+ as_sql = __getattribute__original(self, 'as_sql')
+ return as_sql_wrapper(as_sql)
+
+ vendor_impl = 'as_' + connection.vendor
+ __getattribute__original = Func.__getattribute__
+ self.func_patcher = mock.patch.object(Func, '__getattribute__', __getattribute__)
+ self.func_patcher.start()
+ super().setUp()
+
+ def tearDown(self):
+ super().tearDown()
+ self.func_patcher.stop()