diff --git a/AUTHORS b/AUTHORS index 0be13d0c9f..f611c90fae 100644 --- a/AUTHORS +++ b/AUTHORS @@ -330,6 +330,7 @@ answer newbie questions, and generally made Django that much better: Zak Johnson Nis Jørgensen Michael Josephson + Anubhav Joshi jpellerin@gmail.com junzhang.jn@gmail.com Krzysztof Jurewicz diff --git a/django/db/migrations/operations/models.py b/django/db/migrations/operations/models.py index 6444a20401..561a2d81ad 100644 --- a/django/db/migrations/operations/models.py +++ b/django/db/migrations/operations/models.py @@ -1,5 +1,5 @@ from django.db import models, router -from django.db.models.options import normalize_unique_together +from django.db.models.options import normalize_together from django.db.migrations.state import ModelState from django.db.migrations.operations.base import Operation from django.utils import six @@ -183,7 +183,7 @@ class AlterUniqueTogether(Operation): def __init__(self, name, unique_together): self.name = name - unique_together = normalize_unique_together(unique_together) + unique_together = normalize_together(unique_together) self.unique_together = set(tuple(cons) for cons in unique_together) def state_forwards(self, app_label, state): @@ -220,6 +220,7 @@ class AlterIndexTogether(Operation): def __init__(self, name, index_together): self.name = name + index_together = normalize_together(index_together) self.index_together = set(tuple(cons) for cons in index_together) def state_forwards(self, app_label, state): diff --git a/django/db/migrations/state.py b/django/db/migrations/state.py index 3e73744091..76fc42d368 100644 --- a/django/db/migrations/state.py +++ b/django/db/migrations/state.py @@ -1,7 +1,7 @@ from django.apps import AppConfig from django.apps.registry import Apps from django.db import models -from django.db.models.options import DEFAULT_NAMES, normalize_unique_together +from django.db.models.options import DEFAULT_NAMES, normalize_together from django.utils import six from django.utils.module_loading import import_string @@ -145,7 +145,10 @@ class ModelState(object): elif name in model._meta.original_attrs: if name == "unique_together": ut = model._meta.original_attrs["unique_together"] - options[name] = set(normalize_unique_together(ut)) + options[name] = set(normalize_together(ut)) + elif name == "index_together": + it = model._meta.original_attrs["index_together"] + options[name] = set(normalize_together(it)) else: options[name] = model._meta.original_attrs[name] # Make our record diff --git a/django/db/models/options.py b/django/db/models/options.py index ebbb19f479..4e0307fc4f 100644 --- a/django/db/models/options.py +++ b/django/db/models/options.py @@ -24,24 +24,26 @@ DEFAULT_NAMES = ('verbose_name', 'verbose_name_plural', 'db_table', 'ordering', 'select_on_save') -def normalize_unique_together(unique_together): +def normalize_together(option_together): """ - unique_together can be either a tuple of tuples, or a single + option_together can be either a tuple of tuples, or a single tuple of two strings. Normalize it to a tuple of tuples, so that calling code can uniformly expect that. """ try: - if not unique_together: + if not option_together: return () - first_element = next(iter(unique_together)) + if not isinstance(option_together, (tuple, list)): + raise TypeError + first_element = next(iter(option_together)) if not isinstance(first_element, (tuple, list)): - unique_together = (unique_together,) + option_together = (option_together,) # Normalize everything to tuples - return tuple(tuple(ut) for ut in unique_together) + return tuple(tuple(ot) for ot in option_together) except TypeError: - # If the value of unique_together isn't valid, return it + # If the value of option_together isn't valid, return it # verbatim; this will be picked up by the check framework later. - return unique_together + return option_together @python_2_unicode_compatible @@ -140,7 +142,10 @@ class Options(object): self.original_attrs[attr_name] = getattr(self, attr_name) ut = meta_attrs.pop('unique_together', self.unique_together) - self.unique_together = normalize_unique_together(ut) + self.unique_together = normalize_together(ut) + + it = meta_attrs.pop('index_together', self.index_together) + self.index_together = normalize_together(it) # verbose_name_plural is a special case because it uses a 's' # by default. diff --git a/docs/ref/models/options.txt b/docs/ref/models/options.txt index 3fe7526fa2..ef56b251a0 100644 --- a/docs/ref/models/options.txt +++ b/docs/ref/models/options.txt @@ -340,6 +340,13 @@ Django quotes column and table names behind the scenes. This list of fields will be indexed together (i.e. the appropriate ``CREATE INDEX`` statement will be issued.) + .. versionchanged:: 1.7 + + For convenience, ``index_together`` can be a single list when dealing with a single + set of fields:: + + index_together = ["pub_date", "deadline"] + ``verbose_name`` ---------------- diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 882fa69dee..f9400032e5 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -642,6 +642,9 @@ Models an error (before that, it would either result in a database error or incorrect data). +* You can use a single list for :attr:`~django.db.models.Options.index_together` + (rather than a list of lists) when specifying a single set of fields. + Signals ^^^^^^^ diff --git a/tests/indexes/models.py b/tests/indexes/models.py index e38eb005db..064a099b40 100644 --- a/tests/indexes/models.py +++ b/tests/indexes/models.py @@ -12,6 +12,14 @@ class Article(models.Model): ] +# Model for index_together being used only with single list +class IndexTogetherSingleList(models.Model): + headline = models.CharField(max_length=100) + pub_date = models.DateTimeField() + + class Meta: + index_together = ["headline", "pub_date"] + # Indexing a TextField on Oracle or MySQL results in index creation error. if connection.vendor == 'postgresql': class IndexedArticle(models.Model): diff --git a/tests/indexes/tests.py b/tests/indexes/tests.py index 605fe9037c..da93b31d87 100644 --- a/tests/indexes/tests.py +++ b/tests/indexes/tests.py @@ -4,7 +4,7 @@ from django.core.management.color import no_style from django.db import connections, DEFAULT_DB_ALIAS from django.test import TestCase -from .models import Article +from .models import Article, IndexTogetherSingleList class IndexesTests(TestCase): @@ -13,6 +13,12 @@ class IndexesTests(TestCase): index_sql = connection.creation.sql_indexes_for_model(Article, no_style()) self.assertEqual(len(index_sql), 1) + def test_index_together_single_list(self): + # Test for using index_together with a single list (#22172) + connection = connections[DEFAULT_DB_ALIAS] + index_sql = connection.creation.sql_indexes_for_model(IndexTogetherSingleList, no_style()) + self.assertEqual(len(index_sql), 1) + @skipUnless(connections[DEFAULT_DB_ALIAS].vendor == 'postgresql', "This is a postgresql-specific issue") def test_postgresql_text_indexes(self): diff --git a/tests/invalid_models_tests/test_models.py b/tests/invalid_models_tests/test_models.py index 9820aea969..c4ac960773 100644 --- a/tests/invalid_models_tests/test_models.py +++ b/tests/invalid_models_tests/test_models.py @@ -45,10 +45,7 @@ class IndexTogetherTests(IsolatedModelsTestCase): def test_list_containing_non_iterable(self): class Model(models.Model): class Meta: - index_together = [ - 'non-iterable', - 'second-non-iterable', - ] + index_together = [('a', 'b'), 42] errors = Model.check() expected = [ @@ -139,6 +136,22 @@ class UniqueTogetherTests(IsolatedModelsTestCase): ] self.assertEqual(errors, expected) + def test_non_list(self): + class Model(models.Model): + class Meta: + unique_together = 'not-a-list' + + errors = Model.check() + expected = [ + Error( + '"unique_together" must be a list or tuple.', + hint=None, + obj=Model, + id='E008', + ), + ] + self.assertEqual(errors, expected) + def test_valid_model(self): class Model(models.Model): one = models.IntegerField() diff --git a/tests/migrations/test_state.py b/tests/migrations/test_state.py index 5c71376179..187b06b94a 100644 --- a/tests/migrations/test_state.py +++ b/tests/migrations/test_state.py @@ -25,6 +25,7 @@ class StateTests(TestCase): app_label = "migrations" apps = new_apps unique_together = ["name", "bio"] + index_together = ["bio", "age"] class AuthorProxy(Author): class Meta: @@ -63,7 +64,7 @@ class StateTests(TestCase): self.assertEqual(author_state.fields[1][1].max_length, 255) self.assertEqual(author_state.fields[2][1].null, False) self.assertEqual(author_state.fields[3][1].null, True) - self.assertEqual(author_state.options, {"unique_together": set([("name", "bio")])}) + self.assertEqual(author_state.options, {"unique_together": set([("name", "bio")]), "index_together": set([("bio", "age")])}) self.assertEqual(author_state.bases, (models.Model, )) self.assertEqual(book_state.app_label, "migrations")