Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #5805 -- it is now possible to specify multi-column indexes. Th…

…anks to jgelens for the original patch.
  • Loading branch information...
commit 4285571c5a9bf6ca3cb7c4d774942b9ae5b537e4 1 parent 249c3d7
authored November 04, 2012
35  django/core/management/validation.py
... ...
@@ -1,3 +1,4 @@
  1
+import collections
1 2
 import sys
2 3
 
3 4
 from django.conf import settings
@@ -327,15 +328,29 @@ def get_validation_errors(outfile, app=None):
327 328
 
328 329
         # Check unique_together.
329 330
         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)
340 337
 
341 338
     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))
51  django/db/backends/creation.py
@@ -177,34 +177,47 @@ def sql_indexes_for_model(self, model, style):
177 177
         output = []
178 178
         for f in model._meta.local_fields:
179 179
             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))
180 183
         return output
181 184
 
182 185
     def sql_indexes_for_field(self, model, f, style):
183 186
         """
184 187
         Return the CREATE INDEX SQL statements for a single model field.
185 188
         """
  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):
186 195
         from django.db.backends.util import truncate_name
187 196
 
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)
205 201
         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
+        ]
208 221
 
209 222
     def sql_destroy_model(self, model, references_to_delete, style):
210 223
         """
4  django/db/models/options.py
@@ -21,7 +21,8 @@
21 21
 DEFAULT_NAMES = ('verbose_name', 'verbose_name_plural', 'db_table', 'ordering',
22 22
                  'unique_together', 'permissions', 'get_latest_by',
23 23
                  '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')
25 26
 
26 27
 
27 28
 @python_2_unicode_compatible
@@ -34,6 +35,7 @@ def __init__(self, meta, app_label=None):
34 35
         self.db_table = ''
35 36
         self.ordering = []
36 37
         self.unique_together = []
  38
+        self.index_together = []
37 39
         self.permissions = []
38 40
         self.object_name, self.app_label = None, app_label
39 41
         self.get_latest_by = None
15  docs/ref/models/options.txt
@@ -261,6 +261,21 @@ Django quotes column and table names behind the scenes.
261 261
     :class:`~django.db.models.ManyToManyField`, try using a signal or
262 262
     an explicit :attr:`through <ManyToManyField.through>` model.
263 263
 
  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
+
264 279
 ``verbose_name``
265 280
 ----------------
266 281
 
8  tests/modeltests/invalid_models/invalid_models/models.py
@@ -356,6 +356,13 @@ class HardReferenceModel(models.Model):
356 356
     m2m_4 = models.ManyToManyField('invalid_models.SwappedModel', related_name='m2m_hardref4')
357 357
 
358 358
 
  359
+class BadIndexTogether1(models.Model):
  360
+    class Meta:
  361
+        index_together = [
  362
+            ["field_that_does_not_exist"],
  363
+        ]
  364
+
  365
+
359 366
 model_errors = """invalid_models.fielderrors: "charfield": CharFields require a "max_length" attribute that is a positive integer.
360 367
 invalid_models.fielderrors: "charfield2": CharFields require a "max_length" attribute that is a positive integer.
361 368
 invalid_models.fielderrors: "charfield3": CharFields require a "max_length" attribute that is a positive integer.
@@ -470,6 +477,7 @@ class HardReferenceModel(models.Model):
470 477
 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.
471 478
 invalid_models.badswappablevalue: TEST_SWAPPED_MODEL_BAD_VALUE is not of the form 'app_label.app_name'.
472 479
 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.
