diff --git a/django/db/backends/creation.py b/django/db/backends/creation.py
a
|
b
|
|
133 | 133 | (qn(r_table), qn(truncate_name(r_name, self.connection.ops.max_name_length())), |
134 | 134 | qn(r_col), qn(table), qn(col), |
135 | 135 | self.connection.ops.deferrable_sql())) |
136 | 136 | del pending_references[model] |
137 | 137 | return final_output |
138 | 138 | |
139 | 139 | def sql_indexes_for_model(self, model, style): |
140 | 140 | "Returns the CREATE INDEX SQL statements for a single model" |
| 141 | from django.db.backends.util import truncate_name |
| 142 | |
141 | 143 | if not model._meta.managed or model._meta.proxy: |
142 | 144 | return [] |
| 145 | |
| 146 | opts = model._meta |
| 147 | qn = self.connection.ops.quote_name |
143 | 148 | output = [] |
| 149 | |
144 | 150 | for f in model._meta.local_fields: |
145 | 151 | output.extend(self.sql_indexes_for_field(model, f, style)) |
| 152 | |
| 153 | for field_constraints in opts.index_together: |
| 154 | i_name = '%s_%s' % (opts.db_table, self._digest(field_constraints)) |
| 155 | |
| 156 | output.append( |
| 157 | style.SQL_KEYWORD('CREATE INDEX') + ' ' + |
| 158 | style.SQL_TABLE(qn(truncate_name(i_name, self.connection.ops.max_name_length()))) + ' ' + |
| 159 | style.SQL_KEYWORD('ON') + ' ' + |
| 160 | style.SQL_TABLE(qn(opts.db_table)) + ' ' + |
| 161 | "(%s);" % ", ".join([style.SQL_FIELD(qn(opts.get_field(f).column)) for f in field_constraints]) |
| 162 | ) |
| 163 | |
146 | 164 | return output |
147 | 165 | |
148 | 166 | def sql_indexes_for_field(self, model, f, style): |
149 | 167 | "Return the CREATE INDEX SQL statements for a single model field" |
150 | 168 | from django.db.backends.util import truncate_name |
151 | 169 | |
152 | 170 | if f.db_index and not f.unique: |
153 | 171 | qn = self.connection.ops.quote_name |
diff --git a/django/db/models/options.py b/django/db/models/options.py
a
|
b
|
|
12 | 12 | from django.utils.datastructures import SortedDict |
13 | 13 | |
14 | 14 | # Calculate the verbose_name by converting from InitialCaps to "lowercase with spaces". |
15 | 15 | get_verbose_name = lambda class_name: re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).lower().strip() |
16 | 16 | |
17 | 17 | DEFAULT_NAMES = ('verbose_name', 'verbose_name_plural', 'db_table', 'ordering', |
18 | 18 | 'unique_together', 'permissions', 'get_latest_by', |
19 | 19 | 'order_with_respect_to', 'app_label', 'db_tablespace', |
20 | | 'abstract', 'managed', 'proxy', 'auto_created') |
| 20 | 'abstract', 'managed', 'proxy', 'auto_created', 'index_together') |
21 | 21 | |
22 | 22 | class Options(object): |
23 | 23 | def __init__(self, meta, app_label=None): |
24 | 24 | self.local_fields, self.local_many_to_many = [], [] |
25 | 25 | self.virtual_fields = [] |
26 | 26 | self.module_name, self.verbose_name = None, None |
27 | 27 | self.verbose_name_plural = None |
28 | 28 | self.db_table = '' |
29 | 29 | self.ordering = [] |
30 | 30 | self.unique_together = [] |
| 31 | self.index_together = [] |
31 | 32 | self.permissions = [] |
32 | 33 | self.object_name, self.app_label = None, app_label |
33 | 34 | self.get_latest_by = None |
34 | 35 | self.order_with_respect_to = None |
35 | 36 | self.db_tablespace = settings.DEFAULT_TABLESPACE |
36 | 37 | self.admin = None |
37 | 38 | self.meta = meta |
38 | 39 | self.pk = None |
… |
… |
|
75 | 76 | if name.startswith('_'): |
76 | 77 | del meta_attrs[name] |
77 | 78 | for attr_name in DEFAULT_NAMES: |
78 | 79 | if attr_name in meta_attrs: |
79 | 80 | setattr(self, attr_name, meta_attrs.pop(attr_name)) |
80 | 81 | elif hasattr(self.meta, attr_name): |
81 | 82 | setattr(self, attr_name, getattr(self.meta, attr_name)) |
82 | 83 | |
83 | | # unique_together can be either a tuple of tuples, or a single |
| 84 | # unique_together and index_together can be either a tuple of tuples, or a single |
84 | 85 | # tuple of two strings. Normalize it to a tuple of tuples, so that |
85 | 86 | # calling code can uniformly expect that. |
86 | 87 | ut = meta_attrs.pop('unique_together', self.unique_together) |
87 | 88 | if ut and not isinstance(ut[0], (tuple, list)): |
88 | 89 | ut = (ut,) |
89 | 90 | self.unique_together = ut |
90 | 91 | |
| 92 | it = meta_attrs.pop('index_together', self.index_together) |
| 93 | if it and not isinstance(it[0], (tuple, list)): |
| 94 | it = (it,) |
| 95 | self.index_together = it |
| 96 | |
91 | 97 | # verbose_name_plural is a special case because it uses a 's' |
92 | 98 | # by default. |
93 | 99 | if self.verbose_name_plural is None: |
94 | 100 | self.verbose_name_plural = string_concat(self.verbose_name, 's') |
95 | 101 | |
96 | 102 | # Any leftover attributes must be invalid. |
97 | 103 | if meta_attrs != {}: |
98 | 104 | raise TypeError("'class Meta' got invalid attribute(s): %s" % ','.join(meta_attrs.keys())) |
diff --git a/docs/ref/models/options.txt b/docs/ref/models/options.txt
a
|
b
|
|
242 | 242 | appropriate ``UNIQUE`` statements are included in the ``CREATE TABLE`` |
243 | 243 | statement). |
244 | 244 | |
245 | 245 | For convenience, unique_together can be a single list when dealing with a single |
246 | 246 | set of fields:: |
247 | 247 | |
248 | 248 | unique_together = ("driver", "restaurant") |
249 | 249 | |
| 250 | ``index_together`` |
| 251 | ------------------- |
| 252 | |
| 253 | .. versionadded:: 1.4 |
| 254 | |
| 255 | .. attribute:: Options.index_together |
| 256 | |
| 257 | Sets of field names that, taken together, will be indexed:: |
| 258 | |
| 259 | index_together = (("driver", "restaurant"),) |
| 260 | |
| 261 | This is a list of lists of fields that will indexed together. (i.e., the |
| 262 | appropriate ``CREATE INDEX`` statements will be created for this table. |
| 263 | |
| 264 | For convenience, index_together can be a single list when dealing with a single |
| 265 | set of fields:: |
| 266 | |
| 267 | index_together = ("driver", "restaurant") |
| 268 | |
250 | 269 | ``verbose_name`` |
251 | 270 | ---------------- |
252 | 271 | |
253 | 272 | .. attribute:: Options.verbose_name |
254 | 273 | |
255 | 274 | A human-readable name for the object, singular:: |
256 | 275 | |
257 | 276 | verbose_name = "pizza" |
diff --git a/tests/modeltests/basic/models.py b/tests/modeltests/basic/models.py
a
|
b
|
|
8 | 8 | |
9 | 9 | |
10 | 10 | class Article(models.Model): |
11 | 11 | headline = models.CharField(max_length=100, default='Default headline') |
12 | 12 | pub_date = models.DateTimeField() |
13 | 13 | |
14 | 14 | class Meta: |
15 | 15 | ordering = ('pub_date','headline') |
| 16 | index_together = ('headline', 'pub_date') |
16 | 17 | |
17 | 18 | def __unicode__(self): |
18 | 19 | return self.headline |
diff --git a/tests/modeltests/get_or_create/models.py b/tests/modeltests/get_or_create/models.py
a
|
b
|
|
9 | 9 | from django.db import models |
10 | 10 | |
11 | 11 | |
12 | 12 | class Person(models.Model): |
13 | 13 | first_name = models.CharField(max_length=100) |
14 | 14 | last_name = models.CharField(max_length=100) |
15 | 15 | birthday = models.DateField() |
16 | 16 | |
| 17 | class Meta: |
| 18 | index_together = (('first_name', 'last_name'), ('first_name', 'birthday')) |
| 19 | |
17 | 20 | def __unicode__(self): |
18 | 21 | return u'%s %s' % (self.first_name, self.last_name) |
19 | 22 | |
20 | 23 | class ManualPrimaryKeyTest(models.Model): |
21 | 24 | id = models.IntegerField(primary_key=True) |
22 | 25 | data = models.CharField(max_length=100) |