Skip to content

Commit 540e7a9

Browse files
committed
Add GeoDjango support
1 parent 9831e9b commit 540e7a9

File tree

21 files changed

+495
-7
lines changed

21 files changed

+495
-7
lines changed

.github/workflows/runtests.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import pathlib
44
import sys
55

6+
from django.core.exceptions import ImproperlyConfigured
7+
68
test_apps = [
79
"admin_changelist",
810
"admin_checks",
@@ -154,9 +156,20 @@
154156
[
155157
x.name
156158
for x in (pathlib.Path(__file__).parent.parent.parent.resolve() / "tests").iterdir()
159+
# Omit GIS tests unless GIS libraries are installed.
160+
if x.name != "gis_tests_"
157161
]
158162
),
159163
]
164+
165+
try:
166+
from django.contrib.gis.db import models # noqa: F401
167+
except ImproperlyConfigured:
168+
# GIS libraries (GDAL/GEOS) not installed.
169+
pass
170+
else:
171+
test_apps.extend(["gis_tests", "gis_tests_"])
172+
160173
runtests = pathlib.Path(__file__).parent.resolve() / "runtests.py"
161174
run_tests_cmd = f"python3 {runtests} %s --settings mongodb_settings -v 2"
162175

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Identical to test-python.yml except that gdal-bin is also installed.
2+
name: Python Tests with GeoDjango
3+
4+
on:
5+
pull_request:
6+
paths:
7+
- '**.py'
8+
- '!setup.py'
9+
- '.github/workflows/test-python-geo.yml'
10+
workflow_dispatch:
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
defaults:
17+
run:
18+
shell: bash -eux {0}
19+
20+
jobs:
21+
build:
22+
name: Django Test Suite
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout django-mongodb-backend
26+
uses: actions/checkout@v4
27+
with:
28+
persist-credentials: false
29+
- name: install django-mongodb-backend
30+
run: |
31+
pip3 install --upgrade pip
32+
pip3 install -e .
33+
- name: Checkout Django
34+
uses: actions/checkout@v4
35+
with:
36+
repository: 'mongodb-forks/django'
37+
ref: 'mongodb-5.2.x'
38+
path: 'django_repo'
39+
persist-credentials: false
40+
- name: Install system packages for Django's Python test dependencies
41+
run: |
42+
sudo apt-get update
43+
sudo apt-get install gdal-bin libmemcached-dev
44+
- name: Install Django and its Python test dependencies
45+
run: |
46+
cd django_repo/tests/
47+
pip3 install -e ..
48+
pip3 install -r requirements/py3.txt
49+
- name: Copy the test settings file
50+
run: cp .github/workflows/mongodb_settings.py django_repo/tests/
51+
- name: Copy the test runner file
52+
run: cp .github/workflows/runtests.py django_repo/tests/runtests_.py
53+
- name: Start MongoDB
54+
uses: supercharge/mongodb-github-action@90004df786821b6308fb02299e5835d0dae05d0d # 1.12.0
55+
with:
56+
mongodb-version: 6.0
57+
- name: Run tests
58+
run: python3 django_repo/tests/runtests_.py
59+
permissions:
60+
contents: read

django_mongodb_backend/features.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
from django.core.exceptions import ImproperlyConfigured
12
from django.db.backends.base.features import BaseDatabaseFeatures
23
from django.utils.functional import cached_property
34
from pymongo.errors import OperationFailure
45

6+
try:
7+
from .gis.features import GISFeatures
8+
except ImproperlyConfigured:
9+
# GIS libraries (GDAL/GEOS) not installed.
10+
class GISFeatures:
11+
pass
512

6-
class DatabaseFeatures(BaseDatabaseFeatures):
13+
14+
class DatabaseFeatures(GISFeatures, BaseDatabaseFeatures):
715
minimum_database_version = (6, 0)
816
allow_sliced_subqueries_with_in = False
917
allows_multiple_constraints_on_same_fields = False
@@ -105,7 +113,7 @@ def django_test_expected_failures(self):
105113
expected_failures.update(self._django_test_expected_failures_bitwise)
106114
return expected_failures
107115

