Skip to content
16 changes: 14 additions & 2 deletions django_mongodb_backend/gis/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

class GISFeatures(BaseSpatialFeatures):
has_spatialrefsys_table = False
supports_distance_geodetic = False
supports_transform = False

@cached_property
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions django_mongodb_backend/gis/lookups.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the geo operator like $geoWithin and other inside an $expr?
As long as I know we can't, so we should raise an error if the as_expr is True I have fear that someone tries to wrap a gis_lookup inside a Case and the query fails

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test for the exception and document all limitations of these lookups (no subqueries, no expressions).

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:
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
DistanceLookupFromFunction.as_mql = gis_lookup
38 changes: 35 additions & 3 deletions django_mongodb_backend/gis/operations.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
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,
DistanceGTE,
DistanceLT,
DistanceLTE,
DWithin,
Intersects,
Within,
)


class GISOperations(BaseSpatialOperations):
Expand All @@ -16,9 +28,17 @@ class GISOperations(BaseSpatialOperations):
models.Union,
)

@property
def gis_operators(self):
return {}
gis_operators = {
"contains": Contains(),
"disjoint": Disjoint(),
"distance_gt": DistanceGT(),
"distance_gte": DistanceGTE(),
"distance_lt": DistanceLT(),
"distance_lte": DistanceLTE(),
"dwithin": DWithin(),
"intersects": Intersects(),
"within": Within(),
}

unsupported_functions = {
"Area",
Expand Down Expand Up @@ -95,3 +115,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 radian 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]
134 changes: 134 additions & 0 deletions django_mongodb_backend/gis/operators.py
Original file line number Diff line number Diff line change
@@ -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 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 Disjoint(Operator):
name = "disjoint"

def as_mql(self, field, value, params=None):
return {
field: {
"$not": {
"$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]]}}}


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"],
}
}
}
}
16 changes: 13 additions & 3 deletions docs/ref/contrib/gis.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ Each model field stores data as :doc:`GeoJSON objects
All fields have a :doc:`2dsphere index
<manual:core/indexes/index-types/geospatial/2dsphere>` created on them.

You can use any of the :ref:`geospatial query operators
The following :doc:`GIS QuerySet APIs <django:ref/contrib/gis/geoquerysets>` 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
<manual:geospatial-query-operators>` or the :ref:`geospatial aggregation
pipeline stage <geospatial-aggregation>` in :meth:`.raw_aggregate` queries.

Expand All @@ -39,6 +50,5 @@ Limitations
(:attr:`BaseSpatialField.srid
<django.contrib.gis.db.models.BaseSpatialField.srid>`)
besides `4326 (WGS84) <https://spatialreference.org/ref/epsg/4326/>`_.
- None of the :doc:`GIS QuerySet APIs <django:ref/contrib/gis/geoquerysets>`
(lookups, aggregates, and database functions) are supported.
- QuerySet APIs do not support subqueries or expressions.
- :class:`~django.contrib.gis.db.models.RasterField` isn't supported.
98 changes: 98 additions & 0 deletions tests/gis_tests_/fixtures/initial.json
Original file line number Diff line number Diff line change
@@ -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))"
}
}
]
9 changes: 9 additions & 0 deletions tests/gis_tests_/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@


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)
poly = models.PolygonField(geography=True)
Loading