Skip to content

Commit 18ee639

Browse files
committed
Support index definition on Embedded Models in top level model.
1 parent b3a4245 commit 18ee639

File tree

8 files changed

+256
-4
lines changed

8 files changed

+256
-4
lines changed

django_mongodb_backend/features.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class DatabaseFeatures(GISFeatures, BaseDatabaseFeatures):
106106
"contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_mixed_scenario",
107107
"contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_respects_mixed_manual_order",
108108
"contenttypes_tests.test_order_with_respect_to.OrderWithRespectToGFKTests.test_bulk_create_with_existing_children",
109+
# Simple expression index are supported
110+
"schema.tests.SchemaTests.test_func_unique_constraint_unsupported",
111+
"schema.tests.SchemaTests.test_func_index_unsupported",
109112
}
110113

111114
@cached_property

django_mongodb_backend/fields/embedded_model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ def as_mql_path(self, compiler, connection):
211211
def output_field(self):
212212
return self._field
213213

214+
def db_type(self, connection):
215+
return self.output_field.db_type(connection)
216+
214217
@property
215218
def can_use_path(self):
216219
return self.is_simple_column

django_mongodb_backend/indexes.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
from django.core.checks import Error, Warning
55
from django.db import NotSupportedError
66
from django.db.models import FloatField, Index, IntegerField
7+
from django.db.models.expressions import OrderBy
8+
from django.db.models.indexes import IndexExpression
79
from django.db.models.lookups import BuiltinLookup
810
from django.db.models.sql.query import Query
911
from django.db.models.sql.where import AND, XOR, WhereNode
1012
from pymongo import ASCENDING, DESCENDING
1113
from pymongo.operations import IndexModel, SearchIndexModel
1214

1315
from django_mongodb_backend.fields import ArrayField
16+
from django_mongodb_backend.query_utils import process_lhs
1417

1518
from .query_utils import process_rhs
1619

@@ -46,10 +49,29 @@ def builtin_lookup_idx(self, compiler, connection):
4649

4750
def get_pymongo_index_model(self, model, schema_editor, field=None, unique=False, column_prefix=""):
4851
"""Return a pymongo IndexModel for this Django Index."""
52+
filter_expression = defaultdict(dict)
53+
expressions_fields = []
4954
if self.contains_expressions:
50-
return None
55+
query = Query(model=model, alias_cols=False)
56+
compiler = query.get_compiler(connection=schema_editor.connection)
57+
for expression in self.expressions:
58+
field_ = expression.resolve_expression(query)
59+
column = field_.as_mql(compiler, schema_editor.connection)
60+
db_type = (
61+
field_.expression.db_type(schema_editor.connection)
62+
if isinstance(field_, OrderBy)
63+
else field_.output_field.db_type(schema_editor.connection)
64+
)
65+
if unique:
66+
filter_expression[column].update({"$type": db_type})
67+
order = (
68+
DESCENDING
69+
if isinstance(expression, OrderBy) and expression.descending
70+
else ASCENDING
71+
)
72+
expressions_fields.append((column, order))
73+
5174
kwargs = {}
52-
filter_expression = defaultdict(dict)
5375
if self.condition:
5476
filter_expression.update(self._get_condition_mql(model, schema_editor))
5577
if unique:
@@ -80,7 +102,7 @@ def get_pymongo_index_model(self, model, schema_editor, field=None, unique=False
80102
for field_name, order in self.fields_orders
81103
]
82104
)
83-
return IndexModel(index_orders, name=self.name, **kwargs)
105+
return IndexModel(expressions_fields + index_orders, name=self.name, **kwargs)
84106

85107

86108
def where_node_idx(self, compiler, connection):
@@ -322,4 +344,5 @@ def register_indexes():
322344
BuiltinLookup.as_mql_idx = builtin_lookup_idx
323345
Index._get_condition_mql = _get_condition_mql
324346
Index.get_pymongo_index_model = get_pymongo_index_model
347+
IndexExpression.as_mql = process_lhs
325348
WhereNode.as_mql_idx = where_node_idx

django_mongodb_backend/schema.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
44
from django.db.models import Index, UniqueConstraint
5+
from django.db.models.expressions import F, OrderBy
56
from pymongo.operations import SearchIndexModel
67

78
from django_mongodb_backend.indexes import SearchIndex
@@ -351,6 +352,37 @@ def _remove_field_index(self, model, field, column_prefix=""):
351352
)
352353
collection.drop_index(index_names[0])
353354

