From c97a4fe58e3d0479b27da6bca756e65945ab2771 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Wed, 19 Nov 2025 16:34:46 -0500 Subject: [PATCH 01/14] Initial support --- django_mongodb_backend/compiler.py | 2 + django_mongodb_backend/gis/features.py | 15 ++- django_mongodb_backend/gis/lookups.py | 15 ++- django_mongodb_backend/gis/operations.py | 125 ++++++++++++++++++++++- django_mongodb_backend/gis/utils.py | 19 ++++ 5 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 django_mongodb_backend/gis/utils.py diff --git a/django_mongodb_backend/compiler.py b/django_mongodb_backend/compiler.py index 888362c6b..6198c3f5b 100644 --- a/django_mongodb_backend/compiler.py +++ b/django_mongodb_backend/compiler.py @@ -359,6 +359,7 @@ def execute_sql( except EmptyResultSet: return iter([]) if result_type == MULTI else None + print(f"Query: {query}") cursor = query.get_cursor() if result_type == SINGLE: try: @@ -785,6 +786,7 @@ def explain_query(self): for option in self.connection.ops.explain_options: if value := options.get(option): kwargs[option] = value + print(f"PIPELINE: {pipeline}") explain = self.connection.database.command( "explain", {"aggregate": self.collection_name, "pipeline": pipeline, "cursor": {}}, diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index c82f48435..67de1e29a 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -5,6 +5,9 @@ class GISFeatures(BaseSpatialFeatures): has_spatialrefsys_table = False supports_transform = False + supports_distance_geodetic = False + has_Distance_function = False + has_Union_function = False @cached_property def django_test_expected_failures(self): @@ -39,6 +42,11 @@ 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", + "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_simple", + "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_order_by", }, "ImproperlyConfigured isn't raised when using RasterField": { # Normally RasterField.db_type() raises an error, but MongoDB @@ -49,10 +57,13 @@ 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", }, + "Subqueries not supported.": { + "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", "gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_geometry_field_option", diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py index 8df8ed59c..98aa983bb 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -1,10 +1,15 @@ -from django.contrib.gis.db.models.lookups import GISLookup -from django.db import NotSupportedError +from django.contrib.gis.db.models.lookups import DistanceLookupFromFunction, GISLookup +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) + rhs_op = self.get_rhs_op(connection, rhs_mql) + 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..573ed06e2 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -1,11 +1,110 @@ +import warnings + 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.backends.base.operations import BaseDatabaseOperations from .adapter import Adapter +from .utils import SpatialOperator + + +def _gis_within_operator(field, value, op=None, params=None): + print(f"Within value: {value}") + return { + field: { + "$geoWithin": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + + +def _gis_intersects_operator(field, value, op=None, params=None): + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + + +def _gis_disjoint_operator(field, value, op=None, params=None): + return { + field: { + "$not": { + "$geoIntersects": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + } + +def _gis_contains_operator(field, value, op=None, params=None): + value_type = value["type"] + if value_type != "Point": + warnings.warn( + "MongoDB does not support strict contains on non-Point query geometries. Results will be for intersection." + ) + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value_type, + "coordinates": value["coordinates"], + } + } + } + } -class GISOperations(BaseSpatialOperations): + +def _gis_distance_operator(field, value, op=None, params=None): + print(f"Distance: {params}") + if hasattr(params[0], "m"): + distance = params[0].m + else: + distance = 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 + ], + } + } + } + print(f"Command: {cmd}") + return cmd + + +class GISOperations(BaseSpatialOperations, BaseDatabaseOperations): Adapter = Adapter disallowed_aggregates = ( @@ -18,7 +117,16 @@ 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), + } unsupported_functions = { "Area", @@ -33,7 +141,6 @@ def gis_operators(self): "Centroid", "ClosestPoint", "Difference", - "Distance", "Envelope", "ForcePolygonCW", "FromWKB", @@ -95,3 +202,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) From b3a905fdcd9b169f17e84bc91392da2dff94be2a Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 11:17:32 -0500 Subject: [PATCH 02/14] Try unsupported_functions again --- django_mongodb_backend/compiler.py | 4 ++-- django_mongodb_backend/gis/features.py | 6 ++---- django_mongodb_backend/gis/operations.py | 14 ++++++-------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/django_mongodb_backend/compiler.py b/django_mongodb_backend/compiler.py index 6198c3f5b..f8f37f809 100644 --- a/django_mongodb_backend/compiler.py +++ b/django_mongodb_backend/compiler.py @@ -359,7 +359,7 @@ def execute_sql( except EmptyResultSet: return iter([]) if result_type == MULTI else None - print(f"Query: {query}") + # print(f"Query: {query}") cursor = query.get_cursor() if result_type == SINGLE: try: @@ -786,7 +786,7 @@ def explain_query(self): for option in self.connection.ops.explain_options: if value := options.get(option): kwargs[option] = value - print(f"PIPELINE: {pipeline}") + # print(f"PIPELINE: {pipeline}") explain = self.connection.database.command( "explain", {"aggregate": self.collection_name, "pipeline": pipeline, "cursor": {}}, diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index 67de1e29a..adaf6c75b 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -6,8 +6,6 @@ class GISFeatures(BaseSpatialFeatures): has_spatialrefsys_table = False supports_transform = False supports_distance_geodetic = False - has_Distance_function = False - has_Union_function = False @cached_property def django_test_expected_failures(self): @@ -45,8 +43,8 @@ def django_test_skips(self): "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", - "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_simple", - "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_order_by", + # "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_simple", + # "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_order_by", }, "ImproperlyConfigured isn't raised when using RasterField": { # Normally RasterField.db_type() raises an error, but MongoDB diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index 573ed06e2..b7ce39f07 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -1,9 +1,8 @@ -import warnings - 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 @@ -11,7 +10,7 @@ def _gis_within_operator(field, value, op=None, params=None): - print(f"Within value: {value}") + # print(f"Within value: {value}") return { field: { "$geoWithin": { @@ -55,9 +54,7 @@ def _gis_disjoint_operator(field, value, op=None, params=None): def _gis_contains_operator(field, value, op=None, params=None): value_type = value["type"] if value_type != "Point": - warnings.warn( - "MongoDB does not support strict contains on non-Point query geometries. Results will be for intersection." - ) + raise NotSupportedError("MongoDB does not support contains on non-Point query geometries.") return { field: { "$geoIntersects": { @@ -71,7 +68,7 @@ def _gis_contains_operator(field, value, op=None, params=None): def _gis_distance_operator(field, value, op=None, params=None): - print(f"Distance: {params}") + # print(f"Distance: {params}") if hasattr(params[0], "m"): distance = params[0].m else: @@ -100,7 +97,7 @@ def _gis_distance_operator(field, value, op=None, params=None): } } } - print(f"Command: {cmd}") + # print(f"Command: {cmd}") return cmd @@ -140,6 +137,7 @@ def gis_operators(self): "BoundingCircle", "Centroid", "ClosestPoint", + "Distance", "Difference", "Envelope", "ForcePolygonCW", From e56bf17b519ebae8a6634b1c4b3ed502a574769b Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 11:55:02 -0500 Subject: [PATCH 03/14] Fix error handling for unsupported lookups --- django_mongodb_backend/gis/lookups.py | 6 +++++- tests/gis_tests_/tests.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py index 98aa983bb..dd38f8e07 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -1,4 +1,5 @@ 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 @@ -6,7 +7,10 @@ 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) - rhs_op = self.get_rhs_op(connection, rhs_mql) + try: + rhs_op = self.get_rhs_op(connection, rhs_mql) + except KeyError: + raise NotSupportedError(f"MongoDB does not support the '{self.lookup_name}' lookup.") return rhs_op.as_mql(lhs_mql, rhs_mql, self.rhs_params) diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index 014b8e5e9..3b9bb4130 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -8,6 +8,6 @@ @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)) From 38ed40a437bed06531f0367aaf5da5abe2613a29 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 13:32:31 -0500 Subject: [PATCH 04/14] Linting --- django_mongodb_backend/compiler.py | 2 -- django_mongodb_backend/gis/features.py | 2 -- django_mongodb_backend/gis/lookups.py | 4 ++-- django_mongodb_backend/gis/operations.py | 16 +++++----------- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/django_mongodb_backend/compiler.py b/django_mongodb_backend/compiler.py index f8f37f809..888362c6b 100644 --- a/django_mongodb_backend/compiler.py +++ b/django_mongodb_backend/compiler.py @@ -359,7 +359,6 @@ def execute_sql( except EmptyResultSet: return iter([]) if result_type == MULTI else None - # print(f"Query: {query}") cursor = query.get_cursor() if result_type == SINGLE: try: @@ -786,7 +785,6 @@ def explain_query(self): for option in self.connection.ops.explain_options: if value := options.get(option): kwargs[option] = value - # print(f"PIPELINE: {pipeline}") explain = self.connection.database.command( "explain", {"aggregate": self.collection_name, "pipeline": pipeline, "cursor": {}}, diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index adaf6c75b..3a23bcf0d 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -43,8 +43,6 @@ def django_test_skips(self): "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", - # "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_simple", - # "gis_tests.distapp.tests.DistanceFunctionsTests.test_distance_order_by", }, "ImproperlyConfigured isn't raised when using RasterField": { # Normally RasterField.db_type() raises an error, but MongoDB diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py index dd38f8e07..9a42e4d8e 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -9,8 +9,8 @@ def _gis_lookup(self, compiler, connection, as_expr=False): rhs_mql = process_rhs(self, compiler, connection, as_expr=as_expr) try: rhs_op = self.get_rhs_op(connection, rhs_mql) - except KeyError: - raise NotSupportedError(f"MongoDB does not support the '{self.lookup_name}' lookup.") + 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) diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index b7ce39f07..e79c4d28f 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -9,8 +9,7 @@ from .utils import SpatialOperator -def _gis_within_operator(field, value, op=None, params=None): - # print(f"Within value: {value}") +def _gis_within_operator(field, value, op=None, params=None): # noqa: ARG001 return { field: { "$geoWithin": { @@ -23,7 +22,7 @@ def _gis_within_operator(field, value, op=None, params=None): } -def _gis_intersects_operator(field, value, op=None, params=None): +def _gis_intersects_operator(field, value, op=None, params=None): # noqa: ARG001 return { field: { "$geoIntersects": { @@ -36,7 +35,7 @@ def _gis_intersects_operator(field, value, op=None, params=None): } -def _gis_disjoint_operator(field, value, op=None, params=None): +def _gis_disjoint_operator(field, value, op=None, params=None): # noqa: ARG001 return { field: { "$not": { @@ -51,7 +50,7 @@ def _gis_disjoint_operator(field, value, op=None, params=None): } -def _gis_contains_operator(field, value, op=None, params=None): +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.") @@ -68,11 +67,7 @@ def _gis_contains_operator(field, value, op=None, params=None): def _gis_distance_operator(field, value, op=None, params=None): - # print(f"Distance: {params}") - if hasattr(params[0], "m"): - distance = params[0].m - else: - distance = params[0] + distance = params[0].m if hasattr(params[0], "m") else params[0] if op == "distance_gt" or op == "distance_gte": cmd = { field: { @@ -97,7 +92,6 @@ def _gis_distance_operator(field, value, op=None, params=None): } } } - # print(f"Command: {cmd}") return cmd From 462a54dd6f7c84f0754f9734ad9c2afed7efa5ed Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 13:33:31 -0500 Subject: [PATCH 05/14] Avoid code churn --- django_mongodb_backend/gis/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index e79c4d28f..5a71bf497 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -131,8 +131,8 @@ def gis_operators(self): "BoundingCircle", "Centroid", "ClosestPoint", - "Distance", "Difference", + "Distance", "Envelope", "ForcePolygonCW", "FromWKB", From 86b1e756289eab6b206ae32fcbdb72964dca7751 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 15:21:14 -0500 Subject: [PATCH 06/14] Add tests for lookups --- tests/gis_tests_/tests.py | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index 3b9bb4130..4611c15d0 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -11,3 +11,43 @@ def test_unsupported_lookups(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_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) From f4b724fa12662fd83854f99f97f798ae2442d7b1 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 15:46:18 -0500 Subject: [PATCH 07/14] Add dwithin --- django_mongodb_backend/gis/operations.py | 5 +++++ tests/gis_tests_/tests.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index 5a71bf497..5dcc26454 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -95,6 +95,10 @@ def _gis_distance_operator(field, value, op=None, params=None): 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 @@ -117,6 +121,7 @@ def gis_operators(self): "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 = { diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index 4611c15d0..c813bf687 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -51,3 +51,8 @@ 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) From 5ad9f60255e43a341ce3bfd270ad2c51e436206f Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Thu, 20 Nov 2025 16:25:40 -0500 Subject: [PATCH 08/14] Skip dwithin tests we don't support --- django_mongodb_backend/gis/features.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index 3a23bcf0d..ba66af2d6 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -55,10 +55,15 @@ def django_test_skips(self): }, "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", From fc3bf2c6f46a4873a647a63e63014c38c0f31194 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Mon, 24 Nov 2025 11:06:26 -0500 Subject: [PATCH 09/14] Address Tim review --- django_mongodb_backend/gis/features.py | 2 +- django_mongodb_backend/gis/lookups.py | 6 +- django_mongodb_backend/gis/operations.py | 2 +- tests/gis_tests_/fixtures/initial.json | 98 +++++++++++++++++++++ tests/gis_tests_/models.py | 6 ++ tests/gis_tests_/tests.py | 104 +++++++++++++++-------- 6 files changed, 176 insertions(+), 42 deletions(-) create mode 100644 tests/gis_tests_/fixtures/initial.json diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py index ba66af2d6..72e7d4410 100644 --- a/django_mongodb_backend/gis/features.py +++ b/django_mongodb_backend/gis/features.py @@ -4,8 +4,8 @@ class GISFeatures(BaseSpatialFeatures): has_spatialrefsys_table = False - supports_transform = False supports_distance_geodetic = False + supports_transform = False @cached_property def django_test_expected_failures(self): diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py index 9a42e4d8e..bfe7da70a 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -4,7 +4,7 @@ from django_mongodb_backend.query_utils import process_lhs, process_rhs -def _gis_lookup(self, compiler, connection, as_expr=False): +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: @@ -15,5 +15,5 @@ def _gis_lookup(self, compiler, connection, as_expr=False): def register_lookups(): - GISLookup.as_mql = _gis_lookup - DistanceLookupFromFunction.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 5dcc26454..e271c8fa7 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -205,7 +205,7 @@ def get_distance(self, f, value, lookup_type): if isinstance(value, Distance): if f.geodetic(self.connection): raise ValueError( - "Only numeric values of degree units are allowed on geodetic distance queries." + "Only numeric values of radian units are allowed on geodetic distance queries." ) dist_param = getattr(value, Distance.unit_attname(f.units_name(self.connection))) else: 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..dfea35764 100644 --- a/tests/gis_tests_/models.py +++ b/tests/gis_tests_/models.py @@ -2,4 +2,10 @@ class City(models.Model): + name = models.CharField(max_length=30) point = models.PointField() + + +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 c813bf687..f032556ed 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -1,58 +1,88 @@ from django.contrib.gis.geos import Point +from django.contrib.gis.measure import Distance from django.db import NotSupportedError from django.test import TestCase, skipUnlessDBFeature -from .models import City +from .models import City, Zipcode @skipUnlessDBFeature("gis_enabled") class LookupTests(TestCase): - def test_unsupported_lookups(self): + fixtures = ["initial"] + + 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_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_contains(self): + houston = City.objects.get(name="Houston") + qs = City.objects.filter(point__contains=Point(-95.363151, 29.763374)) + self.assertEqual(qs.count(), 1) + self.assertEqual(houston, qs.first()) - 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)) + def test_disjoint(self): + houston = City.objects.get(name="Houston") qs = City.objects.filter(point__disjoint=Point(100, 50)) - self.assertIn(city, qs) + self.assertIn(houston, 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(self): + houston = City.objects.get(name="Houston") + dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston + qs = City.objects.filter(point__distance_gt=(houston.point, 362826)) + self.assertEqual(qs.count(), 6) + self.assertNotIn(dallas, list(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_gte(self): + houston = City.objects.get(name="Houston") + dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston + qs = City.objects.filter(point__distance_gte=(houston.point, 362825)) + self.assertEqual(qs.count(), 7) + self.assertIn(dallas, list(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_lt(self): + houston = City.objects.get(name="Houston") + qs = City.objects.filter(point__distance_lt=(houston.point, 362825)) + self.assertEqual(qs.count(), 1) + self.assertEqual(houston, qs.first()) - 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(self): + houston = City.objects.get(name="Houston") + dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston + qs = City.objects.filter(point__distance_lte=(houston.point, 362826)) + self.assertEqual(qs.count(), 2) + self.assertEqual([houston, dallas], list(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_distance_units(self): + chicago = City.objects.get(name="Chicago") + lawrence = City.objects.get(name="Lawrence") + qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(km=720))) + self.assertEqual(qs.count(), 2) + self.assertEqual([lawrence, chicago], list(qs)) + qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(mi=447))) + self.assertEqual(qs.count(), 2) + self.assertEqual([lawrence, chicago], list(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)) + def test_dwithin(self): + houston = City.objects.get(name="Houston") + qs = City.objects.filter(point__dwithin=(houston.point, 0.2)) + self.assertEqual(qs.count(), 5) + + def test_dwithin_unsupported_units(self): + qs = City.objects.filter(point__dwithin=(Point(40.7670, -73.9820), Distance(km=1))) + with self.assertRaisesMessage( + ValueError, + "Only numeric values of radian units are allowed on geodetic distance queries.", + ): + qs.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.assertEqual(qs.count(), 1) self.assertIn(city, qs) + + def test_within(self): + zipcode = Zipcode.objects.get(code="77002") + qs = City.objects.filter(point__within=zipcode.poly).values_list("name", flat=True) + self.assertEqual(["Houston"], list(qs)) From a37977d4e21d9360030485d0f6d8f7d78b4559d7 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Mon, 1 Dec 2025 08:50:43 -0500 Subject: [PATCH 10/14] Address review --- django_mongodb_backend/gis/lookups.py | 2 ++ tests/gis_tests_/models.py | 3 +++ tests/gis_tests_/tests.py | 23 ++++++++--------------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py index bfe7da70a..1a9c2a372 100644 --- a/django_mongodb_backend/gis/lookups.py +++ b/django_mongodb_backend/gis/lookups.py @@ -5,6 +5,8 @@ def gis_lookup(self, compiler, connection, as_expr=False): + if as_expr: + raise NotSupportedError("MongoDB does not support GIS lookups as expressions.") lhs_mql = process_lhs(self, compiler, connection, as_expr=as_expr) rhs_mql = process_rhs(self, compiler, connection, as_expr=as_expr) try: diff --git a/tests/gis_tests_/models.py b/tests/gis_tests_/models.py index dfea35764..627bf7c5f 100644 --- a/tests/gis_tests_/models.py +++ b/tests/gis_tests_/models.py @@ -5,6 +5,9 @@ class City(models.Model): name = models.CharField(max_length=30) point = models.PointField() + def __str__(self): + return self.name + class Zipcode(models.Model): code = models.CharField(max_length=10) diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index f032556ed..aa61d9fa6 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -18,8 +18,7 @@ def test_unsupported(self): def test_contains(self): houston = City.objects.get(name="Houston") qs = City.objects.filter(point__contains=Point(-95.363151, 29.763374)) - self.assertEqual(qs.count(), 1) - self.assertEqual(houston, qs.first()) + self.assertCountEqual(qs, [houston]) def test_disjoint(self): houston = City.objects.get(name="Houston") @@ -43,25 +42,21 @@ def test_distance_gte(self): def test_distance_lt(self): houston = City.objects.get(name="Houston") qs = City.objects.filter(point__distance_lt=(houston.point, 362825)) - self.assertEqual(qs.count(), 1) - self.assertEqual(houston, qs.first()) + self.assertCountEqual(qs, [houston]) def test_distance_lte(self): houston = City.objects.get(name="Houston") dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston qs = City.objects.filter(point__distance_lte=(houston.point, 362826)) - self.assertEqual(qs.count(), 2) - self.assertEqual([houston, dallas], list(qs)) + self.assertCountEqual(list(qs), [houston, dallas]) def test_distance_units(self): chicago = City.objects.get(name="Chicago") lawrence = City.objects.get(name="Lawrence") qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(km=720))) - self.assertEqual(qs.count(), 2) - self.assertEqual([lawrence, chicago], list(qs)) + self.assertCountEqual(list(qs), [lawrence, chicago]) qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(mi=447))) - self.assertEqual(qs.count(), 2) - self.assertEqual([lawrence, chicago], list(qs)) + self.assertCountEqual(list(qs), [lawrence, chicago]) def test_dwithin(self): houston = City.objects.get(name="Houston") @@ -70,10 +65,8 @@ def test_dwithin(self): def test_dwithin_unsupported_units(self): qs = City.objects.filter(point__dwithin=(Point(40.7670, -73.9820), Distance(km=1))) - with self.assertRaisesMessage( - ValueError, - "Only numeric values of radian units are allowed on geodetic distance queries.", - ): + message = "Only numeric values of radian units are allowed on geodetic distance queries." + with self.assertRaisesMessage(ValueError, message): qs.first() def test_intersects(self): @@ -85,4 +78,4 @@ def test_intersects(self): def test_within(self): zipcode = Zipcode.objects.get(code="77002") qs = City.objects.filter(point__within=zipcode.poly).values_list("name", flat=True) - self.assertEqual(["Houston"], list(qs)) + self.assertEqual(list(qs), ["Houston"]) From fa78fb181864e2d3dc5d51acd9334d5684aa582c Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Mon, 1 Dec 2025 09:17:10 -0500 Subject: [PATCH 11/14] Replace SpatialOperator with simpler Operator class --- django_mongodb_backend/gis/operations.py | 127 ++++----------------- django_mongodb_backend/gis/operators.py | 134 +++++++++++++++++++++++ django_mongodb_backend/gis/utils.py | 19 ---- 3 files changed, 158 insertions(+), 122 deletions(-) create mode 100644 django_mongodb_backend/gis/operators.py delete mode 100644 django_mongodb_backend/gis/utils.py diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index e271c8fa7..a9a4b13f0 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -2,101 +2,20 @@ 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 - - -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]]}}} +from .operators import ( + Contains, + Disjoint, + DistanceGT, + DistanceGTE, + DistanceLT, + DistanceLTE, + DWithin, + Intersects, + Within, +) class GISOperations(BaseSpatialOperations, BaseDatabaseOperations): @@ -110,19 +29,21 @@ class GISOperations(BaseSpatialOperations, BaseDatabaseOperations): models.Union, ) + operators = { + "contains": Contains(), + "intersects": Intersects(), + "disjoint": Disjoint(), + "within": Within(), + "distance_gt": DistanceGT(), + "distance_gte": DistanceGTE(), + "distance_lt": DistanceLT(), + "distance_lte": DistanceLTE(), + "dwithin": DWithin(), + } + @property def gis_operators(self): - 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), - } + return self.operators unsupported_functions = { "Area", diff --git a/django_mongodb_backend/gis/operators.py b/django_mongodb_backend/gis/operators.py new file mode 100644 index 000000000..c9e9dcd6a --- /dev/null +++ b/django_mongodb_backend/gis/operators.py @@ -0,0 +1,134 @@ +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 Within(Operator): + name = "within" + + def as_mql(self, field, value, params=None): + return { + field: { + "$geoWithin": { + "$geometry": { + "type": value["type"], + "coordinates": value["coordinates"], + } + } + } + } + + +class Intersects(Operator): + name = "intersects" + + def as_mql(self, field, value, params=None): + 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 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 query geometries." + ) + return { + field: { + "$geoIntersects": { + "$geometry": { + "type": value_type, + "coordinates": value["coordinates"], + } + } + } + } + + +class DistanceBase(Operator): + name = "distance_base" + + def as_mql(self, field, value, params=None): + distance = params[0].m if hasattr(params[0], "m") else params[0] + if self.name == "distance_gt" or self.name == "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 + + +class DistanceGT(DistanceBase): + name = "distance_gt" + + +class DistanceGTE(DistanceBase): + name = "distance_gte" + + +class DistanceLT(DistanceBase): + name = "distance_lt" + + +class DistanceLTE(DistanceBase): + name = "distance_lte" + + +class DWithin(Operator): + name = "dwithin" + + def as_mql(self, field, value, params=None): + return {field: {"$geoWithin": {"$centerSphere": [value["coordinates"], params[0]]}}} diff --git a/django_mongodb_backend/gis/utils.py b/django_mongodb_backend/gis/utils.py deleted file mode 100644 index c24a97fce..000000000 --- a/django_mongodb_backend/gis/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -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) From 9c4d119df12d56ee6a682b8d203ab12110b90ef4 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Mon, 1 Dec 2025 09:20:49 -0500 Subject: [PATCH 12/14] Update docs --- docs/ref/contrib/gis.rst | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/ref/contrib/gis.rst b/docs/ref/contrib/gis.rst index c329759af..4eb46c32c 100644 --- a/docs/ref/contrib/gis.rst +++ b/docs/ref/contrib/gis.rst @@ -18,7 +18,18 @@ Each model field stores data as :doc:`GeoJSON objects All fields have a :doc:`2dsphere index ` created on them. -You can use any of the :ref:`geospatial query operators +The following :doc:`GIS QuerySet APIs ` are supported: +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.contains` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.intersects` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.disjoint` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.within` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.distance_gt` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.distance_gte` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.distance_lt` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.distance_lte` +- :meth:`~django.contrib.gis.db.models.query.GeoQuerySet.dwithin` + +You can also use any of the :ref:`geospatial query operators ` or the :ref:`geospatial aggregation pipeline stage ` in :meth:`.raw_aggregate` queries. @@ -39,6 +50,4 @@ Limitations (:attr:`BaseSpatialField.srid `) besides `4326 (WGS84) `_. -- None of the :doc:`GIS QuerySet APIs ` - (lookups, aggregates, and database functions) are supported. - :class:`~django.contrib.gis.db.models.RasterField` isn't supported. From beba412cc21c061dca0eda56a5052d4c5cfd2b52 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Tue, 2 Dec 2025 08:14:36 -0500 Subject: [PATCH 13/14] Remove inheritance of BaseDatabaseOperations in GISOperations --- django_mongodb_backend/gis/operations.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index a9a4b13f0..b749fc751 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -2,7 +2,6 @@ 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.backends.base.operations import BaseDatabaseOperations from .adapter import Adapter from .operators import ( @@ -18,7 +17,7 @@ ) -class GISOperations(BaseSpatialOperations, BaseDatabaseOperations): +class GISOperations(BaseSpatialOperations): Adapter = Adapter disallowed_aggregates = ( From 51569fcf5d22d4804c0f3e4000331988f03ee6c9 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Fri, 5 Dec 2025 09:26:29 -0500 Subject: [PATCH 14/14] Address review --- django_mongodb_backend/gis/operations.py | 10 +-- django_mongodb_backend/gis/operators.py | 80 ++++++++--------- docs/ref/contrib/gis.rst | 1 + tests/gis_tests_/tests.py | 110 ++++++++++++++++------- 4 files changed, 121 insertions(+), 80 deletions(-) diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py index b749fc751..d4fa80044 100644 --- a/django_mongodb_backend/gis/operations.py +++ b/django_mongodb_backend/gis/operations.py @@ -28,22 +28,18 @@ class GISOperations(BaseSpatialOperations): models.Union, ) - operators = { + gis_operators = { "contains": Contains(), - "intersects": Intersects(), "disjoint": Disjoint(), - "within": Within(), "distance_gt": DistanceGT(), "distance_gte": DistanceGTE(), "distance_lt": DistanceLT(), "distance_lte": DistanceLTE(), "dwithin": DWithin(), + "intersects": Intersects(), + "within": Within(), } - @property - def gis_operators(self): - return self.operators - unsupported_functions = { "Area", "AsGeoJSON", diff --git a/django_mongodb_backend/gis/operators.py b/django_mongodb_backend/gis/operators.py index c9e9dcd6a..cc24473d7 100644 --- a/django_mongodb_backend/gis/operators.py +++ b/django_mongodb_backend/gis/operators.py @@ -8,31 +8,20 @@ def as_sql(self, connection, lookup, template_params, sql_params): return self.name, [] -class Within(Operator): - name = "within" - - def as_mql(self, field, value, params=None): - return { - field: { - "$geoWithin": { - "$geometry": { - "type": value["type"], - "coordinates": value["coordinates"], - } - } - } - } - - -class Intersects(Operator): - name = "intersects" +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 query geometries." + ) return { field: { "$geoIntersects": { "$geometry": { - "type": value["type"], + "type": value_type, "coordinates": value["coordinates"], } } @@ -58,27 +47,6 @@ def as_mql(self, field, value, params=None): } -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 query geometries." - ) - return { - field: { - "$geoIntersects": { - "$geometry": { - "type": value_type, - "coordinates": value["coordinates"], - } - } - } - } - - class DistanceBase(Operator): name = "distance_base" @@ -132,3 +100,35 @@ class DWithin(Operator): def as_mql(self, field, value, params=None): return {field: {"$geoWithin": {"$centerSphere": [value["coordinates"], params[0]]}}} + + +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 4eb46c32c..e2e2a19b3 100644 --- a/docs/ref/contrib/gis.rst +++ b/docs/ref/contrib/gis.rst @@ -50,4 +50,5 @@ Limitations (:attr:`BaseSpatialField.srid `) besides `4326 (WGS84) `_. +- QuerySet APIs do not support subqueries or expressions. - :class:`~django.contrib.gis.db.models.RasterField` isn't supported. diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py index aa61d9fa6..8b1e03a7d 100644 --- a/tests/gis_tests_/tests.py +++ b/tests/gis_tests_/tests.py @@ -1,6 +1,7 @@ -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, Value, When from django.test import TestCase, skipUnlessDBFeature from .models import City, Zipcode @@ -10,34 +11,45 @@ class LookupTests(TestCase): fixtures = ["initial"] - 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_contains(self): - houston = City.objects.get(name="Houston") - qs = City.objects.filter(point__contains=Point(-95.363151, 29.763374)) - self.assertCountEqual(qs, [houston]) + 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): + qs = City.objects.filter(point__contains=LineString((0, 0), (1, 1))) + message = "MongoDB does not support contains on non-Point query geometries." + with self.assertRaisesMessage(NotSupportedError, message): + qs.first() def test_disjoint(self): - houston = City.objects.get(name="Houston") - qs = City.objects.filter(point__disjoint=Point(100, 50)) - self.assertIn(houston, qs) + qs = City.objects.filter(point__disjoint=Point(100, 50)).values_list("name", flat=True) + self.assertIn("Houston", qs) def test_distance_gt(self): houston = City.objects.get(name="Houston") - dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston - qs = City.objects.filter(point__distance_gt=(houston.point, 362826)) - self.assertEqual(qs.count(), 6) - self.assertNotIn(dallas, list(qs)) + expected = ["Oklahoma City", "Wellington", "Pueblo", "Lawrence", "Chicago", "Victoria"] + qs = City.objects.filter(point__distance_gt=(houston.point, 362826)).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, expected) def test_distance_gte(self): houston = City.objects.get(name="Houston") - dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston - qs = City.objects.filter(point__distance_gte=(houston.point, 362825)) - self.assertEqual(qs.count(), 7) - self.assertIn(dallas, list(qs)) + expected = [ + "Dallas", + "Oklahoma City", + "Wellington", + "Pueblo", + "Lawrence", + "Chicago", + "Victoria", + ] + qs = City.objects.filter(point__distance_gte=(houston.point, 362825)).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, expected) def test_distance_lt(self): houston = City.objects.get(name="Houston") @@ -46,22 +58,27 @@ def test_distance_lt(self): def test_distance_lte(self): houston = City.objects.get(name="Houston") - dallas = City.objects.get(name="Dallas") # Roughly ~363 km from Houston - qs = City.objects.filter(point__distance_lte=(houston.point, 362826)) - self.assertCountEqual(list(qs), [houston, dallas]) + 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") - lawrence = City.objects.get(name="Lawrence") - qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(km=720))) - self.assertCountEqual(list(qs), [lawrence, chicago]) - qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(mi=447))) - self.assertCountEqual(list(qs), [lawrence, chicago]) + qs = City.objects.filter(point__distance_lt=(chicago.point, Distance(km=720))).values_list( + "name", flat=True + ) + self.assertCountEqual(qs, ["Lawrence", "Chicago"]) + qs = City.objects.filter(point__distance_lt=(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, 0.2)) - self.assertEqual(qs.count(), 5) + expected = ["Houston", "Dallas", "Pueblo", "Oklahoma City", "Lawrence"] + qs = City.objects.filter(point__dwithin=(houston.point, 0.2)).values_list("name", flat=True) + self.assertCountEqual(qs, expected) def test_dwithin_unsupported_units(self): qs = City.objects.filter(point__dwithin=(Point(40.7670, -73.9820), Distance(km=1))) @@ -72,10 +89,37 @@ def test_dwithin_unsupported_units(self): def test_intersects(self): city = City.objects.create(point=Point(95, 30)) qs = City.objects.filter(point__intersects=Point(95, 30).buffer(10)) - self.assertEqual(qs.count(), 1) - self.assertIn(city, qs) + 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.assertEqual(list(qs), ["Houston"]) + 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_unsupported_expr(self): + downtown_area = Polygon( + ( + (-122.4194, 37.7749), + (-122.4194, 37.8049), + (-122.3894, 37.8049), + (-122.3894, 37.7749), + (-122.4194, 37.7749), + ) + ) + + qs = City.objects.annotate( + area_type=Case( + When(point__within=downtown_area, then=Value("Downtown")), + default=Value("Other"), + output_field=CharField(), + ) + ) + + message = "MongoDB does not support GIS lookups as expressions." + with self.assertRaisesMessage(NotSupportedError, message): + qs.first()