summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Walls <jwalls@azavea.com>2023-02-02 10:23:16 -0500
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2023-02-07 11:48:10 +0100
commit4403432b759124aa613249373e0d2ede64ae8765 (patch)
tree120b68e4926f48b961f302a1b064066864d68daa
parentfb77be9ae1cbca4a52948e129c32a482c2c99c10 (diff)
Fixed #33638 -- Fixed GIS lookups crash with geography fields on PostGIS.
-rw-r--r--django/contrib/gis/db/backends/postgis/operations.py16
-rw-r--r--tests/gis_tests/geogapp/models.py10
-rw-r--r--tests/gis_tests/geogapp/tests.py58
3 files changed, 58 insertions, 26 deletions
diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py
index efa1d0f204..b68db377f8 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -27,7 +27,8 @@ BILATERAL = "bilateral"
class PostGISOperator(SpatialOperator):
def __init__(self, geography=False, raster=False, **kwargs):
# Only a subset of the operators and functions are available for the
- # geography type.
+ # geography type. Lookups that don't support geography will be cast to
+ # geometry.
self.geography = geography
# Only a subset of the operators and functions are available for the
# raster type. Lookups that don't support raster will be converted to
@@ -37,13 +38,8 @@ class PostGISOperator(SpatialOperator):
super().__init__(**kwargs)
def as_sql(self, connection, lookup, template_params, *args):
- if lookup.lhs.output_field.geography and not self.geography:
- raise ValueError(
- 'PostGIS geography does not support the "%s" '
- "function/operator." % (self.func or self.op,)
- )
-
template_params = self.check_raster(lookup, template_params)
+ template_params = self.check_geography(lookup, template_params)
return super().as_sql(connection, lookup, template_params, *args)
def check_raster(self, lookup, template_params):
@@ -93,6 +89,12 @@ class PostGISOperator(SpatialOperator):
return template_params
+ def check_geography(self, lookup, template_params):
+ """Convert geography fields to geometry types, if necessary."""
+ if lookup.lhs.output_field.geography and not self.geography:
+ template_params["lhs"] += "::geometry"
+ return template_params
+
class ST_Polygon(Func):
function = "ST_Polygon"
diff --git a/tests/gis_tests/geogapp/models.py b/tests/gis_tests/geogapp/models.py
index c5c550feba..c06d4a7f8f 100644
--- a/tests/gis_tests/geogapp/models.py
+++ b/tests/gis_tests/geogapp/models.py
@@ -18,6 +18,16 @@ class City(NamedModel):
app_label = "geogapp"
+class CityUnique(NamedModel):
+ point = models.PointField(geography=True, unique=True)
+
+ class Meta:
+ required_db_features = {
+ "supports_geography",
+ "supports_geometry_field_unique_index",
+ }
+
+
class Zipcode(NamedModel):
code = models.CharField(max_length=10)
poly = models.PolygonField(geography=True)
diff --git a/tests/gis_tests/geogapp/tests.py b/tests/gis_tests/geogapp/tests.py
index ae12d26706..8565608993 100644
--- a/tests/gis_tests/geogapp/tests.py
+++ b/tests/gis_tests/geogapp/tests.py
@@ -6,12 +6,14 @@ import os
from django.contrib.gis.db import models
from django.contrib.gis.db.models.functions import Area, Distance
from django.contrib.gis.measure import D
+from django.core.exceptions import ValidationError
from django.db import NotSupportedError, connection
from django.db.models.functions import Cast
from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature
+from django.test.utils import CaptureQueriesContext
from ..utils import FuncTestMixin
-from .models import City, County, Zipcode
+from .models import City, CityUnique, County, Zipcode
class GeographyTest(TestCase):
@@ -38,28 +40,46 @@ class GeographyTest(TestCase):
for cities in [cities1, cities2]:
self.assertEqual(["Dallas", "Houston", "Oklahoma City"], cities)
- def test04_invalid_operators_functions(self):
+ @skipUnlessDBFeature("supports_geography", "supports_geometry_field_unique_index")
+ def test_geography_unique(self):
"""
- Exceptions are raised for operators & functions invalid on geography
- fields.
+ Cast geography fields to geometry type when validating uniqueness to
+ remove the reliance on unavailable ~= operator.
"""
- if not connection.ops.postgis:
- self.skipTest("This is a PostGIS-specific test.")
- # Only a subset of the geometry functions & operator are available
- # to PostGIS geography types. For more information, visit:
- # http://postgis.refractions.net/documentation/manual-1.5/ch08.html#PostGIS_GeographyFunctions
- z = Zipcode.objects.get(code="77002")
- # ST_Within not available.
- with self.assertRaises(ValueError):
- City.objects.filter(point__within=z.poly).count()
- # `@` operator not available.
- with self.assertRaises(ValueError):
- City.objects.filter(point__contained=z.poly).count()
+ htown = City.objects.get(name="Houston")
+ CityUnique.objects.create(point=htown.point)
+ duplicate = CityUnique(point=htown.point)
+ msg = "City unique with this Point already exists."
+ with self.assertRaisesMessage(ValidationError, msg):
+ duplicate.validate_unique()
- # Regression test for #14060, `~=` was never really implemented for PostGIS.
+ @skipUnlessDBFeature("supports_geography")
+ def test_operators_functions_unavailable_for_geography(self):
+ """
+ Geography fields are cast to geometry if the relevant operators or
+ functions are not available.
+ """
+ z = Zipcode.objects.get(code="77002")
+ point_field = "%s.%s::geometry" % (
+ connection.ops.quote_name(City._meta.db_table),
+ connection.ops.quote_name("point"),
+ )
+ # ST_Within.
+ qs = City.objects.filter(point__within=z.poly)
+ with CaptureQueriesContext(connection) as ctx:
+ self.assertEqual(qs.count(), 1)
+ self.assertIn(f"ST_Within({point_field}", ctx.captured_queries[0]["sql"])
+ # @ operator.
+ qs = City.objects.filter(point__contained=z.poly)
+ with CaptureQueriesContext(connection) as ctx:
+ self.assertEqual(qs.count(), 1)
+ self.assertIn(f"{point_field} @", ctx.captured_queries[0]["sql"])
+ # ~= operator.
htown = City.objects.get(name="Houston")
- with self.assertRaises(ValueError):
- City.objects.get(point__exact=htown.point)
+ qs = City.objects.filter(point__exact=htown.point)
+ with CaptureQueriesContext(connection) as ctx:
+ self.assertEqual(qs.count(), 1)
+ self.assertIn(f"{point_field} ~=", ctx.captured_queries[0]["sql"])
def test05_geography_layermapping(self):
"Testing LayerMapping support on models with geography fields."