Skip to content

Commit 0b958e7

Browse files
committed
Add GeoDjango support
1 parent b5573d8 commit 0b958e7

File tree

8 files changed

+281
-2
lines changed

8 files changed

+281
-2
lines changed

django_mongodb_backend/introspection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from django.db.backends.base.introspection import BaseDatabaseIntrospection
22
from django.db.models import Index
3-
from pymongo import ASCENDING, DESCENDING
3+
from pymongo import ASCENDING, DESCENDING, GEOSPHERE
44

55
from django_mongodb_backend.indexes import SearchIndex, VectorSearchIndex
66

77

88
class DatabaseIntrospection(BaseDatabaseIntrospection):
9-
ORDER_DIR = {ASCENDING: "ASC", DESCENDING: "DESC"}
9+
ORDER_DIR = {ASCENDING: "ASC", DESCENDING: "DESC", GEOSPHERE: "GEO"}
1010

1111
def table_names(self, cursor=None, include_views=False):
1212
return sorted([x["name"] for x in self.connection.database.list_collections()])
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .lookups import register_lookups
2+
3+
register_lookups()

django_mongodb_backend_gis/adapter.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import collections
2+
3+
4+
class Adapter(collections.UserDict):
5+
def __init__(self, obj, geography=False):
6+
"""
7+
Initialize on the spatial object.
8+
"""
9+
if obj.__class__.__name__ == "GeometryCollection":
10+
self.data = {
11+
"type": obj.__class__.__name__,
12+
"geometries": [self.get_data(x) for x in obj],
13+
}
14+
else:
15+
self.data = self.get_data(obj)
16+
17+
def get_data(self, obj):
18+
return {
19+
"type": obj.__class__.__name__,
20+
"coordinates": obj.coords,
21+
}

django_mongodb_backend_gis/base.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django_mongodb_backend.base import DatabaseWrapper as BaseDatabaseWrapper
2+
3+
from .features import DatabaseFeatures
4+
from .operations import DatabaseOperations
5+
from .schema import DatabaseSchemaEditor
6+
7+
8+
class DatabaseWrapper(BaseDatabaseWrapper):
9+
SchemaEditorClass = DatabaseSchemaEditor
10+
features_class = DatabaseFeatures
11+
ops_class = DatabaseOperations
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from django.contrib.gis.db.backends.base.features import BaseSpatialFeatures
2+
from django.utils.functional import cached_property
3+
4+
from django_mongodb_backend.features import DatabaseFeatures as MongoFeatures
5+
6+
7+
class DatabaseFeatures(BaseSpatialFeatures, MongoFeatures):
8+
has_spatialrefsys_table = False
9+
supports_transform = False
10+
11+
@cached_property
12+
def django_test_expected_failures(self):
13+
expected_failures = super().django_test_expected_failures
14+
expected_failures.update(
15+
{
16+
# SRIDs aren't populated: AssertionError: 4326 != None
17+
# self.assertEqual(4326, nullcity.point.srid)
18+
"gis_tests.geoapp.tests.GeoModelTest.test_proxy",
19+
# MongoDB does not support the within lookup
20+
"gis_tests.relatedapp.tests.RelatedGeoModelTest.test06_f_expressions",
21+
# 'Adapter' object has no attribute 'srid'
22+
"gis_tests.geoapp.test_expressions.GeoExpressionsTests.test_geometry_value_annotation",
23+
# Object of type ObjectId is not JSON serializable
24+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_fields_option",
25+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_geometry_field_option",
26+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_serialization_base",
27+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_srid_option",
28+
# KeyError: 'within' connection.ops.gis_operators[self.lookup_name]
29+
"gis_tests.geoapp.tests.GeoModelTest.test_gis_query_as_string",
30+
# No lookups are supported (yet?)
31+
"gis_tests.geoapp.tests.GeoLookupTest.test_gis_lookups_with_complex_expressions",
32+
# Trying to remove spatial index fails:
33+
# "index not found with name [gis_neighborhood_geom_id]"
34+
"gis_tests.gis_migrations.test_operations.OperationTests.test_alter_field_remove_spatial_index",
35+
}
36+
)
37+
return expected_failures
38+
39+
@cached_property
40+
def django_test_skips(self):
41+
skips = super().django_test_skips
42+
skips.update(
43+
{
44+
"inspectdb not supported.": {
45+
"gis_tests.inspectapp.tests.InspectDbTests",
46+
},
47+
"Raw SQL not supported": {
48+
"gis_tests.geoapp.tests.GeoModelTest.test_raw_sql_query",
49+
},
50+
"MongoDB doesn't support the SRID(s) used in this test.": {
51+
# Error messages:
52+
# - Can't extract geo keys
53+
# - Longitude/latitude is out of bounds
54+
"gis_tests.geoapp.test_expressions.GeoExpressionsTests.test_update_from_other_field",
55+
"gis_tests.layermap.tests.LayerMapTest.test_encoded_name",
56+
# SouthTexasCity fixture objects use SRID 2278 which is ignored
57+
# by the patched version of loaddata in the Django fork.
58+
"gis_tests.distapp.tests.DistanceTest.test_init",
59+
},
60+
"ImproperlyConfigured isn't raised when using RasterField": {
61+
# Normally RasterField.db_type() raises an error, but MongoDB
62+
# migrations don't need to call it, so the check doesn't happen.
63+
"gis_tests.gis_migrations.test_operations.NoRasterSupportTests",
64+
},
65+
},
66+
)
67+
return skips

