From 2bb8ebf0e8e5f154f3d68d6b41de7ec65bd2c70c Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Thu, 31 Jul 2025 16:19:56 -0400 Subject: [PATCH] Add GeoDjango support --- .github/workflows/runtests.py | 13 +++ .github/workflows/test-python-atlas-geo.yml | 59 +++++++++++++ .github/workflows/test-python-geo.yml | 60 +++++++++++++ django_mongodb_backend/features.py | 18 +++- django_mongodb_backend/gis/__init__.py | 9 ++ django_mongodb_backend/gis/adapter.py | 27 ++++++ django_mongodb_backend/gis/features.py | 64 ++++++++++++++ django_mongodb_backend/gis/lookups.py | 10 +++ django_mongodb_backend/gis/operations.py | 97 +++++++++++++++++++++ django_mongodb_backend/gis/schema.py | 61 +++++++++++++ django_mongodb_backend/introspection.py | 4 +- django_mongodb_backend/operations.py | 10 ++- django_mongodb_backend/schema.py | 8 +- docs/source/index.rst | 1 + docs/source/ref/contrib/gis.rst | 26 ++++++ docs/source/ref/contrib/index.rst | 10 +++ docs/source/ref/index.rst | 1 + docs/source/releases/5.2.x.rst | 1 + tests/gis_tests_/__init__.py | 0 tests/gis_tests_/models.py | 5 ++ tests/gis_tests_/tests.py | 13 +++ tests/schema_/test_embedded_model.py | 40 ++++++++- 22 files changed, 530 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test-python-atlas-geo.yml create mode 100644 .github/workflows/test-python-geo.yml create mode 100644 django_mongodb_backend/gis/__init__.py create mode 100644 django_mongodb_backend/gis/adapter.py create mode 100644 django_mongodb_backend/gis/features.py create mode 100644 django_mongodb_backend/gis/lookups.py create mode 100644 django_mongodb_backend/gis/operations.py create mode 100644 django_mongodb_backend/gis/schema.py create mode 100644 docs/source/ref/contrib/gis.rst create mode 100644 docs/source/ref/contrib/index.rst create mode 100644 tests/gis_tests_/__init__.py create mode 100644 tests/gis_tests_/models.py create mode 100644 tests/gis_tests_/tests.py diff --git a/.github/workflows/runtests.py b/.github/workflows/runtests.py index ebcc4876f..9e4096b55 100755 --- a/.github/workflows/runtests.py +++ b/.github/workflows/runtests.py @@ -3,6 +3,8 @@ import pathlib import sys +from django.core.exceptions import ImproperlyConfigured + test_apps = [ "admin_changelist", "admin_checks", @@ -154,9 +156,20 @@ [ x.name for x in (pathlib.Path(__file__).parent.parent.parent.resolve() / "tests").iterdir() + # Omit GIS tests unless GIS libraries are installed. + if x.name != "gis_tests_" ] ), ] + +try: + from django.contrib.gis.db import models # noqa: F401 +except ImproperlyConfigured: + # GIS libraries (GDAL/GEOS) not installed. + pass +else: + test_apps.extend(["gis_tests", "gis_tests_"]) + runtests = pathlib.Path(__file__).parent.resolve() / "runtests.py" run_tests_cmd = f"python3 {runtests} %s --settings mongodb_settings -v 2" diff --git a/.github/workflows/test-python-atlas-geo.yml b/.github/workflows/test-python-atlas-geo.yml new file mode 100644 index 000000000..0ff109a78 --- /dev/null +++ b/.github/workflows/test-python-atlas-geo.yml @@ -0,0 +1,59 @@ +# Identical to test-python-atlas.yml except that gdal-bin is also installed. +name: Python Tests on Atlas with GeoDjango + +on: + pull_request: + paths: + - '**.py' + - '!setup.py' + - '.github/workflows/test-python-atlas-geo.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +jobs: + build: + name: Django Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout django-mongodb-backend + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: install django-mongodb-backend + run: | + pip3 install --upgrade pip + pip3 install -e . + - name: Checkout Django + uses: actions/checkout@v4 + with: + repository: 'mongodb-forks/django' + ref: 'mongodb-5.2.x' + path: 'django_repo' + persist-credentials: false + - name: Install system packages for Django's Python test dependencies + run: | + sudo apt-get update + sudo apt-get install gdal-bin libmemcached-dev + - name: Install Django and its Python test dependencies + run: | + cd django_repo/tests/ + pip3 install -e .. + pip3 install -r requirements/py3.txt + - name: Copy the test settings file + run: cp .github/workflows/mongodb_settings.py django_repo/tests/ + - name: Copy the test runner file + run: cp .github/workflows/runtests.py django_repo/tests/runtests_.py + - name: Start local Atlas + working-directory: . + run: bash .github/workflows/start_local_atlas.sh mongodb/mongodb-atlas-local:7 + - name: Run tests + run: python3 django_repo/tests/runtests_.py + permissions: + contents: read diff --git a/.github/workflows/test-python-geo.yml b/.github/workflows/test-python-geo.yml new file mode 100644 index 000000000..39b4ac742 --- /dev/null +++ b/.github/workflows/test-python-geo.yml @@ -0,0 +1,60 @@ +# Identical to test-python.yml except that gdal-bin is also installed. +name: Python Tests with GeoDjango + +on: + pull_request: + paths: + - '**.py' + - '!setup.py' + - '.github/workflows/test-python-geo.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +jobs: + build: + name: Django Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout django-mongodb-backend + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: install django-mongodb-backend + run: | + pip3 install --upgrade pip + pip3 install -e . + - name: Checkout Django + uses: actions/checkout@v4 + with: + repository: 'mongodb-forks/django' + ref: 'mongodb-5.2.x' + path: 'django_repo' + persist-credentials: false + - name: Install system packages for Django's Python test dependencies + run: | + sudo apt-get update + sudo apt-get install gdal-bin libmemcached-dev + - name: Install Django and its Python test dependencies + run: | + cd django_repo/tests/ + pip3 install -e .. + pip3 install -r requirements/py3.txt + - name: Copy the test settings file + run: cp .github/workflows/mongodb_settings.py django_repo/tests/ + - name: Copy the test runner file + run: cp .github/workflows/runtests.py django_repo/tests/runtests_.py + - name: Start MongoDB + uses: supercharge/mongodb-github-action@90004df786821b6308fb02299e5835d0dae05d0d # 1.12.0 + with: + mongodb-version: 6.0 + - name: Run tests + run: python3 django_repo/tests/runtests_.py + permissions: + contents: read diff --git a/django_mongodb_backend/features.py b/django_mongodb_backend/features.py index 9f0245ec2..eb098a787 100644 --- a/django_mongodb_backend/features.py +++ b/django_mongodb_backend/features.py @@ -1,9 +1,17 @@ +from django.core.exceptions import ImproperlyConfigured from django.db.backends.base.features import BaseDatabaseFeatures from django.utils.functional import cached_property from pymongo.errors import OperationFailure +try: + from .gis.features import GISFeatures +except ImproperlyConfigured: + # GIS libraries (GDAL/GEOS) not installed. + class GISFeatures: + pass -class DatabaseFeatures(BaseDatabaseFeatures): + +class DatabaseFeatures(GISFeatures, BaseDatabaseFeatures): minimum_database_version = (6, 0) allow_sliced_subqueries_with_in = False allows_multiple_constraints_on_same_fields = False @@ -105,7 +113,7 @@ def django_test_expected_failures(self): expected_failures.update(self._django_test_expected_failures_bitwise) return expected_failures - django_test_skips = { + _django_test_skips = { "Database defaults aren't supported by MongoDB.": { # bson.errors.InvalidDocument: cannot encode object: # = (6, 3) diff --git a/django_mongodb_backend/gis/__init__.py b/django_mongodb_backend/gis/__init__.py new file mode 100644 index 000000000..c1250dc14 --- /dev/null +++ b/django_mongodb_backend/gis/__init__.py @@ -0,0 +1,9 @@ +from django.core.exceptions import ImproperlyConfigured + +try: + from .lookups import register_lookups +except ImproperlyConfigured: + # GIS libraries (GDAL/GEOS) not installed. + pass +else: + register_lookups() diff --git a/django_mongodb_backend/gis/adapter.py b/django_mongodb_backend/gis/adapter.py new file mode 100644 index 000000000..b29d6ccd6 --- /dev/null +++ b/django_mongodb_backend/gis/adapter.py @@ -0,0 +1,27 @@ +import collections + + +class Adapter(collections.UserDict): + srid = 4326 + + def __init__(self, obj, geography=False): + """ + Initialize on the spatial object. + """ + if obj.__class__.__name__ == "GeometryCollection": + self.data = { + "type": obj.__class__.__name__, + "geometries": [self.get_data(x) for x in obj], + } + else: + self.data = self.get_data(obj) + + def get_data(self, obj): + return { + "type": obj.__class__.__name__, + "coordinates": obj.coords, + } + + @classmethod + def _fix_polygon(cls, poly): + return poly diff --git a/django_mongodb_backend/gis/features.py b/django_mongodb_backend/gis/features.py new file mode 100644 index 000000000..c82f48435 --- /dev/null +++ b/django_mongodb_backend/gis/features.py @@ -0,0 +1,64 @@ +from django.contrib.gis.db.backends.base.features import BaseSpatialFeatures +from django.utils.functional import cached_property + + +class GISFeatures(BaseSpatialFeatures): + has_spatialrefsys_table = False + supports_transform = False + + @cached_property + def django_test_expected_failures(self): + expected_failures = super().django_test_expected_failures + expected_failures.update( + { + # annotate with Value not supported, e.g. + # QuerySet.annotate(p=Value(p, GeometryField(srid=4326) + "gis_tests.geoapp.test_expressions.GeoExpressionsTests.test_geometry_value_annotation", + } + ) + return expected_failures + + @cached_property + def django_test_skips(self): + skips = super().django_test_skips + skips.update( + { + "inspectdb not supported.": { + "gis_tests.inspectapp.tests.InspectDbTests", + }, + "Raw SQL not supported": { + "gis_tests.geoapp.tests.GeoModelTest.test_raw_sql_query", + }, + "MongoDB doesn't support the SRID used in this test.": { + # Error messages: + # - Can't extract geo keys + # - Longitude/latitude is out of bounds + "gis_tests.geoapp.test_expressions.GeoExpressionsTests.test_update_from_other_field", + "gis_tests.layermap.tests.LayerMapTest.test_encoded_name", + "gis_tests.relatedapp.tests.RelatedGeoModelTest.test06_f_expressions", + # 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", + }, + "ImproperlyConfigured isn't raised when using RasterField": { + # Normally RasterField.db_type() raises an error, but MongoDB + # migrations don't need to call it, so the check doesn't happen. + "gis_tests.gis_migrations.test_operations.NoRasterSupportTests", + }, + "MongoDB doesn't support redundant spatial indexes.": { + # 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_tests.geoapp.tests.GeoLookupTest.test_gis_lookups_with_complex_expressions", + }, + "GeoJSONSerializer doesn't support ObjectId.": { + "gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_fields_option", + "gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_geometry_field_option", + "gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_serialization_base", + "gis_tests.geoapp.test_serializers.GeoJSONSerializerTests.test_srid_option", + }, + }, + ) + return skips diff --git a/django_mongodb_backend/gis/lookups.py b/django_mongodb_backend/gis/lookups.py new file mode 100644 index 000000000..29c2e1e96 --- /dev/null +++ b/django_mongodb_backend/gis/lookups.py @@ -0,0 +1,10 @@ +from django.contrib.gis.db.models.lookups import GISLookup +from django.db import NotSupportedError + + +def gis_lookup(self, compiler, connection): # noqa: ARG001 + raise NotSupportedError(f"MongoDB does not support the {self.lookup_name} lookup.") + + +def register_lookups(): + GISLookup.as_mql = gis_lookup diff --git a/django_mongodb_backend/gis/operations.py b/django_mongodb_backend/gis/operations.py new file mode 100644 index 000000000..b5d5df1d5 --- /dev/null +++ b/django_mongodb_backend/gis/operations.py @@ -0,0 +1,97 @@ +from django.contrib.gis import geos +from django.contrib.gis.db import models +from django.contrib.gis.db.backends.base.operations import BaseSpatialOperations + +from .adapter import Adapter + + +class GISOperations(BaseSpatialOperations): + Adapter = Adapter + + disallowed_aggregates = ( + models.Collect, + models.Extent, + models.Extent3D, + models.MakeLine, + models.Union, + ) + + @property + def gis_operators(self): + return {} + + unsupported_functions = { + "Area", + "AsGeoJSON", + "AsGML", + "AsKML", + "AsSVG", + "AsWKB", + "AsWKT", + "Azimuth", + "BoundingCircle", + "Centroid", + "ClosestPoint", + "Difference", + "Distance", + "Envelope", + "ForcePolygonCW", + "FromWKB", + "FromWKT", + "GeoHash", + "GeometryDistance", + "Intersection", + "IsEmpty", + "IsValid", + "Length", + "LineLocatePoint", + "MakeValid", + "MemSize", + "NumGeometries", + "NumPoints", + "Perimeter", + "PointOnSurface", + "Reverse", + "Scale", + "SnapToGrid", + "SymDifference", + "Transform", + "Translate", + "Union", + } + + def geo_db_type(self, f): + return "object" + + def get_geometry_converter(self, expression): + srid = expression.output_field.srid + + def converter(value, expression, connection): # noqa: ARG001 + if value is None: + return None + + geom_class = getattr(geos, value["type"]) + if geom_class.__name__ == "GeometryCollection": + return geom_class( + [ + getattr(geos, v["type"])(*v["coordinates"], srid=srid) + for v in value["geometries"] + ], + srid=srid, + ) + if issubclass(geom_class, geos.GeometryCollection): + sub_geom_class = geom_class._allowed + # MultiLineString allows both LineString and LinearRing but should be + # initialized with LineString. + if isinstance(sub_geom_class, tuple): + sub_geom_class = sub_geom_class[0] + return geom_class( + [ + sub_geom_class(*value["coordinates"][x]) + for x in range(len(value["coordinates"])) + ], + srid=srid, + ) + return geom_class(*value["coordinates"], srid=srid) + + return converter diff --git a/django_mongodb_backend/gis/schema.py b/django_mongodb_backend/gis/schema.py new file mode 100644 index 000000000..b8088a5af --- /dev/null +++ b/django_mongodb_backend/gis/schema.py @@ -0,0 +1,61 @@ +from pymongo import GEOSPHERE +from pymongo.operations import IndexModel + + +class GISSchemaEditor: + def _create_model_indexes(self, model, column_prefix="", parent_model=None): + super()._create_model_indexes(model, column_prefix, parent_model) + for field in model._meta.local_fields: + if getattr(field, "spatial_index", False): + self._add_spatial_index(parent_model or model, field, column_prefix) + + def add_field(self, model, field): + super().add_field(model, field) + if getattr(field, "spatial_index", False): + self._add_spatial_index(model, field) + + def _alter_field( + self, + model, + old_field, + new_field, + old_type, + new_type, + old_db_params, + new_db_params, + strict=False, + ): + super()._alter_field( + model, + old_field, + new_field, + old_type, + new_type, + old_db_params, + new_db_params, + strict=strict, + ) + old_field_spatial_index = getattr(old_field, "spatial_index", False) + new_field_spatial_index = getattr(new_field, "spatial_index", False) + if not old_field_spatial_index and new_field_spatial_index: + self._add_spatial_index(model, new_field) + elif old_field_spatial_index and not new_field_spatial_index: + self._delete_spatial_index(model, new_field) + + def remove_field(self, model, field): + super().remove_field(model, field) + if getattr(field, "spatial_index", False): + self._delete_spatial_index(model, field) + + def _add_spatial_index(self, model, field, column_prefix=""): + index_name = self._create_spatial_index_name(model, field, column_prefix) + self.get_collection(model._meta.db_table).create_indexes( + [IndexModel([(column_prefix + field.column, GEOSPHERE)], name=index_name)] + ) + + def _delete_spatial_index(self, model, field): + index_name = self._create_spatial_index_name(model, field) + self.get_collection(model._meta.db_table).drop_index(index_name) + + def _create_spatial_index_name(self, model, field, column_prefix=""): + return f"{model._meta.db_table}_{column_prefix}{field.column}_id" diff --git a/django_mongodb_backend/introspection.py b/django_mongodb_backend/introspection.py index 77e068072..714f22465 100644 --- a/django_mongodb_backend/introspection.py +++ b/django_mongodb_backend/introspection.py @@ -1,12 +1,12 @@ from django.db.backends.base.introspection import BaseDatabaseIntrospection from django.db.models import Index -from pymongo import ASCENDING, DESCENDING +from pymongo import ASCENDING, DESCENDING, GEOSPHERE from django_mongodb_backend.indexes import SearchIndex, VectorSearchIndex class DatabaseIntrospection(BaseDatabaseIntrospection): - ORDER_DIR = {ASCENDING: "ASC", DESCENDING: "DESC"} + ORDER_DIR = {ASCENDING: "ASC", DESCENDING: "DESC", GEOSPHERE: "GEO"} def table_names(self, cursor=None, include_views=False): return sorted([x["name"] for x in self.connection.database.list_collections()]) diff --git a/django_mongodb_backend/operations.py b/django_mongodb_backend/operations.py index b90a4cc52..6df845e7c 100644 --- a/django_mongodb_backend/operations.py +++ b/django_mongodb_backend/operations.py @@ -6,6 +6,7 @@ from bson.decimal128 import Decimal128 from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.db import DataError from django.db.backends.base.operations import BaseDatabaseOperations from django.db.models import TextField @@ -14,8 +15,15 @@ from django.utils import timezone from django.utils.regex_helper import _lazy_re_compile +try: + from .gis.operations import GISOperations +except ImproperlyConfigured: + # GIS libraries not installed + class GISOperations: + pass -class DatabaseOperations(BaseDatabaseOperations): + +class DatabaseOperations(GISOperations, BaseDatabaseOperations): compiler_module = "django_mongodb_backend.compiler" combine_operators = { Combinable.ADD: "add", diff --git a/django_mongodb_backend/schema.py b/django_mongodb_backend/schema.py index a12086a6e..9472db962 100644 --- a/django_mongodb_backend/schema.py +++ b/django_mongodb_backend/schema.py @@ -5,6 +5,7 @@ from django_mongodb_backend.indexes import SearchIndex from .fields import EmbeddedModelField +from .gis.schema import GISSchemaEditor from .query import wrap_database_errors from .utils import OperationCollector @@ -27,7 +28,7 @@ def wrapper(self, model, *args, **kwargs): return wrapper -class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): +class BaseSchemaEditor(BaseDatabaseSchemaEditor): def get_collection(self, name): if self.collect_sql: return OperationCollector(self.collected_sql, collection=self.connection.database[name]) @@ -418,3 +419,8 @@ def _field_should_have_unique(self, field): db_type = field.db_type(self.connection) # The _id column is automatically unique. return db_type and field.unique and field.column != "_id" + + +# GISSchemaEditor extends some SchemaEditor methods. +class DatabaseSchemaEditor(GISSchemaEditor, BaseSchemaEditor): + pass diff --git a/docs/source/index.rst b/docs/source/index.rst index 9e0243487..74dd7bd12 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -41,6 +41,7 @@ Models - :doc:`ref/models/models` - :doc:`ref/models/indexes` - :doc:`ref/database` +- :doc:`ref/contrib/gis` **Topic guides:** diff --git a/docs/source/ref/contrib/gis.rst b/docs/source/ref/contrib/gis.rst new file mode 100644 index 000000000..4d91e4699 --- /dev/null +++ b/docs/source/ref/contrib/gis.rst @@ -0,0 +1,26 @@ +========= +GeoDjango +========= + +.. versionadded:: 5.2.0b2 + +Django MongoDB Backend supports :doc:`GeoDjango`. + +Configuration +============= + +#. Install the necessary :doc:`Geospatial libraries + ` (GEOS and GDAL). +#. Add :mod:`django.contrib.gis` to :setting:`INSTALLED_APPS` in your settings. + This is so that the ``gis`` templates can be located -- if not done, then + features such as the geographic admin or KML sitemaps will not function properly. + +Limitations +=========== + +- MongoDB doesn't support any spatial reference system identifiers + (:attr:`BaseSpatialField.srid `) + besides 4326 (WGS84) . +- None of the :doc:`GIS QuerySet APIs ` (lookups, + aggregates, and database functions) are supported. +- :class:`~django.contrib.gis.db.models.RasterField` isn't supported. diff --git a/docs/source/ref/contrib/index.rst b/docs/source/ref/contrib/index.rst new file mode 100644 index 000000000..82cb1b88f --- /dev/null +++ b/docs/source/ref/contrib/index.rst @@ -0,0 +1,10 @@ +==================== +``contrib`` packages +==================== + +Notes for Django's :doc:`django:ref/contrib/index` live here. + +.. toctree:: + :maxdepth: 1 + + gis diff --git a/docs/source/ref/index.rst b/docs/source/ref/index.rst index ce12d8d2f..94a11a2a8 100644 --- a/docs/source/ref/index.rst +++ b/docs/source/ref/index.rst @@ -7,6 +7,7 @@ API reference models/index forms + contrib/index database django-admin utils diff --git a/docs/source/releases/5.2.x.rst b/docs/source/releases/5.2.x.rst index d7a86aca9..cc9b64187 100644 --- a/docs/source/releases/5.2.x.rst +++ b/docs/source/releases/5.2.x.rst @@ -16,6 +16,7 @@ New features - Added :class:`~.fields.PolymorphicEmbeddedModelField` and :class:`~.fields.PolymorphicEmbeddedModelArrayField` for storing a model instance or list of model instances that may be of more than one model class. +- Added :doc:`GeoDjango support `. Bug fixes --------- diff --git a/tests/gis_tests_/__init__.py b/tests/gis_tests_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/gis_tests_/models.py b/tests/gis_tests_/models.py new file mode 100644 index 000000000..cec1e0a93 --- /dev/null +++ b/tests/gis_tests_/models.py @@ -0,0 +1,5 @@ +from django.contrib.gis.db import models + + +class City(models.Model): + point = models.PointField() diff --git a/tests/gis_tests_/tests.py b/tests/gis_tests_/tests.py new file mode 100644 index 000000000..014b8e5e9 --- /dev/null +++ b/tests/gis_tests_/tests.py @@ -0,0 +1,13 @@ +from django.contrib.gis.geos import Point +from django.db import NotSupportedError +from django.test import TestCase, skipUnlessDBFeature + +from .models import City + + +@skipUnlessDBFeature("gis_enabled") +class LookupTests(TestCase): + def test_unsupported_lookups(self): + msg = "MongoDB does not support the same_as lookup." + with self.assertRaisesMessage(NotSupportedError, msg): + City.objects.get(point__same_as=Point(95, 30)) diff --git a/tests/schema_/test_embedded_model.py b/tests/schema_/test_embedded_model.py index 2ae6108a9..c6c926031 100644 --- a/tests/schema_/test_embedded_model.py +++ b/tests/schema_/test_embedded_model.py @@ -1,7 +1,7 @@ import itertools from django.db import connection, models -from django.test import TransactionTestCase +from django.test import TransactionTestCase, skipUnlessDBFeature from django.test.utils import isolate_apps from django_mongodb_backend.fields import EmbeddedModelField @@ -552,3 +552,41 @@ def test_embedded_alter_field_ignored(self): new_field.set_attributes_from_name("new") with connection.schema_editor() as editor, self.assertNumQueries(0): editor.alter_field(Author, old_field, new_field) + + +@skipUnlessDBFeature("gis_enabled") +class GISTests(TestMixin, TransactionTestCase): + @isolate_apps("schema_") + def test_create_model(self): + """ + Spatial indexes for embedded GIS fields are created when the collections are + created. + """ + from django.contrib.gis.db.models import PointField # noqa: PLC0415 + + class Place(EmbeddedModel): + name = models.CharField(max_length=10) + location = PointField() + + class Meta: + app_label = "schema_" + + class Author(models.Model): + birthplace = EmbeddedModelField(Place) + + class Meta: + app_label = "schema_" + + with connection.schema_editor() as editor: + # Create the table + editor.create_model(Author) + self.assertTableExists(Author) + # The embedded GEO indexes is created. + constraint_name = "schema__author_birthplace.location_id" + self.assertEqual( + self.get_constraints_for_columns(Author, ["birthplace.location"]), + [constraint_name], + ) + self.assertIndexOrder(Author._meta.db_table, constraint_name, ["GEO"]) + editor.delete_model(Author) + self.assertTableNotExists(Author)