Skip to content

Commit e523bff

Browse files
committed
Add GeoDjango support
1 parent 4c608c2 commit e523bff

File tree

10 files changed

+280
-4
lines changed

10 files changed

+280
-4
lines changed

.github/workflows/test-python-atlas.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
- name: Install system packages for Django's Python test dependencies
4040
run: |
4141
sudo apt-get update
42-
sudo apt-get install libmemcached-dev
42+
sudo apt-get install gdal-bin libmemcached-dev
4343
- name: Install Django and its Python test dependencies
4444
run: |
4545
cd django_repo/tests/

.github/workflows/test-python.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
- name: Install system packages for Django's Python test dependencies
4040
run: |
4141
sudo apt-get update
42-
sudo apt-get install libmemcached-dev
42+
sudo apt-get install gdal-bin libmemcached-dev
4343
- name: Install Django and its Python test dependencies
4444
run: |
4545
cd django_repo/tests/

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
def geom_from_coordinates(geom_class, coordinates):
72+
is_polygon = geom_class.__name__ == "Polygon"
73+
return geom_class(*coordinates if is_polygon else coordinates, srid=4326)
74+
75+
def converter(value, expression, connection): # noqa: ARG001
76+
if value is None:
77+
return None
78+
geom_class = getattr(geos, value["type"])
79+
if geom_class.__name__ == "GeometryCollection":
80+
return geom_class(
81+
[
82+
geom_from_coordinates(getattr(geos, v["type"]), v["coordinates"])
83+
for v in value["geometries"]
84+
],
85+
srid=4326,
86+
)
87+
if issubclass(geom_class, geos.GeometryCollection):
88+
# TODO: confirm this is correct.
89+
return geom_class(
90+
[
91+
# TODO: For MultiLineString, geom_class._allowed is a
92+
# tuple so this will crash.
93+
geom_class._allowed(
94+
*value["coordinates"][x]
95+
if geom_class.__name__ == "MultiPolygon"
96+
else value["coordinates"][x]
97+
)
98+
for x in range(len(value["coordinates"]))
99+
],
100+
srid=4326,
101+
)
102+
return geom_from_coordinates(geom_class, value["coordinates"])
103+
104+
return converter

django_mongodb_backend_gis/schema.py

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

0 commit comments

Comments
 (0)