Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions django_mongodb_backend/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,22 @@ class SearchIndex(Index):
suffix = "six"
_error_id_prefix = "django_mongodb_backend.indexes.SearchIndex"

def __init__(self, *, fields=(), name=None):
def __init__(self, *, fields=(), name=None, field_mappings=None):
if field_mappings and not isinstance(field_mappings, dict):
raise ValueError(
"field_mappings must be a dictionary mapping field names to their "
"Atlas Search field mappings."
)
self.field_mappings = field_mappings or {}

fields = list({*fields, *self.field_mappings.keys()})
Copy link
Preview

Copilot AI Aug 19, 2025

Choose a reason for hiding this comment

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

Using set unpacking with {*fields, *self.field_mappings.keys()} may not preserve the original order of fields. Consider using list(dict.fromkeys(list(fields) + list(self.field_mappings.keys()))) to maintain order while removing duplicates.

Suggested change
fields = list({*fields, *self.field_mappings.keys()})
fields = list(dict.fromkeys(list(fields) + list(self.field_mappings.keys())))

Copilot uses AI. Check for mistakes.

super().__init__(fields=fields, name=name)

def deconstruct(self):
path, args, kwargs = super().deconstruct()
kwargs["field_mappings"] = self.field_mappings
Copy link
Collaborator

Choose a reason for hiding this comment

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

if self.field_mappings != {}: (or None check), field_mappings or {} in __init__() is expedient, but unsure if it's the cleanest.

return path, args, kwargs

def check(self, model, connection):
errors = []
if not connection.features.supports_atlas_search:
Expand Down Expand Up @@ -152,23 +165,39 @@ def get_pymongo_index_model(
return None
fields = {}
for field_name, _ in self.fields_orders:
field = model._meta.get_field(field_name)
type_ = self.search_index_data_types(field.db_type(schema_editor.connection))
field_path = column_prefix + model._meta.get_field(field_name).column
fields[field_path] = {"type": type_}
if field_name in self.field_mappings:
fields[field_path] = self.field_mappings[field_name].copy()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why copy()?

Comment on lines +169 to +170
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is field_mappings really supposed to contain the entire mapping? (e.g. "type" too). I'd think it would be more likely to be interpreted as "extra options to add to the field".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, type in the Atlas Search Field Mapping refers to the Atlas Search Field Type. We infer type from our fields, but, for instance, strings can be interpreted as four different types:

  • string (we infer)
  • token
  • stringFacet
  • autocomplete

else:
# If no field mapping is provided, use the default search index data type.
field = model._meta.get_field(field_name)
type_ = self.search_index_data_types(field.db_type(schema_editor.connection))
fields[field_path] = {"type": type_}
return SearchIndexModel(
definition={"mappings": {"dynamic": False, "fields": fields}}, name=self.name
)


class DynamicSearchIndex(SearchIndex):
suffix = "dsix"
_error_id_prefix = "django_mongodb_backend.indexes.DynamicSearchIndex"
Comment on lines +181 to +183
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here I can override the __init__() and have it be something like this:

class DynamicSearchIndex(SearchIndex):
    def __init__(...):
        super().__init__(fields=("id"), name=name, field_mappings=field_mappings)

Overall design should be properly bike-shed either way.


def get_pymongo_index_model(
self, model, schema_editor, field=None, unique=False, column_prefix=""
):
if not schema_editor.connection.features.supports_atlas_search:
return None
return SearchIndexModel(definition={"mappings": {"dynamic": True}}, name=self.name)


class VectorSearchIndex(SearchIndex):
suffix = "vsi"
_error_id_prefix = "django_mongodb_backend.indexes.VectorSearchIndex"
VALID_FIELD_TYPES = frozenset(("boolean", "date", "number", "objectId", "string", "uuid"))
VALID_SIMILARITIES = frozenset(("cosine", "dotProduct", "euclidean"))

def __init__(self, *, fields=(), name=None, similarities):
super().__init__(fields=fields, name=name)
def __init__(self, *, fields=(), name=None, similarities=(), fields_mappings=None):
super().__init__(fields=fields, name=name, field_mappings=fields_mappings)
self.similarities = similarities
self._multiple_similarities = isinstance(similarities, tuple | list)
for func in similarities if self._multiple_similarities else (similarities,):
Expand Down
35 changes: 31 additions & 4 deletions django_mongodb_backend/schema.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from time import monotonic, sleep

from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.models import Index, UniqueConstraint
from pymongo.operations import SearchIndexModel
Expand Down Expand Up @@ -28,6 +30,27 @@ def wrapper(self, model, *args, **kwargs):
return wrapper


def wait_until_index_ready(collection, index_name, timeout: float = 60, interval: float = 0.5):
start = monotonic()
while monotonic() - start < timeout:
indexes = list(collection.list_search_indexes())
for idx in indexes:
if idx["name"] == index_name and idx["status"] == "READY":
return True
sleep(interval)
raise TimeoutError(f"Index {index_name} not ready after {timeout} seconds")


def wait_until_index_delete(collection, index_name, timeout: float = 60, interval: float = 0.5):
start = monotonic()
while monotonic() - start < timeout:
indexes = list(collection.list_search_indexes())
if all(idx["name"] != index_name for idx in indexes):
return True
sleep(interval)
raise TimeoutError(f"Index {index_name} not deleted after {timeout} seconds")


class BaseSchemaEditor(BaseDatabaseSchemaEditor):
def get_collection(self, name):
if self.collect_sql:
Expand Down Expand Up @@ -269,10 +292,12 @@ def add_index(
)
if idx:
model = parent_model or model
collection = self.get_collection(model._meta.db_table)
if isinstance(idx, SearchIndexModel):
self.get_collection(model._meta.db_table).create_search_index(idx)
collection.create_search_index(idx)
wait_until_index_ready(collection, index.name)
else:
self.get_collection(model._meta.db_table).create_indexes([idx])
collection.create_indexes([idx])

def _add_composed_index(self, model, field_names, column_prefix="", parent_model=None):
"""Add an index on the given list of field_names."""
Expand All @@ -290,12 +315,14 @@ def _add_field_index(self, model, field, *, column_prefix=""):
def remove_index(self, model, index):
if index.contains_expressions:
return
collection = self.get_collection(model._meta.db_table)
if isinstance(index, SearchIndex):
# Drop the index if it's supported.
if self.connection.features.supports_atlas_search:
self.get_collection(model._meta.db_table).drop_search_index(index.name)
collection.drop_search_index(index.name)
wait_until_index_delete(collection, index.name)
else:
self.get_collection(model._meta.db_table).drop_index(index.name)
collection.drop_index(index.name)

def _remove_composed_index(
self, model, field_names, constraint_kwargs, column_prefix="", parent_model=None
Expand Down
Loading