Skip to content

Commit 4285571

Browse files
author
Alex Gaynor
committed
Fixed django#5805 -- it is now possible to specify multi-column indexes. Thanks to jgelens for the original patch.
1 parent 249c3d7 commit 4285571

File tree

12 files changed

+121
-40
lines changed

12 files changed

+121
-40
lines changed

django/core/management/validation.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import sys
23

34
from django.conf import settings
@@ -327,15 +328,29 @@ def get_validation_errors(outfile, app=None):
327328

328329
# Check unique_together.
329330
for ut in opts.unique_together:
330-
for field_name in ut:
331-
try:
332-
f = opts.get_field(field_name, many_to_many=True)
333-
except models.FieldDoesNotExist:
334-
e.add(opts, '"unique_together" refers to %s, a field that doesn\'t exist. Check your syntax.' % field_name)
335-
else:
336-
if isinstance(f.rel, models.ManyToManyRel):
337-
e.add(opts, '"unique_together" refers to %s. ManyToManyFields are not supported in unique_together.' % f.name)
338-
if f not in opts.local_fields:
339-
e.add(opts, '"unique_together" refers to %s. This is not in the same model as the unique_together statement.' % f.name)
331+
validate_local_fields(e, opts, "unique_together", ut)
332+
if not isinstance(opts.index_together, collections.Sequence):
333+
e.add(opts, '"index_together" must a sequence')
334+
else:
335+
for it in opts.index_together:
336+
validate_local_fields(e, opts, "index_together", it)
340337

341338
return len(e.errors)
339+
340+
341+
def validate_local_fields(e, opts, field_name, fields):
342+
from django.db import models
343+
344+
if not isinstance(fields, collections.Sequence):
345+
e.add(opts, 'all %s elements must be sequences' % field_name)
346+
else:
347+
for field in fields:
348+
try:
349+
f = opts.get_field(field, many_to_many=True)
350+
except models.FieldDoesNotExist:
351+
e.add(opts, '"%s" refers to %s, a field that doesn\'t exist.' % (field_name, field))
352+
else:
353+
if isinstance(f.rel, models.ManyToManyRel):
354+
e.add(opts, '"%s" refers to %s. ManyToManyFields are not supported in %s.' % (field_name, f.name, field_name))
355+
if f not in opts.local_fields:
356+
e.add(opts, '"%s" refers to %s. This is not in the same model as the %s statement.' % (field_name, f.name, field_name))

django/db/backends/creation.py

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -177,34 +177,47 @@ def sql_indexes_for_model(self, model, style):
177177
output = []
178178
for f in model._meta.local_fields:
179179
output.extend(self.sql_indexes_for_field(model, f, style))
180+
for fs in model._meta.index_together:
181+
fields = [model._meta.get_field_by_name(f)[0] for f in fs]
182+
output.extend(self.sql_indexes_for_fields(model, fields, style))
180183
return output
181184

182185
def sql_indexes_for_field(self, model, f, style):
183186
"""
184187
Return the CREATE INDEX SQL statements for a single model field.
185188
"""
189+
if f.db_index and not f.unique:
190+
return self.sql_indexes_for_fields(model, [f], style)
191+
else:
192+
return []
193+
194+
def sql_indexes_for_fields(self, model, fields, style):
186195
from django.db.backends.util import truncate_name
187196

188-
if f.db_index and not f.unique:
189-
qn = self.connection.ops.quote_name
190-
tablespace = f.db_tablespace or model._meta.db_tablespace
191-
if tablespace:
192-
tablespace_sql = self.connection.ops.tablespace_sql(tablespace)
193-
if tablespace_sql:
194-
tablespace_sql = ' ' + tablespace_sql
195-
else:
196-
tablespace_sql = ''
197-
i_name = '%s_%s' % (model._meta.db_table, self._digest(f.column))
198-
output = [style.SQL_KEYWORD('CREATE INDEX') + ' ' +
199-
style.SQL_TABLE(qn(truncate_name(
200-
i_name, self.connection.ops.max_name_length()))) + ' ' +
201-
style.SQL_KEYWORD('ON') + ' ' +
202-
style.SQL_TABLE(qn(model._meta.db_table)) + ' ' +
203-
"(%s)" % style.SQL_FIELD(qn(f.column)) +
204-
"%s;" % tablespace_sql]
197+
if len(fields) == 1 and fields[0].db_tablespace:
198+
tablespace_sql = self.connection.ops.tablespace_sql(fields[0].db_tablespace)
199+
elif model._meta.db_tablespace:
200+
tablespace_sql = self.connection.ops.tablespace_sql(model._meta.db_tablespace)
205201
else:
206-
output = []
207-
return output
202+
tablespace_sql = ""
203+
if tablespace_sql:
204+
tablespace_sql = " " + tablespace_sql
205+
206+
field_names = []
207+
qn = self.connection.ops.quote_name
208+
for f in fields:
209+
field_names.append(style.SQL_FIELD(qn(f.column)))
210+
211+
index_name = "%s_%s" % (model._meta.db_table, self._digest([f.name for f in fields]))
212+
213+
return [
214+
style.SQL_KEYWORD("CREATE INDEX") + " " +
215+
style.SQL_TABLE(qn(truncate_name(index_name, self.connection.ops.max_name_length()))) + " " +
216+
style.SQL_KEYWORD("ON") + " " +
217+
style.SQL_TABLE(qn(model._meta.db_table)) + " " +
218+
"(%s)" % style.SQL_FIELD(", ".join(field_names)) +
219+
"%s;" % tablespace_sql,
220+
]
208221