108-
django_test_skips = {
116+
_django_test_skips = {
109117
"Database defaults aren't supported by MongoDB.": {
110118
# bson.errors.InvalidDocument: cannot encode object:
111119
# <django.db.models.expressions.DatabaseDefault
@@ -569,6 +577,12 @@ def django_test_expected_failures(self):
569577
},
570578
}
571579

580+
@cached_property
581+
def django_test_skips(self):
582+
skips = super().django_test_skips
583+
skips.update(self._django_test_skips)
584+
return skips
585+
572586
@cached_property
573587
def is_mongodb_6_3(self):
574588
return self.connection.get_database_version() >= (6, 3)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.core.exceptions import ImproperlyConfigured
2+
3+
try:
4+
from .lookups import register_lookups
5+
except ImproperlyConfigured:
6+
# GIS libraries (GDAL/GEOS) not installed.
7+
pass
8+
else:
9+
register_lookups()

django_mongodb_backend/gis/adapter.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import collections
2+
3+
4+
class Adapter(collections.UserDict):
5+
srid = 4326
6+
7+
def __init__(self, obj, geography=False):
8+
"""
9+
Initialize on the spatial object per
10+
https://www.mongodb.com/docs/manual/reference/geojson/.
11+
"""
12+
if obj.__class__.__name__ == "GeometryCollection":
13+
self.data = {
14+
"type": obj.__class__.__name__,
15+
"geometries": [self.get_data(x) for x in obj],
16+
}
17+
else:
18+
self.data = self.get_data(obj)
19+
20+
def get_data(self, obj):
21+
return {
22+
"type": obj.__class__.__name__,
23+
"coordinates": obj.coords,
24+
}
25+
26+
@classmethod
27+
def _fix_polygon(cls, poly):
28+
return poly
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from django.contrib.gis.db.backends.base.features import BaseSpatialFeatures
2+
from django.utils.functional import cached_property
3+
4+
5+
class GISFeatures(BaseSpatialFeatures):
6+
has_spatialrefsys_table = False
7+
supports_transform = False
8+
9+
@cached_property
10+
def django_test_expected_failures(self):
11+
expected_failures = super().django_test_expected_failures
12+
expected_failures.update(
13+
{
14+
# annotate with Value not supported, e.g.
15+
# QuerySet.annotate(p=Value(p, GeometryField(srid=4326)
16+
"gis_tests.geoapp.test_expressions.GeoExpressionsTests.test_geometry_value_annotation",
17+
}
18+
)
19+
return expected_failures
20+
21+
@cached_property
22+
def django_test_skips(self):
23+
skips = super().django_test_skips
24+
skips.update(
25+
{
26+
"inspectdb not supported.": {
27+
"gis_tests.inspectapp.tests.InspectDbTests",
28+
},
29+
"Raw SQL not supported": {
30+
"gis_tests.geoapp.tests.GeoModelTest.test_raw_sql_query",
31+
},
32+
"MongoDB doesn't support the SRID used in this test.": {
33+
# Error messages:
34+
# - Can't extract geo keys
35+
# - Longitude/latitude is out of bounds
36+
"gis_tests.geoapp.test_expressions.GeoExpressionsTests.test_update_from_other_field",
37+
"gis_tests.layermap.tests.LayerMapTest.test_encoded_name",
38+
"gis_tests.relatedapp.tests.RelatedGeoModelTest.test06_f_expressions",
39+
# SouthTexasCity fixture objects use SRID 2278 which is ignored
40+
# by the patched version of loaddata in the Django fork.
41+
"gis_tests.distapp.tests.DistanceTest.test_init",
42+
},
43+
"ImproperlyConfigured isn't raised when using RasterField": {
44+
# Normally RasterField.db_type() raises an error, but MongoDB
45+
# migrations don't need to call it, so the check doesn't happen.
46+
"gis_tests.gis_migrations.test_operations.NoRasterSupportTests",
47+
},
48+
"MongoDB doesn't support redundant spatial indexes.": {
49+
# Error: Index already exists with a different name
50+
"gis_tests.geoapp.test_indexes.SchemaIndexesTests.test_index_name",
51+
},
52+
"GIS lookups not supported.": {
53+
"gis_tests.geoapp.tests.GeoModelTest.test_gis_query_as_string",
54+
"gis_tests.geoapp.tests.GeoLookupTest.test_gis_lookups_with_complex_expressions",
55+
},
56+
"GeoJSONSerializer doesn't support ObjectId.": {
57+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_fields_option",
58+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_geometry_field_option",
59+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_serialization_base",
60+
"gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_srid_option",
61+
},
62+
},
63+
)
64+
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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 .adapter import Adapter
6+
7+
8+
class GISOperations(BaseSpatialOperations):
9+
Adapter = Adapter
10+
11+
disallowed_aggregates = (
12+
models.Collect,
13+
models.Extent,
14+
models.Extent3D,
15+
models.MakeLine,
16+
models.Union,
17+
)
18+
19+
@property
20+
def gis_operators(self):
21+
return {}
22+
23+
unsupported_functions = {
24+
"Area",
25+
"AsGeoJSON",
26+
"AsGML",
27+
"AsKML",
28+
"AsSVG",
29+
"AsWKB",
30+
"AsWKT",
31+
"Azimuth",
32+
"BoundingCircle",
33+
"Centroid",
34+
"ClosestPoint",
35+
"Difference",
36+
"Distance",
37+
"Envelope",
38+
"ForcePolygonCW",
39+
"FromWKB",
40+
"FromWKT",
41+
"GeoHash",
42+
"GeometryDistance",
43+
"Intersection",
44+
"IsEmpty",
45+
"IsValid",
46+
"Length",
47+
"LineLocatePoint",
48+
"MakeValid",
49+
"MemSize",
50+
"NumGeometries",
51+
"NumPoints",
52+
"Perimeter",
53+
"PointOnSurface",
54+
"Reverse",
55+
"Scale",
56+
"SnapToGrid",
57+
"SymDifference",
58+
"Transform",
59+
"Translate",
60+
"Union",
61+
}
62+
63+
def geo_db_type(self, f):
64+
return "object"
65+
66+
def get_geometry_converter(self, expression):
67+
srid = expression.output_field.srid
68+
69+
def converter(value, expression, connection): # noqa: ARG001
70+
if value is None:
71+
return None
72+
73+
geom_class = getattr(geos, value["type"])
74+
if geom_class.__name__ == "GeometryCollection":
75+
return geom_class(
76+
[
77+
getattr(geos, v["type"])(*v["coordinates"], srid=srid)
78+
for v in value["geometries"]
79+
],
80+
srid=srid,
81+
)
82+
if issubclass(geom_class, geos.GeometryCollection):
83+
sub_geom_class = geom_class._allowed
84+
# MultiLineString allows both LineString and LinearRing but should be
85+
# initialized with LineString.
86+
if isinstance(sub_geom_class, tuple):
87+
sub_geom_class = sub_geom_class[0]
88+
return geom_class(
89+
[
90+
sub_geom_class(*value["coordinates"][x])
91+
for x in range(len(value["coordinates"]))
92+
],
93+
srid=srid,
94+
)
95+
return geom_class(*value["coordinates"], srid=srid)
96+
97+
return converter

0 commit comments

Comments
 (0)