diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index c82f48435..ba66af2d6 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -5,6 +5,7 @@ class GISFeatures(BaseSpatialFeatures): has_spatialrefsys_table = False supports_transform = False + supports_distance_geodetic = False @cached_property def django_test_expected_failures(self): @@ -39,6 +40,9 @@ def django_test_skips(self): # SouthTexasCity fixture objects use SRID 2278 which is ignored # by the patched version of loaddata in the Django fork. "gis_tests.distapp.tests.DistanceTest.test_init", + "gis_tests.distapp.tests.DistanceTest.test_distance_lookups", + "gis_tests.distapp.tests.DistanceTest.test_distance_lookups_with_expression_rhs", + "gis_tests.distapp.tests.DistanceTest.test_distance_annotation_group_by", }, "ImproperlyConfigured isn't raised when using RasterField": { # Normally RasterField.db_type() raises an error, but MongoDB @@ -49,9 +53,17 @@ def django_test_skips(self): # Error: Index already exists with a different name "gis_tests.geoapp.test_indexes.SchemaIndexesTests.test_index_name", }, - "GIS lookups not supported.": { - "gis_tests.geoapp.tests.GeoModelTest.test_gis_query_as_string", + "GIS Union not supported.": { "gis_tests.geoapp.tests.GeoLookupTest.test_gis_lookups_with_complex_expressions", + "gis_tests.distapp.tests.DistanceTest.test_dwithin", + }, + "Cannot use a non-Point geometry with distance lookups.": { + "gis_tests.distapp.tests.DistanceTest.test_dwithin_with_expression_rhs" + }, + "Subqueries not supported.": { + "gis_tests.geoapp.tests.GeoLookupTest.test_subquery_annotation", + "gis_tests.geoapp.tests.GeoQuerySetTest.test_within_subquery", + "gis_tests.distapp.tests.DistanceTest.test_dwithin_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..9a42e4d8e 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -1,10 +1,19 @@ -from django.contrib.gis.db.models.lookups import GISLookup +from django.contrib.gis.db.models.lookups import DistanceLookupFromFunction, 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): + 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 e: + raise NotSupportedError(f"MongoDB does not support the '{self.lookup_name}' lookup.") from e + return rhs_op.as_mql(lhs_mql, rhs_mql, self.rhs_params) def register_lookups(): - GISLookup.as_mql = gis_lookup + GISLookup.as_mql = _gis_lookup + DistanceLookupFromFunction.as_mql = _gis_lookup diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index b5d5df1d5..5dcc26454 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -1,11 +1,105 @@ 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 django.db import NotSupportedError +from django.db.backends.base.operations import BaseDatabaseOperations from .adapter import Adapter +from .utils import SpatialOperator -class GISOperations(BaseSpatialOperations): +def _gis_within_operator(field, value, op=None, params=None): # noqa: ARG001 + return { + field: { + "$geoWithin": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + + +def _gis_intersects_operator(field, value, op=None, params=None): # noqa: ARG001 + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + + +def _gis_disjoint_operator(field, value, op=None, params=None): # noqa: ARG001 + return { + field: { + "$not": { + "$geoIntersects": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + } + + +def _gis_contains_operator(field, value, op=None, params=None): # noqa: ARG001 + value_type = value["type"] + if value_type != "Point": + raise NotSupportedError("MongoDB does not support contains on non-Point query geometries.") + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value_type, + "coordinates": value["coordinates"], + } + } + } + } + + +def _gis_distance_operator(field, value, op=None, params=None): + distance = params[0].m if hasattr(params[0], "m") else params[0] + if op == "distance_gt" or op == "distance_gte": + cmd = { + field: { + "$not": { + "$geoWithin": { + "$centerSphere": [ + value["coordinates"], + distance / 6378100, # radius of earth in meters + ], + } + } + } + } + else: + cmd = { + field: { + "$geoWithin": { + "$centerSphere": [ + value["coordinates"], + distance / 6378100, # radius of earth in meters + ], + } + } + } + return cmd + + +def _gis_dwithin_operator(field, value, op=None, params=None): # noqa: ARG001 + return {field: {"$geoWithin": {"$centerSphere": [value["coordinates"], params[0]]}}} + + +class GISOperations(BaseSpatialOperations, BaseDatabaseOperations): Adapter = Adapter disallowed_aggregates = ( @@ -18,7 +112,17 @@ class GISOperations(BaseSpatialOperations): @property def gis_operators(self): - return {} + return { + "contains": SpatialOperator("contains", _gis_contains_operator), + "intersects": SpatialOperator("intersects", _gis_intersects_operator), + "disjoint": SpatialOperator("disjoint", _gis_disjoint_operator), + "within": SpatialOperator("within", _gis_within_operator), + "distance_gt": SpatialOperator("distance_gt", _gis_distance_operator), + "distance_gte": SpatialOperator("distance_gte", _gis_distance_operator), + "distance_lt": SpatialOperator("distance_lt", _gis_distance_operator), + "distance_lte": SpatialOperator("distance_lte", _gis_distance_operator), + "dwithin": SpatialOperator("dwithin", _gis_dwithin_operator), + } unsupported_functions = { "Area", @@ -95,3 +199,15 @@ 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): + if f.geodetic(self.connection): + raise ValueError( + "Only numeric values of degree units are allowed on geodetic distance queries." + ) + dist_param = getattr(value, Distance.unit_attname(f.units_name(self.connection))) + else: + dist_param = value + return [dist_param] diff --git a/django_mongodb_backend/gis/utils.py b/django_mongodb_backend/gis/utils.py new file mode 100644 index 000000000..c24a97fce --- /dev/null +++ b/django_mongodb_backend/gis/utils.py @@ -0,0 +1,19 @@ +""" +A collection of utility routines and classes used by the spatial +backend. +""" + +from django.contrib.gis.db.backends.utils import SpatialOperator as _SpatialOperator + + +class SpatialOperator(_SpatialOperator): + """ + Class encapsulating the behavior specific to a GIS operation (used by lookups). + """ + + def __init__(self, op=None, func=None): + self.op = op + self.func = func + + def as_mql(self, lhs, rhs, params=None): + return self.func(lhs, rhs, self.op, params) diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index 014b8e5e9..c813bf687 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -8,6 +8,51 @@ @skipUnlessDBFeature("gis_enabled") class LookupTests(TestCase): def test_unsupported_lookups(self): - msg = "MongoDB does not support the same_as lookup." + 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_within_lookup(self): + city = City.objects.create(point=Point(95, 30)) + qs = City.objects.filter(point__within=Point(95, 30).buffer(10)) + self.assertIn(city, qs) + + def test_intersects_lookup(self): + city = City.objects.create(point=Point(95, 30)) + qs = City.objects.filter(point__intersects=Point(95, 30).buffer(10)) + self.assertIn(city, qs) + + def test_disjoint_lookup(self): + city = City.objects.create(point=Point(50, 30)) + qs = City.objects.filter(point__disjoint=Point(100, 50)) + self.assertIn(city, qs) + + def test_contains_lookup(self): + city = City.objects.create(point=Point(95, 30)) + qs = City.objects.filter(point__contains=Point(95, 30)) + self.assertIn(city, qs) + + def test_distance_gt_lookup(self): + city = City.objects.create(point=Point(95, 30)) + qs = City.objects.filter(point__distance_gt=(Point(0, 0), 100)) + self.assertIn(city, qs) + + def test_distance_lt_lookup(self): + city = City.objects.create(point=Point(40.7589, -73.9851)) + qs = City.objects.filter(point__distance_lt=(Point(40.7670, -73.9820), 1000)) + self.assertIn(city, qs) + + def test_distance_gte_lookup(self): + city = City.objects.create(point=Point(95, 30)) + qs = City.objects.filter(point__distance_gt=(Point(0, 0), 100)) + self.assertIn(city, qs) + + def test_distance_lte_lookup(self): + city = City.objects.create(point=Point(40.7589, -73.9851)) + qs = City.objects.filter(point__distance_lt=(Point(40.7670, -73.9820), 1000)) + self.assertIn(city, qs) + + def test_dwithin_lookup(self): + city = City.objects.create(point=Point(40.7589, -73.9851)) + qs = City.objects.filter(point__dwithin=(Point(40.7670, -73.9820), 1000)) + self.assertIn(city, qs)