diff --git a/django_mongodb/features.py b/django_mongodb/features.py index 190f87b77..891f30cb7 100644 --- a/django_mongodb/features.py +++ b/django_mongodb/features.py @@ -70,39 +70,16 @@ class DatabaseFeatures(BaseDatabaseFeatures): "backends.tests.ThreadTests.test_closing_non_shared_connections", "backends.tests.ThreadTests.test_default_connection_thread_local", # AddField - "schema.tests.SchemaTests.test_add_indexed_charfield", "schema.tests.SchemaTests.test_add_unique_charfield", - # Add/RemoveIndex - "migrations.test_operations.OperationTests.test_add_index", - "migrations.test_operations.OperationTests.test_alter_field_with_index", - "migrations.test_operations.OperationTests.test_remove_index", - "migrations.test_operations.OperationTests.test_rename_index", - "migrations.test_operations.OperationTests.test_rename_index_unknown_unnamed_index", - "migrations.test_operations.OperationTests.test_rename_index_unnamed_index", - "schema.tests.SchemaTests.test_add_remove_index", - "schema.tests.SchemaTests.test_composed_desc_index_with_fk", - "schema.tests.SchemaTests.test_composed_index_with_fk", - "schema.tests.SchemaTests.test_create_index_together", - "schema.tests.SchemaTests.test_order_index", - "schema.tests.SchemaTests.test_text_field_with_db_index", # AlterField - "schema.tests.SchemaTests.test_alter_field_add_index_to_integerfield", - "schema.tests.SchemaTests.test_alter_field_fk_keeps_index", "schema.tests.SchemaTests.test_alter_field_fk_to_o2o", "schema.tests.SchemaTests.test_alter_field_o2o_keeps_unique", "schema.tests.SchemaTests.test_alter_field_o2o_to_fk", "schema.tests.SchemaTests.test_alter_int_pk_to_int_unique", - "schema.tests.SchemaTests.test_alter_not_unique_field_to_primary_key", - # AlterField (db_index) - "schema.tests.SchemaTests.test_alter_renames_index", - "schema.tests.SchemaTests.test_indexes", - "schema.tests.SchemaTests.test_remove_db_index_doesnt_remove_custom_indexes", # AlterField (unique) + "schema.tests.SchemaTests.test_indexes", "schema.tests.SchemaTests.test_unique", "schema.tests.SchemaTests.test_unique_and_reverse_m2m", - # alter_index_together - "migrations.test_operations.OperationTests.test_alter_index_together", - "schema.tests.SchemaTests.test_index_together", # alter_unique_together "migrations.test_operations.OperationTests.test_alter_unique_together", "schema.tests.SchemaTests.test_unique_together", @@ -114,9 +91,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "schema.tests.SchemaTests.test_composed_constraint_with_fk", "schema.tests.SchemaTests.test_remove_ignored_unique_constraint_not_create_fk_index", "schema.tests.SchemaTests.test_unique_constraint", - # subclasses of BaseDatabaseIntrospection may require a get_constraints() method - "migrations.test_operations.OperationTests.test_add_func_unique_constraint", - "migrations.test_operations.OperationTests.test_remove_func_unique_constraint", } # $bitAnd, #bitOr, and $bitXor are new in MongoDB 6.3. _django_test_expected_failures_bitwise = { @@ -220,6 +194,7 @@ def django_test_expected_failures(self): "get_or_create.tests.GetOrCreateThroughManyToMany.test_something", "get_or_create.tests.UpdateOrCreateTests.test_manual_primary_key_test", "get_or_create.tests.UpdateOrCreateTestsWithManualPKs.test_create_with_duplicate_primary_key", + "introspection.tests.IntrospectionTests.test_get_constraints_unique_indexes_orders", "model_fields.test_filefield.FileFieldTests.test_unique_when_same_filename", "one_to_one.tests.OneToOneTests.test_multiple_o2o", "queries.test_bulk_update.BulkUpdateTests.test_database_routing_batch_atomicity", @@ -613,14 +588,8 @@ def django_test_expected_failures(self): "introspection.tests.IntrospectionTests.test_get_table_description_types", "introspection.tests.IntrospectionTests.test_smallautofield", }, - "DatabaseIntrospection.get_constraints() not implemented.": { - "introspection.tests.IntrospectionTests.test_get_constraints", - "introspection.tests.IntrospectionTests.test_get_constraints_index_types", - "introspection.tests.IntrospectionTests.test_get_constraints_indexes_orders", - "introspection.tests.IntrospectionTests.test_get_constraints_unique_indexes_orders", - "introspection.tests.IntrospectionTests.test_get_primary_key_column", - }, "MongoDB can't introspect primary key.": { + "introspection.tests.IntrospectionTests.test_get_primary_key_column", "schema.tests.SchemaTests.test_alter_primary_key_the_same_name", "schema.tests.SchemaTests.test_primary_key", }, diff --git a/django_mongodb/introspection.py b/django_mongodb/introspection.py index 59cb1f41a..fee0f5fea 100644 --- a/django_mongodb/introspection.py +++ b/django_mongodb/introspection.py @@ -1,6 +1,32 @@ from django.db.backends.base.introspection import BaseDatabaseIntrospection +from django.db.models import Index +from pymongo import ASCENDING, DESCENDING class DatabaseIntrospection(BaseDatabaseIntrospection): + ORDER_DIR = {ASCENDING: "ASC", DESCENDING: "DESC"} + def table_names(self, cursor=None, include_views=False): return sorted([x["name"] for x in self.connection.database.list_collections()]) + + def get_constraints(self, cursor, table_name): + indexes = self.connection.get_collection(table_name).index_information() + constraints = {} + for name, details in indexes.items(): + # Remove underscore prefix from "_id" columns in primary key index. + if is_primary_key := name == "_id_": + name = "id" + details["key"] = [("id", 1)] + constraints[name] = { + "check": False, + "columns": [field for field, order in details["key"]], + "definition": None, + "foreign_key": None, + "index": True, + "orders": [self.ORDER_DIR[order] for field, order in details["key"]], + "primary_key": is_primary_key, + "type": Index.suffix, + "unique": details.get("unique", False), + "options": {}, + } + return constraints diff --git a/django_mongodb/schema.py b/django_mongodb/schema.py index 62655659c..7cc8fe6d2 100644 --- a/django_mongodb/schema.py +++ b/django_mongodb/schema.py @@ -1,4 +1,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.models import Index +from pymongo import ASCENDING, DESCENDING +from pymongo.operations import IndexModel from .query import wrap_database_errors from .utils import OperationCollector @@ -18,11 +21,30 @@ def get_database(self): @wrap_database_errors def create_model(self, model): self.get_database().create_collection(model._meta.db_table) + self._create_model_indexes(model) # Make implicit M2M tables. for field in model._meta.local_many_to_many: if field.remote_field.through._meta.auto_created: self.create_model(field.remote_field.through) + def _create_model_indexes(self, model): + """ + Create all indexes (field indexes, index_together, Meta.indexes) for + the specified model. + """ + if not model._meta.managed or model._meta.proxy or model._meta.swapped: + return + # Field indexes + for field in model._meta.local_fields: + if self._field_should_be_indexed(model, field): + self._add_field_index(model, field) + # Meta.index_together (RemovedInDjango51Warning) + for field_names in model._meta.index_together: + self._add_composed_index(model, field_names) + # Meta.indexes + for index in model._meta.indexes: + self.add_index(model, index) + def delete_model(self, model): # Delete implicit M2m tables. for field in model._meta.local_many_to_many: @@ -40,6 +62,9 @@ def add_field(self, model, field): self.get_collection(model._meta.db_table).update_many( {}, [{"$set": {column: self.effective_default(field)}}] ) + # Add an index, if required. + if self._field_should_be_indexed(model, field): + self._add_field_index(model, field) def _alter_field( self, @@ -53,15 +78,27 @@ def _alter_field( strict=False, ): collection = self.get_collection(model._meta.db_table) + # Removed an index? + old_field_indexed = self._field_should_be_indexed(model, old_field) + new_field_indexed = self._field_should_be_indexed(model, new_field) + if old_field_indexed and not new_field_indexed: + self._remove_field_index(model, old_field) # Have they renamed the column? if old_field.column != new_field.column: collection.update_many({}, {"$rename": {old_field.column: new_field.column}}) + # Move index to the new field, if needed. + if old_field_indexed and new_field_indexed: + self._remove_field_index(model, old_field) + self._add_field_index(model, new_field) # Replace NULL with the field default if the field and was changed from # NULL to NOT NULL. if new_field.has_default() and old_field.null and not new_field.null: column = new_field.column default = self.effective_default(new_field) collection.update_many({column: {"$eq": None}}, [{"$set": {column: default}}]) + # Added an index? + if not old_field_indexed and new_field_indexed: + self._add_field_index(model, new_field) def remove_field(self, model, field): # Remove implicit M2M tables. @@ -71,21 +108,99 @@ def remove_field(self, model, field): # Unset field on existing documents. if column := field.column: self.get_collection(model._meta.db_table).update_many({}, {"$unset": {column: ""}}) + if self._field_should_be_indexed(model, field): + self._remove_field_index(model, field) def alter_index_together(self, model, old_index_together, new_index_together): - pass + olds = {tuple(fields) for fields in old_index_together} + news = {tuple(fields) for fields in new_index_together} + # Deleted indexes + for field_names in olds.difference(news): + self._remove_composed_index(model, field_names, {"index": True, "unique": False}) + # Created indexes + for field_names in news.difference(olds): + self._add_composed_index(model, field_names) def alter_unique_together(self, model, old_unique_together, new_unique_together): pass - def add_index(self, model, index): - pass - - def rename_index(self, model, old_index, new_index): - pass + def add_index(self, model, index, field=None): + if index.contains_expressions: + return + index_orders = ( + [(field.column, ASCENDING)] + if field + else [ + # order is "" if ASCENDING or "DESC" if DESCENDING (see + # django.db.models.indexes.Index.fields_orders). + (model._meta.get_field(field_name).column, ASCENDING if order == "" else DESCENDING) + for field_name, order in index.fields_orders + ] + ) + idx = IndexModel(index_orders, name=index.name) + self.get_collection(model._meta.db_table).create_indexes([idx]) + + def _add_composed_index(self, model, field_names): + """Add an index on the given list of field_names.""" + idx = Index(fields=field_names) + idx.set_name_with_model(model) + self.add_index(model, idx) + + def _add_field_index(self, model, field): + """Add an index on a field with db_index=True.""" + index = Index(fields=[field.name]) + index.name = self._create_index_name(model._meta.db_table, [field.column]) + self.add_index(model, index, field=field) def remove_index(self, model, index): - pass + if index.contains_expressions: + return + self.get_collection(model._meta.db_table).drop_index(index.name) + + def _remove_composed_index(self, model, field_names, constraint_kwargs): + """ + Remove the index on the given list of field_names created by + index/unique_together, depending on constraint_kwargs. + """ + meta_constraint_names = {constraint.name for constraint in model._meta.constraints} + meta_index_names = {constraint.name for constraint in model._meta.indexes} + columns = [model._meta.get_field(field).column for field in field_names] + constraint_names = self._constraint_names( + model, + columns, + exclude=meta_constraint_names | meta_index_names, + **constraint_kwargs, + ) + if len(constraint_names) != 1: + num_found = len(constraint_names) + columns_str = ", ".join(columns) + raise ValueError( + f"Found wrong number ({num_found}) of constraints for " + f"{model._meta.db_table}({columns_str})." + ) + collection = self.get_collection(model._meta.db_table) + collection.drop_index(constraint_names[0]) + + def _remove_field_index(self, model, field): + """Remove a field's db_index=True index.""" + collection = self.get_collection(model._meta.db_table) + meta_index_names = {index.name for index in model._meta.indexes} + index_names = self._constraint_names( + model, + [field.column], + index=True, + # Retrieve only BTREE indexes since this is what's created with + # db_index=True. + type_=Index.suffix, + exclude=meta_index_names, + ) + if len(index_names) != 1: + num_found = len(index_names) + raise ValueError( + f"Found wrong number ({num_found}) of constraints for " + f"{model._meta.db_table}.{field.column}." + ) + collection.drop_index(index_names[0]) def add_constraint(self, model, constraint): pass diff --git a/django_mongodb/utils.py b/django_mongodb/utils.py index 24bc5b23a..b4d87cc7d 100644 --- a/django_mongodb/utils.py +++ b/django_mongodb/utils.py @@ -40,9 +40,12 @@ class OperationDebugWrapper: wrapped_methods = { "aggregate", "create_collection", + "create_indexes", "drop", + "index_information", "insert_many", "delete_many", + "drop_index", "rename", "update_many", }