209222
def sql_destroy_model(self, model, references_to_delete, style):
210223
"""

django/db/models/options.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
DEFAULT_NAMES = ('verbose_name', 'verbose_name_plural', 'db_table', 'ordering',
2222
'unique_together', 'permissions', 'get_latest_by',
2323
'order_with_respect_to', 'app_label', 'db_tablespace',
24-
'abstract', 'managed', 'proxy', 'swappable', 'auto_created')
24+
'abstract', 'managed', 'proxy', 'swappable', 'auto_created',
25+
'index_together')
2526

2627

2728
@python_2_unicode_compatible
@@ -34,6 +35,7 @@ def __init__(self, meta, app_label=None):
3435
self.db_table = ''
3536
self.ordering = []
3637
self.unique_together = []
38+
self.index_together = []
3739
self.permissions = []
3840
self.object_name, self.app_label = None, app_label
3941
self.get_latest_by = None

docs/ref/models/options.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,21 @@ Django quotes column and table names behind the scenes.
261261
:class:`~django.db.models.ManyToManyField`, try using a signal or
262262
an explicit :attr:`through <ManyToManyField.through>` model.
263263

264+
``index_together``
265+
266+
.. versionadded:: 1.5
267+
268+
.. attribute:: Options.index_together
269+
270+
Sets of field names that, taken together, are indexed::
271+
272+
index_together = [
273+
["pub_date", "deadline"],
274+
]
275+
276+
This list of fields will be indexed together (i.e. the appropriate
277+
``CREATE INDEX`` statement will be issued.)
278+
264279
``verbose_name``
265280
----------------
266281

tests/modeltests/invalid_models/invalid_models/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,13 @@ class HardReferenceModel(models.Model):
356356
m2m_4 = models.ManyToManyField('invalid_models.SwappedModel', related_name='m2m_hardref4')
357357

358358

359+
class BadIndexTogether1(models.Model):
360+
class Meta:
361+
index_together = [
362+
["field_that_does_not_exist"],
363+
]
364+
365+
359366
model_errors = """invalid_models.fielderrors: "charfield": CharFields require a "max_length" attribute that is a positive integer.
360367
invalid_models.fielderrors: "charfield2": CharFields require a "max_length" attribute that is a positive integer.
361368
invalid_models.fielderrors: "charfield3": CharFields require a "max_length" attribute that is a positive integer.
@@ -470,6 +477,7 @@ class HardReferenceModel(models.Model):
470477
invalid_models.hardreferencemodel: 'm2m_4' defines a relation with the model 'invalid_models.SwappedModel', which has been swapped out. Update the relation to point at settings.TEST_SWAPPED_MODEL.
471478
invalid_models.badswappablevalue: TEST_SWAPPED_MODEL_BAD_VALUE is not of the form 'app_label.app_name'.
472479
invalid_models.badswappablemodel: Model has been swapped out for 'not_an_app.Target' which has not been installed or is abstract.
480+
invalid_models.badindextogether1: "index_together" refers to field_that_does_not_exist, a field that doesn't exist.
473481
"""
474482

475483
if not connection.features.interprets_empty_strings_as_nulls:

tests/regressiontests/indexes/__init__.py

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.db import models
2+
3+
4+
class Article(models.Model):
5+
headline = models.CharField(max_length=100)
6+
pub_date = models.DateTimeField()
7+
8+
class Meta:
9+
index_together = [
10+
["headline", "pub_date"],
11+
]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from django.core.management.color import no_style
2+
from django.db import connections, DEFAULT_DB_ALIAS
3+
from django.test import TestCase
4+
5+
from .models import Article
6+
7+
8+
class IndexesTests(TestCase):
9+
def test_index_together(self):
10+
connection = connections[DEFAULT_DB_ALIAS]
11+
index_sql = connection.creation.sql_indexes_for_model(Article, no_style())
12+
self.assertEqual(len(index_sql), 1)

tests/regressiontests/initial_sql_regress/tests.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from django.core.management.color import no_style
2+
from django.core.management.sql import custom_sql_for_model
3+
from django.db import connections, DEFAULT_DB_ALIAS
14
from django.test import TestCase
25

36
from .models import Simple
@@ -15,10 +18,6 @@ def test_initial_sql(self):
1518
self.assertEqual(Simple.objects.count(), 0)
1619

1720
def test_custom_sql(self):
18-
from django.core.management.sql import custom_sql_for_model
19-
from django.core.management.color import no_style
20-
from django.db import connections, DEFAULT_DB_ALIAS
21-
2221
# Simulate the custom SQL loading by syncdb
2322
connection = connections[DEFAULT_DB_ALIAS]
2423
custom_sql = custom_sql_for_model(Simple, no_style(), connection)

tests/regressiontests/introspection/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Meta:
1717
def __str__(self):
1818
return "%s %s" % (self.first_name, self.last_name)
1919

20+
2021
@python_2_unicode_compatible
2122
class Article(models.Model):
2223
headline = models.CharField(max_length=100)
@@ -28,3 +29,6 @@ def __str__(self):
2829

2930
class Meta:
3031
ordering = ('headline',)
32+
index_together = [
33+
["headline", "pub_date"],
34+
]

0 commit comments

Comments
 (0)