diff --git a/django/contrib/gis/db/backends/base/operations.py b/django/contrib/gis/db/backends/base/operations.py index e7bffb11b4..f6eaf8f503 100644 --- a/django/contrib/gis/db/backends/base/operations.py +++ b/django/contrib/gis/db/backends/base/operations.py @@ -48,6 +48,7 @@ class BaseSpatialOperations: "GeoHash", "GeometryDistance", "Intersection", + "IsEmpty", "IsValid", "Length", "LineLocatePoint", diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py index 6d04874537..46d8fc5d6a 100644 --- a/django/contrib/gis/db/backends/mysql/operations.py +++ b/django/contrib/gis/db/backends/mysql/operations.py @@ -72,6 +72,7 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations): "BoundingCircle", "ForcePolygonCW", "GeometryDistance", + "IsEmpty", "LineLocatePoint", "MakeValid", "MemSize", diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py index ba7e3ca4d8..aefb2f74e2 100644 --- a/django/contrib/gis/db/backends/oracle/operations.py +++ b/django/contrib/gis/db/backends/oracle/operations.py @@ -122,6 +122,7 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations): "ForcePolygonCW", "GeoHash", "GeometryDistance", + "IsEmpty", "LineLocatePoint", "MakeValid", "MemSize", diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py index 8003fcb6c6..47f3d4ca75 100644 --- a/django/contrib/gis/db/backends/spatialite/operations.py +++ b/django/contrib/gis/db/backends/spatialite/operations.py @@ -78,7 +78,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations): @cached_property def unsupported_functions(self): - unsupported = {"BoundingCircle", "GeometryDistance", "MemSize"} + unsupported = {"BoundingCircle", "GeometryDistance", "IsEmpty", "MemSize"} if not self.geom_lib_version(): unsupported |= {"Azimuth", "GeoHash", "MakeValid"} return unsupported diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py index 5f6c7b5cfd..f97c540a1a 100644 --- a/django/contrib/gis/db/models/functions.py +++ b/django/contrib/gis/db/models/functions.py @@ -381,6 +381,12 @@ class Intersection(OracleToleranceMixin, GeomOutputGeoFunc): geom_param_pos = (0, 1) +@BaseSpatialField.register_lookup +class IsEmpty(GeoFuncMixin, Transform): + lookup_name = "isempty" + output_field = BooleanField() + + @BaseSpatialField.register_lookup class IsValid(OracleToleranceMixin, GeoFuncMixin, Transform): lookup_name = "isvalid" diff --git a/docs/ref/contrib/gis/db-api.txt b/docs/ref/contrib/gis/db-api.txt index bc216c7caf..97f1f6ca6b 100644 --- a/docs/ref/contrib/gis/db-api.txt +++ b/docs/ref/contrib/gis/db-api.txt @@ -316,6 +316,7 @@ Lookup Type PostGIS Oracle MariaDB MySQL [#]_ Sp :lookup:`equals` X X X X X C :lookup:`exact <same_as>` X X X X X B :lookup:`intersects` X X X X X B +:lookup:`isempty` X :lookup:`isvalid` X X X X :lookup:`overlaps` X X X X X B :lookup:`relate` X X X X C @@ -361,6 +362,7 @@ Function PostGIS Oracle MariaDB MySQL :class:`ForcePolygonCW` X X :class:`GeoHash` X X X (LWGEOM/RTTOPO) :class:`Intersection` X X X X X +:class:`IsEmpty` X :class:`IsValid` X X X X :class:`Length` X X X X X :class:`LineLocatePoint` X X diff --git a/docs/ref/contrib/gis/functions.txt b/docs/ref/contrib/gis/functions.txt index 00c48e665e..1c062515c6 100644 --- a/docs/ref/contrib/gis/functions.txt +++ b/docs/ref/contrib/gis/functions.txt @@ -23,11 +23,11 @@ Function's summary: ========================= ======================== ====================== ======================= ================== ===================== Measurement Relationships Operations Editors Output format Miscellaneous ========================= ======================== ====================== ======================= ================== ===================== -:class:`Area` :class:`Azimuth` :class:`Difference` :class:`ForcePolygonCW` :class:`AsGeoJSON` :class:`IsValid` -:class:`Distance` :class:`BoundingCircle` :class:`Intersection` :class:`MakeValid` :class:`AsGML` :class:`MemSize` -:class:`GeometryDistance` :class:`Centroid` :class:`SymDifference` :class:`Reverse` :class:`AsKML` :class:`NumGeometries` -:class:`Length` :class:`Envelope` :class:`Union` :class:`Scale` :class:`AsSVG` :class:`NumPoints` -:class:`Perimeter` :class:`LineLocatePoint` :class:`SnapToGrid` :class:`AsWKB` +:class:`Area` :class:`Azimuth` :class:`Difference` :class:`ForcePolygonCW` :class:`AsGeoJSON` :class:`IsEmpty` +:class:`Distance` :class:`BoundingCircle` :class:`Intersection` :class:`MakeValid` :class:`AsGML` :class:`IsValid` +:class:`GeometryDistance` :class:`Centroid` :class:`SymDifference` :class:`Reverse` :class:`AsKML` :class:`MemSize` +:class:`Length` :class:`Envelope` :class:`Union` :class:`Scale` :class:`AsSVG` :class:`NumGeometries` +:class:`Perimeter` :class:`LineLocatePoint` :class:`SnapToGrid` :class:`AsWKB` :class:`NumPoints` .. :class:`PointOnSurface` :class:`Transform` :class:`AsWKT` .. :class:`Translate` :class:`GeoHash` ========================= ======================== ====================== ======================= ================== ===================== @@ -368,6 +368,18 @@ it provides index-assisted nearest-neighbor result sets. Accepts two geographic fields or expressions and returns the geometric intersection between them. +``IsEmpty`` +=========== + +.. versionadded:: 4.2 + +.. class:: IsEmpty(expr) + +*Availability*: `PostGIS <https://postgis.net/docs/ST_IsEmpty.html>`__ + +Accepts a geographic field or expression and tests if the value is an empty +geometry. Returns ``True`` if its value is empty and ``False`` otherwise. + ``IsValid`` =========== diff --git a/docs/ref/contrib/gis/geoquerysets.txt b/docs/ref/contrib/gis/geoquerysets.txt index 760d508b17..53a8b6c3b9 100644 --- a/docs/ref/contrib/gis/geoquerysets.txt +++ b/docs/ref/contrib/gis/geoquerysets.txt @@ -346,6 +346,21 @@ MySQL ``ST_Intersects(poly, geom)`` SpatiaLite ``Intersects(poly, geom)`` ========== ================================================= +.. fieldlookup:: isempty + +``isempty`` +----------- + +.. versionadded:: 4.2 + +*Availability*: `PostGIS <https://postgis.net/docs/ST_IsEmpty.html>`__ + +Tests if the geometry is empty. + +Example:: + + Zipcode.objects.filter(poly__isempty=True) + .. fieldlookup:: isvalid ``isvalid`` diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt index 6d2fb32644..40ca5ba1a7 100644 --- a/docs/releases/4.2.txt +++ b/docs/releases/4.2.txt @@ -155,6 +155,10 @@ Minor features * :class:`~django.contrib.gis.forms.widgets.OpenLayersWidget` is now based on OpenLayers 7.2.2 (previously 4.6.5). +* The new :lookup:`isempty` lookup and + :class:`IsEmpty() <django.contrib.gis.db.models.functions.IsEmpty>` + expression allow filtering empty geometries on PostGIS. + :mod:`django.contrib.messages` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py index e1a66d573e..535e552aa1 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -371,6 +371,18 @@ class GISFunctionsTests(FuncTestMixin, TestCase): else: self.assertIs(c.inter.empty, True) + @skipUnlessDBFeature("supports_empty_geometries", "has_IsEmpty_function") + def test_isempty(self): + empty = City.objects.create(name="Nowhere", point=Point(srid=4326)) + City.objects.create(name="Somewhere", point=Point(6.825, 47.1, srid=4326)) + self.assertSequenceEqual( + City.objects.annotate(isempty=functions.IsEmpty("point")).filter( + isempty=True + ), + [empty], + ) + self.assertSequenceEqual(City.objects.filter(point__isempty=True), [empty]) + @skipUnlessDBFeature("has_IsValid_function") def test_isvalid(self): valid_geom = fromstr("POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))")