473 481
 """
474 482
 
475 483
 if not connection.features.interprets_empty_strings_as_nulls:
0  tests/regressiontests/indexes/__init__.py
No changes.
11  tests/regressiontests/indexes/models.py
... ...
@@ -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
+        ]
12  tests/regressiontests/indexes/tests.py
... ...
@@ -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)
7  tests/regressiontests/initial_sql_regress/tests.py
... ...
@@ -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
1 4
 from django.test import TestCase
2 5
 
3 6
 from .models import Simple
@@ -15,10 +18,6 @@ def test_initial_sql(self):
15 18
         self.assertEqual(Simple.objects.count(), 0)
16 19
 
17 20
     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  
-
22 21
         # Simulate the custom SQL loading by syncdb
23 22
         connection = connections[DEFAULT_DB_ALIAS]
24 23
         custom_sql = custom_sql_for_model(Simple, no_style(), connection)
4  tests/regressiontests/introspection/models.py
@@ -17,6 +17,7 @@ class Meta:
17 17
     def __str__(self):
18 18
         return "%s %s" % (self.first_name, self.last_name)
19 19
 
  20
+
20 21
 @python_2_unicode_compatible
21 22
 class Article(models.Model):
22 23
     headline = models.CharField(max_length=100)
@@ -28,3 +29,6 @@ def __str__(self):
28 29
 
29 30
     class Meta:
30 31
         ordering = ('headline',)
  32
+        index_together = [
  33
+            ["headline", "pub_date"],
  34
+        ]
12  tests/regressiontests/introspection/tests.py
... ...
@@ -1,4 +1,4 @@
1  
-from __future__ import absolute_import,unicode_literals
  1
+from __future__ import absolute_import, unicode_literals
2 2
 
3 3
 from functools import update_wrapper
4 4
 
@@ -13,7 +13,7 @@
13 13
 else:
14 14
     expectedFailureOnOracle = lambda f: f
15 15
 
16  
-#
  16
+
17 17
 # The introspection module is optional, so methods tested here might raise
18 18
 # NotImplementedError. This is perfectly acceptable behavior for the backend
19 19
 # in question, but the tests need to handle this without failing. Ideally we'd
@@ -23,7 +23,7 @@
23 23
 # wrapper that ignores the exception.
24 24
 #
25 25
 # The metaclass is just for fun.
26  
-#
  26
+
27 27
 
28 28
 def ignore_not_implemented(func):
29 29
     def _inner(*args, **kwargs):
@@ -34,15 +34,16 @@ def _inner(*args, **kwargs):
34 34
     update_wrapper(_inner, func)
35 35
     return _inner
36 36
 
  37
+
37 38
 class IgnoreNotimplementedError(type):
38 39
     def __new__(cls, name, bases, attrs):
39  
-        for k,v in attrs.items():
  40
+        for k, v in attrs.items():
40 41
             if k.startswith('test'):
41 42
                 attrs[k] = ignore_not_implemented(v)
42 43
         return type.__new__(cls, name, bases, attrs)
43 44
 
44  
-class IntrospectionTests(six.with_metaclass(IgnoreNotimplementedError, TestCase)):
45 45
 
  46
+class IntrospectionTests(six.with_metaclass(IgnoreNotimplementedError, TestCase)):
46 47
     def test_table_names(self):
47 48
         tl = connection.introspection.table_names()
48 49
         self.assertEqual(tl, sorted(tl))
@@ -163,6 +164,7 @@ def test_get_indexes_multicol(self):
163 164
         self.assertNotIn('first_name', indexes)
164 165
         self.assertIn('id', indexes)
165 166
 
  167
+
166 168
 def datatype(dbtype, description):
167 169
     """Helper to convert a data type into a string."""
168 170
     dt = connection.introspection.get_field_type(dbtype, description)
2  tests/runtests.py
@@ -277,7 +277,7 @@ def paired_tests(paired_test, options, test_labels):
277 277
     usage = "%prog [options] [module module module ...]"
278 278
     parser = OptionParser(usage=usage)
279 279
     parser.add_option(
280  
-        '-v','--verbosity', action='store', dest='verbosity', default='1',
  280
+        '-v', '--verbosity', action='store', dest='verbosity', default='1',
281 281
         type='choice', choices=['0', '1', '2', '3'],
282 282
         help='Verbosity level; 0=minimal output, 1=normal output, 2=all '
283 283
              'output')

0 notes on commit 4285571

Please sign in to comment.
Something went wrong with that request. Please try again.