django_mongodb_backend_gis/lookups.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.contrib.gis.db.models.lookups import GISLookup
2+
from django.db import NotSupportedError
3+
4+
5+
def gis_lookup(self, compiler, connection): # noqa: ARG001
6+
raise NotSupportedError(f"MongoDB does not support the {self.lookup_name} lookup.")
7+
8+
9+
def register_lookups():
10+
GISLookup.as_mql = gis_lookup
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from django.contrib.gis import geos
2+
from django.contrib.gis.db import models
3+
from django.contrib.gis.db.backends.base.operations import BaseSpatialOperations
4+
5+
from django_mongodb_backend.operations import (
6+
DatabaseOperations as MongoOperations,
7+
)
8+
9+
from .adapter import Adapter
10+
11+
12+
class DatabaseOperations(BaseSpatialOperations, MongoOperations):
13+
Adapter = Adapter
14+
15+
disallowed_aggregates = (
16+
models.Collect,
17+
models.Extent,
18+
models.Extent3D,
19+
models.MakeLine,
20+
models.Union,
21+
)
22+
23+
@property
24+
def gis_operators(self):
25+
return {}
26+
27+
unsupported_functions = {
28+
"Area",
29+
"AsGeoJSON",
30+
"AsGML",
31+
"AsKML",
32+
"AsSVG",
33+
"AsWKB",
34+
"AsWKT",
35+
"Azimuth",
36+
"BoundingCircle",
37+
"Centroid",
38+
"ClosestPoint",
39+
"Difference",
40+
"Distance",
41+
"Envelope",
42+
"ForcePolygonCW",
43+
"FromWKB",
44+
"FromWKT",
45+
"GeoHash",
46+
"GeometryDistance",
47+
"Intersection",
48+
"IsEmpty",
49+
"IsValid",
50+
"Length",
51+
"LineLocatePoint",
52+
"MakeValid",
53+
"MemSize",
54+
"NumGeometries",
55+
"NumPoints",
56+
"Perimeter",
57+
"PointOnSurface",
58+
"Reverse",
59+
"Scale",
60+
"SnapToGrid",
61+
"SymDifference",
62+
"Transform",
63+
"Translate",
64+
"Union",
65+
}
66+
67+
def geo_db_type(self, f):
68+
return "object"
69+
70+
def get_geometry_converter(self, expression):
71+
srid = expression.output_field.srid
72+
73+
def geom_from_coordinates(geom_class, coordinates):
74+
is_polygon = geom_class.__name__ == "Polygon"
75+
return geom_class(*coordinates if is_polygon else coordinates, srid=srid)
76+
77+
def converter(value, expression, connection): # noqa: ARG001
78+
if value is None:
79+
return None
80+
81+
geom_class = getattr(geos, value["type"])
82+
if geom_class.__name__ == "GeometryCollection":
83+
return geom_class(
84+
[
85+
geom_from_coordinates(getattr(geos, v["type"]), v["coordinates"])
86+
for v in value["geometries"]
87+
],
88+
srid=srid,
89+
)
90+
if issubclass(geom_class, geos.GeometryCollection):
91+
sub_geom_class = geom_class._allowed
92+
# MultiLineString allows both LineString and LinearRing but should be
93+
# initialized with LineString.
94+
if isinstance(sub_geom_class, tuple):
95+
sub_geom_class = sub_geom_class[0]
96+
return geom_class(
97+
[
98+
sub_geom_class(
99+
*value["coordinates"][x]
100+
if geom_class.__name__ == "MultiPolygon"
101+
else value["coordinates"][x]
102+
)
103+
for x in range(len(value["coordinates"]))
104+
],
105+
srid=srid,
106+
)
107+
return geom_from_coordinates(geom_class, value["coordinates"])
108+
109+
return converter

django_mongodb_backend_gis/schema.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from pymongo import GEOSPHERE
2+
from pymongo.operations import IndexModel
3+
4+
from django_mongodb_backend.schema import DatabaseSchemaEditor as BaseSchemaEditor
5+
6+
7+
class DatabaseSchemaEditor(BaseSchemaEditor):
8+
def _field_should_be_indexed(self, model, field):
9+
if getattr(field, "spatial_index", False):
10+
return True
11+
return super()._field_should_be_indexed(model, field)
12+
13+
def _add_field_index(self, model, field, *, column_prefix=""):
14+
if hasattr(field, "geodetic"):
15+
self._add_spatial_index(model, field)
16+
else:
17+
super()._add_field_index(model, field, column_prefix=column_prefix)
18+
19+
def _alter_field(
20+
self,
21+
model,
22+
old_field,
23+
new_field,
24+
old_type,
25+
new_type,
26+
old_db_params,
27+
new_db_params,
28+
strict=False,
29+
):
30+
super()._alter_field(
31+
model,
32+
old_field,
33+
new_field,
34+
old_type,
35+
new_type,
36+
old_db_params,
37+
new_db_params,
38+
strict=strict,
39+
)
40+
old_field_spatial_index = getattr(old_field, "spatial_index", False)
41+
new_field_spatial_index = getattr(new_field, "spatial_index", False)
42+
if not old_field_spatial_index and new_field_spatial_index:
43+
self._add_spatial_index(model, new_field)
44+
elif old_field_spatial_index and not new_field_spatial_index:
45+
self._delete_spatial_index(model, new_field)
46+
47+
def _add_spatial_index(self, model, field):
48+
index_name = self._create_spatial_index_name(model, field)
49+
self.get_collection(model._meta.db_table).create_indexes(
50+
[IndexModel([(field.column, GEOSPHERE)], name=index_name)]
51+
)
52+
53+
def _delete_spatial_index(self, model, field):
54+
index_name = self._create_spatial_index_name(model, field)
55+
self.get_collection(model._meta.db_table).drop_index(index_name)
56+
57+
def _create_spatial_index_name(self, model, field):
58+
return f"{model._meta.db_table}_{field.column}_id"

0 commit comments

Comments
 (0)