diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index 1256c2dc3..e473691c0 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -4,6 +4,8 @@ class GISFeatures(BaseSpatialFeatures): has_spatialrefsys_table = False + supports_distance_geodetic = False + supports_dwithin_distance_expr = False supports_transform = False @cached_property @@ -45,9 +47,9 @@ def django_test_skips(self): # migrations don't need to call it, so the check doesn't happen. "gis_tests.gis_migrations.test_operations.NoRasterSupportTests", }, - "GIS lookups not supported.": { - "gis_tests.geoapp.tests.GeoModelTest.test_gis_query_as_string", - "gis_tests.geoapp.tests.GeoLookupTest.test_gis_lookups_with_complex_expressions", + "MongoDB does not support expressions for spatial lookup values.": { + "gis_tests.geoapp.tests.GeoLookupTest.test_subquery_annotation", + "gis_tests.geoapp.tests.GeoQuerySetTest.test_within_subquery", }, "GeoJSONSerializer doesn't support ObjectId.": { "gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_fields_option", diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py index 8df8ed59c..abad26b09 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -1,9 +1,21 @@ from django.contrib.gis.db.models.lookups import GISLookup from django.db import NotSupportedError +from django_mongodb_backend.query_utils import process_lhs, process_rhs -def gis_lookup(self, compiler, connection, as_expr=False): # noqa: ARG001 - raise NotSupportedError(f"MongoDB does not support the {self.lookup_name} lookup.") + +def gis_lookup(self, compiler, connection, as_expr=False): + if as_expr or not self.can_use_path: + raise NotSupportedError("MongoDB does not support expressions for spatial lookup values.") + lhs_mql = process_lhs(self, compiler, connection, as_expr=as_expr) + rhs_mql = process_rhs(self, compiler, connection, as_expr=as_expr) + try: + rhs_op = self.get_rhs_op(connection, rhs_mql) + except KeyError as exc: + raise NotSupportedError( + f"MongoDB does not support the '{self.lookup_name}' lookup." + ) from exc + return rhs_op.as_mql(lhs_mql, rhs_mql, self.rhs_params) def register_lookups(): diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index 729ea197e..e6cc78f3b 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -1,8 +1,18 @@ from django.contrib.gis import geos from django.contrib.gis.db import models from django.contrib.gis.db.backends.base.operations import BaseSpatialOperations +from django.contrib.gis.measure import Distance from .adapter import Adapter +from .operators import ( + Contains, + Disjoint, + DistanceGT, + DistanceLTE, + DWithin, + Intersects, + Within, +) class GISOperations(BaseSpatialOperations): @@ -16,9 +26,15 @@ class GISOperations(BaseSpatialOperations): models.Union, ) - @property - def gis_operators(self): - return {} + gis_operators = { + "contains": Contains(), + "disjoint": Disjoint(), + "distance_gt": DistanceGT(), + "distance_lte": DistanceLTE(), + "dwithin": DWithin(), + "intersects": Intersects(), + "within": Within(), + } unsupported_functions = { "Area", @@ -97,3 +113,9 @@ def converter(value, expression, connection): # noqa: ARG001 return geom_class(*value["coordinates"], srid=srid) return converter + + def get_distance(self, f, value, lookup_type): + value = value[0] + if isinstance(value, Distance): + raise ValueError("Only numeric values of degree units are allowed on dwithin queries.") + return [value] diff --git a/django_mongodb_backend/gis/operators.py b/django_mongodb_backend/gis/operators.py new file mode 100644 index 000000000..ba9c89198 --- /dev/null +++ b/django_mongodb_backend/gis/operators.py @@ -0,0 +1,119 @@ +from math import radians + +from django.db import NotSupportedError + + +class Operator: + def as_sql(self, connection, lookup, template_params, sql_params): + # Return some dummy value to prevent str(queryset.query) from crashing. + # The output of as_sql() is meaningless for this no-SQL backend. + return self.name, [] + + +class Contains(Operator): + name = "contains" + + def as_mql(self, field, value, params=None): + value_type = value["type"] + if value_type != "Point": + raise NotSupportedError( + "MongoDB does not support contains on non-Point lookup geometries." + ) + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value_type, + "coordinates": value["coordinates"], + } + } + } + } + + +class Disjoint(Operator): + name = "disjoint" + + def as_mql(self, field, value, params=None): + return { + field: { + "$not": { + "$geoIntersects": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + } + + +class DistanceLTE(Operator): + name = "distance_lte" + + def get_geo_within(self, value, params): + distance = params[0] + # Get the distance in meters if it's a Distance object. + distance = distance.m if hasattr(distance, "m") else distance + return { + "$geoWithin": { + "$centerSphere": [ + value["coordinates"], + distance / 6378100, # radius of earth in meters + ], + } + } + + def as_mql(self, field, value, params=None): + return {field: self.get_geo_within(value, params)} + + +class DistanceGT(DistanceLTE): + name = "distance_gt" + + def as_mql(self, field, value, params=None): + return {field: {"$not": self.get_geo_within(value, params)}} + + +class DWithin(Operator): + name = "dwithin" + + def as_mql(self, field, value, params=None): + # The parameter is always in degrees. GISOperations.get_distance() + # prohibits using Distance objects since MongoDB doesn't support any + # projected systems. + param = radians(params[0]) + return {field: {"$geoWithin": {"$centerSphere": [value["coordinates"], param]}}} + + +class Intersects(Operator): + name = "intersects" + + def as_mql(self, field, value, params=None): + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + + +class Within(Operator): + name = "within" + + def as_mql(self, field, value, params=None): + return { + field: { + "$geoWithin": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } diff --git a/docs/ref/contrib/gis.rst b/docs/ref/contrib/gis.rst index c329759af..e35a52d58 100644 --- a/docs/ref/contrib/gis.rst +++ b/docs/ref/contrib/gis.rst @@ -4,6 +4,9 @@ GeoDjango Django MongoDB Backend supports :doc:`GeoDjango`. +Spatial fields +============== + Each model field stores data as :doc:`GeoJSON objects `. @@ -18,6 +21,37 @@ Each model field stores data as :doc:`GeoJSON objects All fields have a :doc:`2dsphere index ` created on them. +.. _spatial-lookups: + +Spatial lookups +=============== + +.. versionadded:: 6.0.1 + +The following :ref:`spatial lookups ` are supported: + +- :lookup:`contains ` (the lookup geometry must be a + :class:`~django.contrib.gis.geos.Point`) +- :lookup:`disjoint` +- :lookup:`distance_gt` +- :lookup:`distance_lte` +- :lookup:`dwithin` +- :lookup:`intersects` +- :lookup:`within` + +For all lookups, the lookup value must be a :ref:`geometry object +` (e.g. +:class:`~django.contrib.gis.geos.Point`, +:class:`~django.contrib.gis.geos.LineString`, etc.) or a :ref:`geometry +collection ` (e.g. +:class:`~django.contrib.gis.geos.MultiPoint`, +:class:`~django.contrib.gis.geos.MultiLineString`, etc.). MongoDB does not +support expressions (:class:`~django.db.models.F`, +:class:`~django.db.models.Subquery`, etc.) for spatial lookup values. + +Raw spatial queries +=================== + You can use any of the :ref:`geospatial query operators ` or the :ref:`geospatial aggregation pipeline stage ` in :meth:`.raw_aggregate` queries. @@ -39,6 +73,7 @@ Limitations (:attr:`BaseSpatialField.srid `) besides `4326 (WGS84) `_. -- None of the :doc:`GIS QuerySet APIs ` - (lookups, aggregates, and database functions) are supported. +- :ref:`GIS aggregate functions ` and + :doc:`geographic database functions ` + aren't supported. - :class:`~django.contrib.gis.db.models.RasterField` isn't supported. diff --git a/docs/releases/6.0.x.rst b/docs/releases/6.0.x.rst index 0427b6958..e049a4135 100644 --- a/docs/releases/6.0.x.rst +++ b/docs/releases/6.0.x.rst @@ -10,7 +10,7 @@ Django MongoDB Backend 6.0.x New features ------------ -- ... +- Added support for :ref:`spatial lookups `. Bug fixes --------- diff --git a/tests/gis_tests_/fixtures/initial.json b/tests/gis_tests_/fixtures/initial.json new file mode 100644 index 000000000..ed0a1a493 --- /dev/null +++ b/tests/gis_tests_/fixtures/initial.json @@ -0,0 +1,98 @@ +[ + { + "pk": "000000000000000000000001", + "model": "gis_tests_.city", + "fields": { + "name": "Houston", + "point": "POINT (-95.363151 29.763374)" + } + }, + { + "pk": "000000000000000000000002", + "model": "gis_tests_.city", + "fields": { + "name": "Dallas", + "point": "POINT (-96.801611 32.782057)" + } + }, + { + "pk": "000000000000000000000003", + "model": "gis_tests_.city", + "fields": { + "name": "Oklahoma City", + "point": "POINT (-97.521157 34.464642)" + } + }, + { + "pk": "000000000000000000000004", + "model": "gis_tests_.city", + "fields": { + "name": "Wellington", + "point": "POINT (174.783117 -41.315268)" + } + }, + { + "pk": "000000000000000000000005", + "model": "gis_tests_.city", + "fields": { + "name": "Pueblo", + "point": "POINT (-104.609252 38.255001)" + } + }, + { + "pk": "000000000000000000000006", + "model": "gis_tests_.city", + "fields": { + "name": "Lawrence", + "point": "POINT (-95.235060 38.971823)" + } + }, + { + "pk": "000000000000000000000007", + "model": "gis_tests_.city", + "fields": { + "name": "Chicago", + "point": "POINT (-87.650175 41.850385)" + } + }, + { + "pk": "000000000000000000000008", + "model": "gis_tests_.city", + "fields": { + "name": "Victoria", + "point": "POINT (-123.305196 48.462611)" + } + }, + { + "pk": "000000000000000000000001", + "model": "gis_tests_.zipcode", + "fields" : { + "code" : "77002", + "poly" : "POLYGON ((-95.365015 29.772327, -95.362415 29.772327, -95.360915 29.771827, -95.354615 29.771827, -95.351515 29.772527, -95.350915 29.765327, -95.351015 29.762436, -95.350115 29.760328, -95.347515 29.758528, -95.352315 29.753928, -95.356415 29.756328, -95.358215 29.754028, -95.360215 29.756328, -95.363415 29.757128, -95.364014 29.75638, -95.363415 29.753928, -95.360015 29.751828, -95.361815 29.749528, -95.362715 29.750028, -95.367516 29.744128, -95.369316 29.745128, -95.373916 29.744128, -95.380116 29.738028, -95.387916 29.727929, -95.388516 29.729629, -95.387916 29.732129, -95.382916 29.737428, -95.376616 29.742228, -95.372616 29.747228, -95.378601 29.750846, -95.378616 29.752028, -95.378616 29.754428, -95.376016 29.754528, -95.374616 29.759828, -95.373616 29.761128, -95.371916 29.763928, -95.372316 29.768727, -95.365884 29.76791, -95.366015 29.767127, -95.358715 29.765327, -95.358615 29.766327, -95.359115 29.767227, -95.360215 29.767027, -95.362783 29.768267, -95.365315 29.770527, -95.365015 29.772327))" + } + }, + { + "pk": "000000000000000000000002", + "model": "gis_tests_.zipcode", + "fields" : { + "code" : "77005", + "poly" : "POLYGON ((-95.447918 29.727275, -95.428017 29.728729, -95.421117 29.729029, -95.418617 29.727629, -95.418517 29.726429, -95.402117 29.726629, -95.402117 29.725729, -95.395316 29.725729, -95.391916 29.726229, -95.389716 29.725829, -95.396517 29.715429, -95.397517 29.715929, -95.400917 29.711429, -95.411417 29.715029, -95.418417 29.714729, -95.418317 29.70623, -95.440818 29.70593, -95.445018 29.70683, -95.446618 29.70763, -95.447418 29.71003, -95.447918 29.727275))" + } + }, + { + "pk": "000000000000000000000003", + "model": "gis_tests_.zipcode", + "fields" : { + "code" : "77025", + "poly" : "POLYGON ((-95.418317 29.70623, -95.414717 29.706129, -95.414617 29.70533, -95.418217 29.70533, -95.419817 29.69533, -95.419484 29.694196, -95.417166 29.690901, -95.414517 29.69433, -95.413317 29.69263, -95.412617 29.68973, -95.412817 29.68753, -95.414087 29.685055, -95.419165 29.685428, -95.421617 29.68513, -95.425717 29.67983, -95.425017 29.67923, -95.424517 29.67763, -95.427418 29.67763, -95.438018 29.664631, -95.436713 29.664411, -95.440118 29.662231, -95.439218 29.661031, -95.437718 29.660131, -95.435718 29.659731, -95.431818 29.660331, -95.441418 29.656631, -95.441318 29.656331, -95.441818 29.656131, -95.441718 29.659031, -95.441118 29.661031, -95.446718 29.656431, -95.446518 29.673431, -95.446918 29.69013, -95.447418 29.71003, -95.446618 29.70763, -95.445018 29.70683, -95.440818 29.70593, -95.418317 29.70623))" + } + }, + { + "pk": "000000000000000000000004", + "model": "gis_tests_.zipcode", + "fields" : { + "code" : "77401", + "poly" : "POLYGON ((-95.447918 29.727275, -95.447418 29.71003, -95.446918 29.69013, -95.454318 29.68893, -95.475819 29.68903, -95.475819 29.69113, -95.484419 29.69103, -95.484519 29.69903, -95.480419 29.70133, -95.480419 29.69833, -95.474119 29.69833, -95.474119 29.70453, -95.472719 29.71283, -95.468019 29.71293, -95.468219 29.720229, -95.464018 29.720229, -95.464118 29.724529, -95.463018 29.725929, -95.459818 29.726129, -95.459918 29.720329, -95.451418 29.720429, -95.451775 29.726303, -95.451318 29.727029, -95.447918 29.727275))" + } + } +] \ No newline at end of file diff --git a/tests/gis_tests_/models.py b/tests/gis_tests_/models.py index cec1e0a93..9872e42fc 100644 --- a/tests/gis_tests_/models.py +++ b/tests/gis_tests_/models.py @@ -1,5 +1,26 @@ from django.contrib.gis.db import models -class City(models.Model): +class NamedModel(models.Model): + name = models.CharField(max_length=30) + + class Meta: + abstract = True + + def __str__(self): + return self.name + + +class City(NamedModel): point = models.PointField() + + +class MultiFields(NamedModel): + city = models.ForeignKey(City, models.CASCADE) + point = models.PointField() + poly = models.PolygonField() + + +class Zipcode(models.Model): + code = models.CharField(max_length=10) + poly = models.PolygonField(geography=True) diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index 014b8e5e9..18f04f098 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -1,13 +1,142 @@ -from django.contrib.gis.geos import Point +from django.contrib.gis.geos import LineString, Point, Polygon +from django.contrib.gis.measure import Distance from django.db import NotSupportedError +from django.db.models import Case, CharField, F, OuterRef, Subquery, Value, When from django.test import TestCase, skipUnlessDBFeature -from .models import City +from .models import City, MultiFields, Zipcode @skipUnlessDBFeature("gis_enabled") class LookupTests(TestCase): - def test_unsupported_lookups(self): - msg = "MongoDB does not support the same_as lookup." + fixtures = ["initial"] + + def test_contains(self): + qs = City.objects.filter(point__contains=Point(-95.363151, 29.763374)).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, ["Houston"]) + + def test_contains_errors_on_non_point(self): + message = "MongoDB does not support contains on non-Point lookup geometries." + with self.assertRaisesMessage(NotSupportedError, message): + City.objects.filter(point__contains=LineString((0, 0), (1, 1))).first() + + def test_disjoint(self): + qs = City.objects.filter(point__disjoint=Point(100, 50)).values_list("name", flat=True) + self.assertCountEqual( + qs, + [ + "Houston", + "Dallas", + "Oklahoma City", + "Wellington", + "Pueblo", + "Lawrence", + "Chicago", + "Victoria", + ], + ) + + def test_distance_gt(self): + houston = City.objects.get(name="Houston") + qs = City.objects.filter(point__distance_gt=(houston.point, 362825)).values_list( + "name", flat=True + ) + self.assertCountEqual( + qs, + [ + "Dallas", + "Oklahoma City", + "Wellington", + "Pueblo", + "Lawrence", + "Chicago", + "Victoria", + ], + ) + + def test_distance_lte(self): + houston = City.objects.get(name="Houston") + qs = City.objects.filter(point__distance_lte=(houston.point, 362826)).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, ["Houston", "Dallas"]) # Dallas is roughly ~363 km from Houston + + def test_distance_units(self): + chicago = City.objects.get(name="Chicago") + qs = City.objects.filter(point__distance_lte=(chicago.point, Distance(km=720))).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, ["Lawrence", "Chicago"]) + qs = City.objects.filter(point__distance_lte=(chicago.point, Distance(mi=447))).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, ["Lawrence", "Chicago"]) + + def test_dwithin(self): + houston = City.objects.get(name="Houston") + qs = City.objects.filter(point__dwithin=(houston.point, 11.46)).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, ["Houston", "Dallas", "Pueblo", "Oklahoma City", "Lawrence"]) + + def test_dwithin_unsupported_units(self): + message = "Only numeric values of degree units are allowed on dwithin queries." + with self.assertRaisesMessage(ValueError, message): + City.objects.filter(point__dwithin=(Point(40.7670, -73.9820), Distance(km=1))).first() + + def test_intersects(self): + city = City.objects.create(point=Point(95, 30)) + qs = City.objects.filter(point__intersects=Point(95, 30).buffer(10)) + self.assertCountEqual(qs, [city]) + + def test_within(self): + zipcode = Zipcode.objects.get(code="77002") + qs = City.objects.filter(point__within=zipcode.poly).values_list("name", flat=True) + self.assertCountEqual(qs, ["Houston"]) + + def test_unsupported(self): + msg = "MongoDB does not support the 'same_as' lookup." with self.assertRaisesMessage(NotSupportedError, msg): City.objects.get(point__same_as=Point(95, 30)) + + def test_lookup_expression(self): + downtown_area = Polygon( + ( + (-122.4194, 37.7749), + (-122.4194, 37.8049), + (-122.3894, 37.8049), + (-122.3894, 37.7749), + (-122.4194, 37.7749), + ) + ) + msg = "MongoDB does not support expressions for spatial lookup values." + with self.assertRaisesMessage(NotSupportedError, msg): + City.objects.annotate( + area_type=Case( + When(point__within=downtown_area, then=Value("Downtown")), + default=Value("Other"), + output_field=CharField(), + ) + ).first() + + def test_subquery_on_lhs(self): + msg = "MongoDB does not support expressions for spatial lookup values." + with self.assertRaisesMessage(NotSupportedError, msg): + MultiFields.objects.annotate( + city_point=Subquery( + City.objects.filter( + id=OuterRef("city"), + ).values("point") + ), + ).filter( + city_point__within=F("poly"), + ).get() + + def test_subquery_on_rhs(self): + msg = "MongoDB does not support expressions for spatial lookup values." + with self.assertRaisesMessage(NotSupportedError, msg): + City.objects.filter( + point__within=Zipcode.objects.filter(code="73301").values("poly") + ).get()