Skip to content

Commit 5bc08fa

Browse files
NoahStapptimgraham
authored andcommitted
INTPYTHON-835 Add support for GIS lookups
1 parent 44cde1e commit 5bc08fa

File tree

9 files changed

+455
-17
lines changed

9 files changed

+455
-17
lines changed

django_mongodb_backend/gis/features.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
class GISFeatures(BaseSpatialFeatures):
66
has_spatialrefsys_table = False
7+
supports_distance_geodetic = False
8+
supports_dwithin_distance_expr = False
79
supports_transform = False
810

911
@cached_property
@@ -45,9 +47,9 @@ def django_test_skips(self):
4547
# migrations don't need to call it, so the check doesn't happen.
4648
"gis_tests.gis_migrations.test_operations.NoRasterSupportTests",
4749
},
48-
"GIS lookups not supported.": {
49-
"gis_tests.geoapp.tests.GeoModelTest.test_gis_query_as_string",
50-
"gis_tests.geoapp.tests.GeoLookupTest.test_gis_lookups_with_complex_expressions",
50+
"Expressions in GIS lookups not supported.": {
51+
"gis_tests.geoapp.tests.GeoLookupTest.test_subquery_annotation",
52+
"gis_tests.geoapp.tests.GeoQuerySetTest.test_within_subquery",
5153
},
5254
"GeoJSONSerializer doesn't support ObjectId.": {
5355
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_fields_option",
Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1-
from django.contrib.gis.db.models.lookups import GISLookup
1+
from django.contrib.gis.db.models.lookups import DistanceLookupFromFunction, GISLookup
22
from django.db import NotSupportedError
33

4+
from django_mongodb_backend.query_utils import process_lhs, process_rhs
45

5-
def gis_lookup(self, compiler, connection, as_expr=False): # noqa: ARG001
6-
raise NotSupportedError(f"MongoDB does not support the {self.lookup_name} lookup.")
6+
7+
def gis_lookup(self, compiler, connection, as_expr=False):
8+
if as_expr or not self.can_use_path:
9+
raise NotSupportedError("MongoDB does not support GIS lookups in expressions.")
10+
lhs_mql = process_lhs(self, compiler, connection, as_expr=as_expr)
11+
rhs_mql = process_rhs(self, compiler, connection, as_expr=as_expr)
12+
try:
13+
rhs_op = self.get_rhs_op(connection, rhs_mql)
14+
except KeyError as exc:
15+
raise NotSupportedError(
16+
f"MongoDB does not support the '{self.lookup_name}' lookup."
17+
) from exc
18+
return rhs_op.as_mql(lhs_mql, rhs_mql, self.rhs_params)
719

820

921
def register_lookups():
1022
GISLookup.as_mql = gis_lookup
23+
DistanceLookupFromFunction.as_mql = gis_lookup

django_mongodb_backend/gis/operations.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
from django.contrib.gis import geos
22
from django.contrib.gis.db import models
33
from django.contrib.gis.db.backends.base.operations import BaseSpatialOperations
4+
from django.contrib.gis.measure import Distance
45

56
from .adapter import Adapter
7+
from .operators import (
8+
Contains,
9+
Disjoint,
10+
DistanceGTE,
11+
DistanceLTE,
12+
DWithin,
13+
Intersects,
14+
Within,
15+
)
616

717

818
class GISOperations(BaseSpatialOperations):
@@ -16,9 +26,15 @@ class GISOperations(BaseSpatialOperations):
1626
models.Union,
1727
)
1828

19-
@property
20-
def gis_operators(self):
21-
return {}
29+
gis_operators = {
30+
"contains": Contains(),
31+
"disjoint": Disjoint(),
32+
"distance_gte": DistanceGTE(),
33+
"distance_lte": DistanceLTE(),
34+
"dwithin": DWithin(),
35+
"intersects": Intersects(),
36+
"within": Within(),
37+
}
2238

2339
unsupported_functions = {
2440
"Area",
@@ -97,3 +113,15 @@ def converter(value, expression, connection): # noqa: ARG001
97113
return geom_class(*value["coordinates"], srid=srid)
98114

99115
return converter
116+
117+
def get_distance(self, f, value, lookup_type):
118+
value = value[0]
119+
if isinstance(value, Distance):
120+
if f.geodetic(self.connection):
121+
raise ValueError(
122+
"Only numeric values of radian units are allowed on geodetic distance queries."
123+
)
124+
dist_param = getattr(value, Distance.unit_attname(f.units_name(self.connection)))
125+
else:
126+
dist_param = value
127+
return [dist_param]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from django.db import NotSupportedError
2+
3+
4+
class Operator:
5+
def as_sql(self, connection, lookup, template_params, sql_params):
6+
# Return some dummy value to prevent str(queryset.query) from crashing.
7+
# The output of as_sql() is meaningless for this no-SQL backend.
8+
return self.name, []
9+
10+
11+
class Contains(Operator):
12+
name = "contains"
13+
14+
def as_mql(self, field, value, params=None):
15+
value_type = value["type"]
16+
if value_type != "Point":
17+
raise NotSupportedError(
18+
"MongoDB does not support contains on non-Point lookup geometries."
19+
)
20+
return {
21+
field: {
22+
"$geoIntersects": {
23+
"$geometry": {
24+
"type": value_type,
25+
"coordinates": value["coordinates"],
26+
}
27+
}
28+
}
29+
}
30+
31+
32+
class Disjoint(Operator):
33+
name = "disjoint"
34+
35+
def as_mql(self, field, value, params=None):
36+
return {
37+
field: {
38+
"$not": {
39+
"$geoIntersects": {
40+
"$geometry": {
41+
"type": value["type"],
42+
"coordinates": value["coordinates"],
43+
}
44+
}
45+
}
46+
}
47+
}
48+
49+
50+
class DistanceGTE(Operator):
51+
name = "distance_gte"
52+
53+
def as_mql(self, field, value, params=None):
54+
distance = params[0].m if hasattr(params[0], "m") else params[0]
55+
return {
56+
field: {
57+
"$not": {
58+
"$geoWithin": {
59+
"$centerSphere": [
60+
value["coordinates"],
61+
distance / 6378100, # radius of earth in meters
62+
],
63+
}
64+
}
65+
}
66+
}
67+
68+
69+
class DistanceLTE(Operator):
70+
name = "distance_lte"
71+
72+
def as_mql(self, field, value, params=None):
73+
distance = params[0].m if hasattr(params[0], "m") else params[0]
74+
return {
75+
field: {
76+
"$geoWithin": {
77+
"$centerSphere": [
78+
value["coordinates"],
79+
distance / 6378100, # radius of earth in meters
80+
],
81+
}
82+
}
83+
}
84+
85+
86+
class DWithin(Operator):
87+
name = "dwithin"
88+
89+
def as_mql(self, field, value, params=None):
90+
return {field: {"$geoWithin": {"$centerSphere": [value["coordinates"], params[0]]}}}
91+
92+
93+
class Intersects(Operator):
94+
name = "intersects"
95+
96+
def as_mql(self, field, value, params=None):
97+
return {
98+
field: {
99+
"$geoIntersects": {
100+
"$geometry": {
101+
"type": value["type"],
102+
"coordinates": value["coordinates"],
103+
}
104+
}
105+
}
106+
}
107+
108+
109+
class Within(Operator):
110+
name = "within"
111+
112+
def as_mql(self, field, value, params=None):
113+
return {
114+
field: {
115+
"$geoWithin": {
116+
"$geometry": {
117+
"type": value["type"],
118+
"coordinates": value["coordinates"],
119+
}
120+
}
121+
}
122+
}

docs/ref/contrib/gis.rst

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ GeoDjango
44

55
Django MongoDB Backend supports :doc:`GeoDjango<django:ref/contrib/gis/index>`.
66

7+
Spatial fields
8+
==============
9+
710
Each model field stores data as :doc:`GeoJSON objects
811
<manual:reference/geojson>`.
912

@@ -18,6 +21,27 @@ Each model field stores data as :doc:`GeoJSON objects
1821
All fields have a :doc:`2dsphere index
1922
<manual:core/indexes/index-types/geospatial/2dsphere>` created on them.
2023

24+
Spatial lookups
25+
===============
26+
27+
.. versionadded:: 6.0.1
28+
29+
The following :ref:`spatial lookups <django:spatial-lookups>` are supported:
30+
31+
- :lookup:`contains <gis-contains>` (the lookup geometry must be a
32+
:class:`~django.contrib.gis.geos.Point`)
33+
- :lookup:`disjoint`
34+
- :lookup:`distance_gte`
35+
- :lookup:`distance_lte`
36+
- :lookup:`dwithin`
37+
- :lookup:`intersects`
38+
- :lookup:`within`
39+
40+
Spatial lookups don't support expressions or subqueries.
41+
42+
Raw spatial queries
43+
===================
44+
2145
You can use any of the :ref:`geospatial query operators
2246
<manual:geospatial-query-operators>` or the :ref:`geospatial aggregation
2347
pipeline stage <geospatial-aggregation>` in :meth:`.raw_aggregate` queries.
@@ -39,6 +63,7 @@ Limitations
3963
(:attr:`BaseSpatialField.srid
4064
<django.contrib.gis.db.models.BaseSpatialField.srid>`)
4165
besides `4326 (WGS84) <https://spatialreference.org/ref/epsg/4326/>`_.
42-
- None of the :doc:`GIS QuerySet APIs <django:ref/contrib/gis/geoquerysets>`
43-
(lookups, aggregates, and database functions) are supported.
66+
- :ref:`GIS aggregate functions <gis-aggregation-functions>` and
67+
:doc:`geographic database functions <django:ref/contrib/gis/functions>`
68+
aren't supported.
4469
- :class:`~django.contrib.gis.db.models.RasterField` isn't supported.

docs/releases/6.0.x.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Django MongoDB Backend 6.0.x
1010
New features
1111
------------
1212

13-
- ...
13+
- Added support for :ref:`spatial lookups <django:spatial-lookups>`:
14+
``contains``, ``disjoint``, ``distance_gte``, ``distance_lte``, ``dwithin``,
15+
``intersects``, and ``within``.
1416

1517
Bug fixes
1618
---------
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
[
2+
{
3+
"pk": "000000000000000000000001",
4+
"model": "gis_tests_.city",
5+
"fields": {
6+
"name": "Houston",
7+
"point": "POINT (-95.363151 29.763374)"
8+
}
9+
},
10+
{
11+
"pk": "000000000000000000000002",
12+
"model": "gis_tests_.city",
13+
"fields": {
14+
"name": "Dallas",
15+
"point": "POINT (-96.801611 32.782057)"
16+
}
17+
},
18+
{
19+
"pk": "000000000000000000000003",
20+
"model": "gis_tests_.city",
21+
"fields": {
22+
"name": "Oklahoma City",
23+
"point": "POINT (-97.521157 34.464642)"
24+
}
25+
},
26+
{
27+
"pk": "000000000000000000000004",
28+
"model": "gis_tests_.city",
29+
"fields": {
30+
"name": "Wellington",
31+
"point": "POINT (174.783117 -41.315268)"
32+
}
33+
},
34+
{
35+
"pk": "000000000000000000000005",
36+
"model": "gis_tests_.city",
37+
"fields": {
38+
"name": "Pueblo",
39+
"point": "POINT (-104.609252 38.255001)"
40+
}
41+
},
42+
{
43+
"pk": "000000000000000000000006",
44+
"model": "gis_tests_.city",
45+
"fields": {
46+
"name": "Lawrence",
47+
"point": "POINT (-95.235060 38.971823)"
48+
}
49+
},
50+
{
51+
"pk": "000000000000000000000007",
52+
"model": "gis_tests_.city",
53+
"fields": {
54+
"name": "Chicago",
55+
"point": "POINT (-87.650175 41.850385)"
56+
}
57+
},
58+
{
59+
"pk": "000000000000000000000008",
60+
"model": "gis_tests_.city",
61+
"fields": {
62+
"name": "Victoria",
63+
"point": "POINT (-123.305196 48.462611)"
64+
}
65+
},
66+
{
67+
"pk": "000000000000000000000001",
68+
"model": "gis_tests_.zipcode",
69+
"fields" : {
70+
"code" : "77002",
71+
"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))"
72+
}
73+
},
74+
{
75+
"pk": "000000000000000000000002",
76+
"model": "gis_tests_.zipcode",
77+
"fields" : {
78+
"code" : "77005",
79+
"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))"
80+
}
81+
},
82+
{
83+
"pk": "000000000000000000000003",
84+
"model": "gis_tests_.zipcode",
85+
"fields" : {
86+
"code" : "77025",
87+
"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))"
88+
}
89+
},
90+
{
91+
"pk": "000000000000000000000004",
92+
"model": "gis_tests_.zipcode",
93+
"fields" : {
94+
"code" : "77401",
95+
"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))"
96+
}
97+
}
98+
]

0 commit comments

Comments
 (0)