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() - - # Regression test for #14060, `~=` was never really implemented for PostGIS. htown = City.objects.get(name="Houston") - with self.assertRaises(ValueError): - City.objects.get(point__exact=htown.point) + 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() + + @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") + 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."