355+
def _check_expression_indexes_applicable(self, expressions):
356+
# Check if all expressions are field references or ORDER BY expressions on a field.
357+
return all(
358+
isinstance(expression.expression if isinstance(expression, OrderBy) else expression, F)
359+
for expression in expressions
360+
)
361+
362+
def _unique_supported(
363+
self,
364+
condition=None,
365+
deferrable=None,
366+
include=None,
367+
expressions=None,
368+
nulls_distinct=None,
369+
):
370+
return (
371+
(not condition or self.connection.features.supports_partial_indexes)
372+
and (not deferrable or self.connection.features.supports_deferrable_unique_constraints)
373+
and (not include or self.connection.features.supports_covering_indexes)
374+
and (
375+
not expressions
376+
or self.connection.features.supports_expression_indexes
377+
# Expression indexes are partially supported.
378+
or self._check_expression_indexes_applicable(expressions)
379+
)
380+
and (
381+
nulls_distinct is None
382+
or self.connection.features.supports_nulls_distinct_unique_constraints
383+
)
384+
)
385+
354386
@ignore_embedded_models
355387
def add_constraint(self, model, constraint, field=None, column_prefix="", parent_model=None):
356388
if isinstance(constraint, UniqueConstraint) and self._unique_supported(
@@ -361,6 +393,7 @@ def add_constraint(self, model, constraint, field=None, column_prefix="", parent
361393
nulls_distinct=constraint.nulls_distinct,
362394
):
363395
idx = Index(
396+
*constraint.expressions,
364397
fields=constraint.fields,
365398
name=constraint.name,
366399
condition=constraint.condition,
@@ -391,6 +424,7 @@ def remove_constraint(self, model, constraint):
391424
nulls_distinct=constraint.nulls_distinct,
392425
):
393426
idx = Index(
427+
*constraint.expressions,
394428
fields=constraint.fields,
395429
name=constraint.name,
396430
condition=constraint.condition,

docs/releases/5.2.x.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ Django MongoDB Backend 5.2.x
1010
New features
1111
------------
1212

13-
- ...
13+
- Added support for creating indexes from expressions.
14+
Currently, only ``F()`` expressions are supported to reference top-level
15+
model fields inside embedded models.
16+
17+
1418

1519
Bug fixes
1620
---------

docs/topics/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ know:
1111
embedded-models
1212
transactions
1313
known-issues
14+
indexes

docs/topics/indexes.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
Indexes from Expressions
2+
========================
3+
4+
Django MongoDB Backend now supports creating indexes from expressions.
5+
Currently, only ``F()`` expressions are supported, which allows referencing
6+
fields from the top-level model inside embedded fields.
7+
8+
Example::
9+
10+
from django.db import models
11+
from django.db.models import F
12+
13+
class Author(models.EmbeddedModel):
14+
name = models.CharField()
15+
16+
class Book(models.Model):
17+
author = models.EmbeddedField(Author)
18+
19+
class Meta:
20+
indexes = [
21+
models.Index(F("author__name")),
22+
]

tests/schema_/test_embedded_model.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import itertools
22

33
from django.db import connection, models
4+
from django.db.models.expressions import F
45
from django.test import TransactionTestCase, skipUnlessDBFeature
56
from django.test.utils import isolate_apps
67

@@ -519,6 +520,167 @@ class Meta:
519520
self.assertTableNotExists(Author)
520521

521522

523+
class EmbeddedModelsTopLevelIndexTest(TestMixin, TransactionTestCase):
524+
@isolate_apps("schema_")
525+
def test_unique_together(self):
526+
"""Meta.unique_together defined at the top-level for embedded fields."""
527+
528+
class Address(EmbeddedModel):
529+
unique_together_one = models.CharField(max_length=10)
530+
unique_together_two = models.CharField(max_length=10)
531+
532+
class Meta:
533+
app_label = "schema_"
534+
535+
class Author(EmbeddedModel):
536+
address = EmbeddedModelField(Address)
537+
unique_together_three = models.CharField(max_length=10)
538+
unique_together_four = models.CharField(max_length=10)
539+
540+
class Meta:
541+
app_label = "schema_"
542+
543+
class Book(models.Model):
544+
author = EmbeddedModelField(Author)
545+
546+
class Meta:
547+
app_label = "schema_"
548+
constraints = [
549+
models.UniqueConstraint(
550+
F("author__unique_together_three").asc(),
551+
F("author__unique_together_four").desc(),
552+
name="unique_together_34",
553+
),
554+
(
555+
models.UniqueConstraint(
556+
F("author__address__unique_together_one"),
557+
F("author__address__unique_together_two").asc(),
558+
name="unique_together_12",
559+
)
560+
),
561+
]
562+
563+
with connection.schema_editor() as editor:
564+
editor.create_model(Book)
565+
self.assertTableExists(Book)
566+
# Embedded uniques are created from top-level definition.
567+
self.assertEqual(
568+
self.get_constraints_for_columns(
569+
Book, ["author.unique_together_three", "author.unique_together_four"]
570+
),
571+
["unique_together_34"],
572+
)
573+
self.assertEqual(
574+
self.get_constraints_for_columns(
575+
Book,
576+
["author.address.unique_together_one", "author.address.unique_together_two"],
577+
),
578+
["unique_together_12"],
579+
)
580+
editor.delete_model(Book)
581+
self.assertTableNotExists(Book)
582+
583+
@isolate_apps("schema_")
584+
def test_add_remove_field_indexes(self):
585+
"""AddField/RemoveField + EmbeddedModelField + Meta.indexes at top-level."""
586+
587+
class Address(EmbeddedModel):
588+
indexed_one = models.CharField(max_length=10)
589+
590+
class Meta:
591+
app_label = "schema_"
592+
593+
class Author(EmbeddedModel):
594+
address = EmbeddedModelField(Address)
595+
indexed_two = models.CharField(max_length=10)
596+
597+
class Meta:
598+
app_label = "schema_"
599+
600+
class Book(models.Model):
601+
author = EmbeddedModelField(Author)
602+
603+
class Meta:
604+
app_label = "schema_"
605+
indexes = [
606+
models.Index(F("author__indexed_two").asc(), name="indexed_two"),
607+
models.Index(F("author__address__indexed_one").asc(), name="indexed_one"),
608+
]
609+
610+
new_field = EmbeddedModelField(Author)
611+
new_field.set_attributes_from_name("author")
612+
613+
with connection.schema_editor() as editor:
614+
# Create the table and add the field.
615+
editor.create_model(Book)
616+
editor.add_field(Book, new_field)
617+
# Embedded indexes are created.
618+
self.assertEqual(
619+
self.get_constraints_for_columns(Book, ["author.indexed_two"]),
620+
["indexed_two"],
621+
)
622+
self.assertEqual(
623+
self.get_constraints_for_columns(
624+
Book,
625+
["author.address.indexed_one"],
626+
),
627+
["indexed_one"],
628+
)
629+
editor.delete_model(Book)
630+
self.assertTableNotExists(Book)
631+
632+
@isolate_apps("schema_")
633+
def test_add_remove_field_constraints(self):
634+
"""AddField/RemoveField + EmbeddedModelField + Meta.constraints at top-level."""
635+
636+
class Address(EmbeddedModel):
637+
unique_constraint_one = models.CharField(max_length=10)
638+
639+
class Meta:
640+
app_label = "schema_"
641+
642+
class Author(EmbeddedModel):
643+
address = EmbeddedModelField(Address)
644+
unique_constraint_two = models.CharField(max_length=10)
645+
646+
class Meta:
647+
app_label = "schema_"
648+
649+
class Book(models.Model):
650+
author = EmbeddedModelField(Author)
651+
652+
class Meta:
653+
app_label = "schema_"
654+
constraints = [
655+
models.UniqueConstraint(F("author__unique_constraint_two"), name="unique_two"),
656+
models.UniqueConstraint(
657+
F("author__address__unique_constraint_one"), name="unique_one"
658+
),
659+
]
660+
661+
new_field = EmbeddedModelField(Author)
662+
new_field.set_attributes_from_name("author")
663+
664+
with connection.schema_editor() as editor:
665+
# Create the table and add the field.
666+
editor.create_model(Book)
667+
editor.add_field(Book, new_field)
668+
# Embedded constraints are created.
669+
self.assertEqual(
670+
self.get_constraints_for_columns(Book, ["author.unique_constraint_two"]),
671+
["unique_two"],
672+
)
673+
self.assertEqual(
674+
self.get_constraints_for_columns(
675+
Book,
676+
["author.address.unique_constraint_one"],
677+
),
678+
["unique_one"],
679+
)
680+
editor.delete_model(Book)
681+
self.assertTableNotExists(Book)
682+
683+
522684
class EmbeddedModelsIgnoredTests(TestMixin, TransactionTestCase):
523685
def test_embedded_not_created(self):
524686
"""create_model() and delete_model() ignore EmbeddedModel."""

0 commit comments

Comments
 (0)