Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Implement swappable model support for migrations

  • Loading branch information...
commit c9de1b4a55713ad1557f8c40621226253038f665 1 parent 5c7ac74
@andrewgodwin andrewgodwin authored
View
2  django/db/migrations/__init__.py
@@ -1,2 +1,2 @@
-from .migration import Migration # NOQA
+from .migration import Migration, swappable_dependency # NOQA
from .operations import * # NOQA
View
21 django/db/migrations/autodetector.py
@@ -105,8 +105,12 @@ def _detect_changes(self):
)
)
for field_name, other_app_label, other_model_name in related_fields:
- if app_label != other_app_label:
- self.add_dependency(app_label, other_app_label)
+ # If it depends on a swappable something, add a dynamic depend'cy
+ swappable_setting = new_apps.get_model(app_label, model_name)._meta.get_field_by_name(field_name)[0].swappable_setting
+ if swappable_setting is not None:
+ self.add_swappable_dependency(app_label, swappable_setting)
+ elif app_label != other_app_label:
+ self.add_dependency(app_label, other_app_label)
del pending_add[app_label, model_name]
# Ah well, we'll need to split one. Pick deterministically.
else:
@@ -140,7 +144,11 @@ def _detect_changes(self):
),
new=True,
)
- if app_label != other_app_label:
+ # If it depends on a swappable something, add a dynamic depend'cy
+ swappable_setting = new_apps.get_model(app_label, model_name)._meta.get_field_by_name(field_name)[0].swappable_setting
+ if swappable_setting is not None:
+ self.add_swappable_dependency(app_label, swappable_setting)
+ elif app_label != other_app_label:
self.add_dependency(app_label, other_app_label)
# Removing models
removed_models = set(old_model_keys) - set(new_model_keys)
@@ -276,6 +284,13 @@ def add_dependency(self, app_label, other_app_label):
dependency = (other_app_label, "__first__")
self.migrations[app_label][-1].dependencies.append(dependency)
+ def add_swappable_dependency(self, app_label, setting_name):
+ """
+ Adds a dependency to the value of a swappable model setting.
+ """
+ dependency = ("__setting__", setting_name)
+ self.migrations[app_label][-1].dependencies.append(dependency)
+
def _arrange_for_graph(self, changes, graph):
"""
Takes in a result from changes() and a MigrationGraph,
View
2  django/db/migrations/loader.py
@@ -223,7 +223,7 @@ def build_graph(self):
self.graph.add_node(parent, new_migration)
self.applied_migrations.add(parent)
elif parent[0] in self.migrated_apps:
- parent = (parent[0], list(self.graph.root_nodes(parent[0]))[0])
+ parent = list(self.graph.root_nodes(parent[0]))[0]
else:
raise ValueError("Dependency on unknown app %s" % parent[0])
self.graph.add_dependency(key, parent)
View
7 django/db/migrations/migration.py
@@ -127,3 +127,10 @@ def unapply(self, project_state, schema_editor, collect_sql=False):
to_run.reverse()
for operation, to_state, from_state in to_run:
operation.database_backwards(self.app_label, schema_editor, from_state, to_state)
+
+
+def swappable_dependency(value):
+ """
+ Turns a setting value into a dependency.
+ """
+ return (value.split(".", 1)[0], "__first__")
View
27 django/db/migrations/writer.py
@@ -13,6 +13,20 @@
from django.utils import six
+class SettingsReference(str):
+ """
+ Special subclass of string which actually references a current settings
+ value. It's treated as the value in memory, but serializes out to a
+ settings.NAME attribute reference.
+ """
+
+ def __new__(self, value, setting_name):
+ return str.__new__(self, value)
+
+ def __init__(self, value, setting_name):
+ self.setting_name = setting_name
+
+
class MigrationWriter(object):
"""
Takes a Migration instance and is able to produce the contents
@@ -27,7 +41,6 @@ def as_string(self):
Returns a string of the file contents.
"""
items = {
- "dependencies": repr(self.migration.dependencies),
"replaces_str": "",
}
imports = set()
@@ -46,6 +59,15 @@ def as_string(self):
arg_strings.append("%s = %s" % (kw, arg_string))
operation_strings.append("migrations.%s(%s\n )" % (name, "".join("\n %s," % arg for arg in arg_strings)))
items["operations"] = "[%s\n ]" % "".join("\n %s," % s for s in operation_strings)
+ # Format dependencies and write out swappable dependencies right
+ items["dependencies"] = "["
+ for dependency in self.migration.dependencies:
+ if dependency[0] == "__setting__":
+ items["dependencies"] += "\n migrations.swappable_dependency(settings.%s)," % dependency[1]
+ imports.add("from django.conf import settings")
+ else:
+ items["dependencies"] += "\n %s," % repr(dependency)
+ items["dependencies"] += "\n ]"
# Format imports nicely
imports.discard("from django.db import models")
if not imports:
@@ -136,6 +158,9 @@ def serialize(cls, value):
# Datetimes
elif isinstance(value, (datetime.datetime, datetime.date)):
return repr(value), set(["import datetime"])
+ # Settings references
+ elif isinstance(value, SettingsReference):
+ return "settings.%s" % value.setting_name, set(["from django.conf import settings"])
# Simple types
elif isinstance(value, six.integer_types + (float, six.binary_type, six.text_type, bool, type(None))):
return repr(value), set()
View
60 django/db/models/fields/related.py
@@ -16,6 +16,7 @@
from django.utils.translation import ugettext_lazy as _
from django.utils.functional import curry, cached_property
from django.core import exceptions
+from django.apps import apps
from django import forms
RECURSIVE_RELATIONSHIP_CONSTANT = 'self'
@@ -121,6 +122,30 @@ def resolve_related_class(field, model, cls):
else:
self.do_related_class(other, cls)
+ @property
+ def swappable_setting(self):
+ """
+ Gets the setting that this is powered from for swapping, or None
+ if it's not swapped in / marked with swappable=False.
+ """
+ if self.swappable:
+ # Work out string form of "to"
+ if isinstance(self.rel.to, six.string_types):
+ to_string = self.rel.to
+ else:
+ to_string = "%s.%s" % (
+ self.rel.to._meta.app_label,
+ self.rel.to._meta.object_name,
+ )
+ # See if anything swapped/swappable matches
+ for model in apps.get_models(include_swapped=True):
+ if model._meta.swapped:
+ if model._meta.swapped == to_string:
+ return model._meta.swappable
+ if ("%s.%s" % (model._meta.app_label, model._meta.object_name)) == to_string and model._meta.swappable:
+ return model._meta.swappable
+ return None
+
def set_attributes_from_rel(self):
self.name = self.name or (self.rel.to._meta.model_name + '_' + self.rel.to._meta.pk.name)
if self.verbose_name is None:
@@ -1061,9 +1086,10 @@ class ForeignObject(RelatedField):
generate_reverse_relation = True
related_accessor_class = ForeignRelatedObjectsDescriptor
- def __init__(self, to, from_fields, to_fields, **kwargs):
+ def __init__(self, to, from_fields, to_fields, swappable=True, **kwargs):
self.from_fields = from_fields
self.to_fields = to_fields
+ self.swappable = swappable
if 'rel' not in kwargs:
kwargs['rel'] = ForeignObjectRel(
@@ -1082,10 +1108,25 @@ def deconstruct(self):
name, path, args, kwargs = super(ForeignObject, self).deconstruct()
kwargs['from_fields'] = self.from_fields
kwargs['to_fields'] = self.to_fields
+ # Work out string form of "to"
if isinstance(self.rel.to, six.string_types):
kwargs['to'] = self.rel.to
else:
kwargs['to'] = "%s.%s" % (self.rel.to._meta.app_label, self.rel.to._meta.object_name)
+ # If swappable is True, then see if we're actually pointing to the target
+ # of a swap.
+ swappable_setting = self.swappable_setting
+ if swappable_setting is not None:
+ # If it's already a settings reference, error
+ if hasattr(kwargs['to'], "setting_name"):
+ if kwargs['to'].setting_name != swappable_setting:
+ raise ValueError("Cannot deconstruct a ForeignKey pointing to a model that is swapped in place of more than one model (%s and %s)" % (kwargs['to'].setting_name, swappable_setting))
+ # Set it
+ from django.db.migrations.writer import SettingsReference
+ kwargs['to'] = SettingsReference(
+ kwargs['to'],
+ swappable_setting,
+ )
return name, path, args, kwargs
def resolve_related_fields(self):
@@ -1516,7 +1557,7 @@ def set_managed(field, model, cls):
class ManyToManyField(RelatedField):
description = _("Many-to-many relationship")
- def __init__(self, to, db_constraint=True, **kwargs):
+ def __init__(self, to, db_constraint=True, swappable=True, **kwargs):
try:
assert not to._meta.abstract, "%s cannot define a relation with abstract class %s" % (self.__class__.__name__, to._meta.object_name)
except AttributeError: # to._meta doesn't exist, so it must be RECURSIVE_RELATIONSHIP_CONSTANT
@@ -1534,6 +1575,7 @@ def __init__(self, to, db_constraint=True, **kwargs):
db_constraint=db_constraint,
)
+ self.swappable = swappable
self.db_table = kwargs.pop('db_table', None)
if kwargs['rel'].through is not None:
assert self.db_table is None, "Cannot specify a db_table if an intermediary model is used."
@@ -1552,6 +1594,20 @@ def deconstruct(self):
kwargs['to'] = self.rel.to
else:
kwargs['to'] = "%s.%s" % (self.rel.to._meta.app_label, self.rel.to._meta.object_name)
+ # If swappable is True, then see if we're actually pointing to the target
+ # of a swap.
+ swappable_setting = self.swappable_setting
+ if swappable_setting is not None:
+ # If it's already a settings reference, error
+ if hasattr(kwargs['to'], "setting_name"):
+ if kwargs['to'].setting_name != swappable_setting:
+ raise ValueError("Cannot deconstruct a ManyToManyField pointing to a model that is swapped in place of more than one model (%s and %s)" % (kwargs['to'].setting_name, swappable_setting))
+ # Set it
+ from django.db.migrations.writer import SettingsReference
+ kwargs['to'] = SettingsReference(
+ kwargs['to'],
+ swappable_setting,
+ )
return name, path, args, kwargs
def _get_path_info(self, direct=False):
View
34 docs/ref/models/fields.txt
@@ -1201,6 +1201,23 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in
you manually add an SQL ``ON DELETE`` constraint to the database field
(perhaps using :ref:`initial sql<initial-sql>`).
+.. attribute:: ForeignKey.swappable
+
+ .. versionadded:: 1.7
+
+ Controls the migration framework's reaction if this :class:`ForeignKey`
+ is pointing at a swappable model. If it is ``True`` - the default -
+ then if the :class:`ForeignKey` is pointing at a model which matches
+ the current value of ``settings.AUTH_USER_MODEL`` (or another swappable
+ model setting) the relationship will be stored in the migration using
+ a reference to the setting, not to the model directly.
+
+ You only want to override this to be ``False`` if you are sure your
+ model should always point towards the swapped-in model - for example,
+ if it is a profile model designed specifically for your custom user model.
+
+ If in doubt, leave it to its default of ``True``.
+
.. _ref-manytomany:
``ManyToManyField``
@@ -1309,6 +1326,23 @@ that control how the relationship functions.
It is an error to pass both ``db_constraint`` and ``through``.
+.. attribute:: ManyToManyField.swappable
+
+ .. versionadded:: 1.7
+
+ Controls the migration framework's reaction if this :class:`ManyToManyField`
+ is pointing at a swappable model. If it is ``True`` - the default -
+ then if the :class:`ManyToManyField` is pointing at a model which matches
+ the current value of ``settings.AUTH_USER_MODEL`` (or another swappable
+ model setting) the relationship will be stored in the migration using
+ a reference to the setting, not to the model directly.
+
+ You only want to override this to be ``False`` if you are sure your
+ model should always point towards the swapped-in model - for example,
+ if it is a profile model designed specifically for your custom user model.
+
+ If in doubt, leave it to its default of ``True``.
+
.. _ref-onetoone:
View
44 tests/field_deconstruction/tests.py
@@ -1,5 +1,5 @@
import warnings
-from django.test import TestCase
+from django.test import TestCase, override_settings
from django.db import models
@@ -149,22 +149,44 @@ def test_float_field(self):
self.assertEqual(kwargs, {})
def test_foreign_key(self):
+ # Test basic pointing
+ field = models.ForeignKey("auth.Permission")
+ name, path, args, kwargs = field.deconstruct()
+ self.assertEqual(path, "django.db.models.ForeignKey")
+ self.assertEqual(args, [])
+ self.assertEqual(kwargs, {"to": "auth.Permission"})
+ self.assertFalse(hasattr(kwargs['to'], "setting_name"))
+ # Test swap detection for swappable model
field = models.ForeignKey("auth.User")
name, path, args, kwargs = field.deconstruct()
self.assertEqual(path, "django.db.models.ForeignKey")
self.assertEqual(args, [])
self.assertEqual(kwargs, {"to": "auth.User"})
+ self.assertEqual(kwargs['to'].setting_name, "AUTH_USER_MODEL")
+ # Test nonexistent (for now) model
field = models.ForeignKey("something.Else")
name, path, args, kwargs = field.deconstruct()
self.assertEqual(path, "django.db.models.ForeignKey")
self.assertEqual(args, [])
self.assertEqual(kwargs, {"to": "something.Else"})
+ # Test on_delete
field = models.ForeignKey("auth.User", on_delete=models.SET_NULL)
name, path, args, kwargs = field.deconstruct()
self.assertEqual(path, "django.db.models.ForeignKey")
self.assertEqual(args, [])
self.assertEqual(kwargs, {"to": "auth.User", "on_delete": models.SET_NULL})
+ @override_settings(AUTH_USER_MODEL="auth.Permission")
+ def test_foreign_key_swapped(self):
+ # It doesn't matter that we swapped out user for permission;
+ # there's no validation. We just want to check the setting stuff works.
+ field = models.ForeignKey("auth.Permission")
+ name, path, args, kwargs = field.deconstruct()
+ self.assertEqual(path, "django.db.models.ForeignKey")
+ self.assertEqual(args, [])
+ self.assertEqual(kwargs, {"to": "auth.Permission"})
+ self.assertEqual(kwargs['to'].setting_name, "AUTH_USER_MODEL")
+
def test_image_field(self):
field = models.ImageField(upload_to="foo/barness", width_field="width", height_field="height")
name, path, args, kwargs = field.deconstruct()
@@ -201,11 +223,31 @@ def test_generic_ip_address_field(self):
self.assertEqual(kwargs, {"protocol": "IPv6"})
def test_many_to_many_field(self):
+ # Test normal
+ field = models.ManyToManyField("auth.Permission")
+ name, path, args, kwargs = field.deconstruct()
+ self.assertEqual(path, "django.db.models.ManyToManyField")
+ self.assertEqual(args, [])
+ self.assertEqual(kwargs, {"to": "auth.Permission"})
+ self.assertFalse(hasattr(kwargs['to'], "setting_name"))
+ # Test swappable
field = models.ManyToManyField("auth.User")
name, path, args, kwargs = field.deconstruct()
self.assertEqual(path, "django.db.models.ManyToManyField")
self.assertEqual(args, [])
self.assertEqual(kwargs, {"to": "auth.User"})
+ self.assertEqual(kwargs['to'].setting_name, "AUTH_USER_MODEL")
+
+ @override_settings(AUTH_USER_MODEL="auth.Permission")
+ def test_many_to_many_field_swapped(self):
+ # It doesn't matter that we swapped out user for permission;
+ # there's no validation. We just want to check the setting stuff works.
+ field = models.ManyToManyField("auth.Permission")
+ name, path, args, kwargs = field.deconstruct()
+ self.assertEqual(path, "django.db.models.ManyToManyField")
+ self.assertEqual(args, [])
+ self.assertEqual(kwargs, {"to": "auth.Permission"})
+ self.assertEqual(kwargs['to'].setting_name, "AUTH_USER_MODEL")
def test_null_boolean_field(self):
field = models.NullBooleanField()
View
16 tests/migrations/test_autodetector.py
@@ -1,5 +1,5 @@
# encoding: utf8
-from django.test import TestCase
+from django.test import TestCase, override_settings
from django.db.migrations.autodetector import MigrationAutodetector
from django.db.migrations.questioner import MigrationQuestioner
from django.db.migrations.state import ProjectState, ModelState
@@ -18,6 +18,7 @@ class AutodetectorTests(TestCase):
author_name_renamed = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("names", models.CharField(max_length=200))])
author_with_book = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=200)), ("book", models.ForeignKey("otherapp.Book"))])
author_with_publisher = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=200)), ("publisher", models.ForeignKey("testapp.Publisher"))])
+ author_with_custom_user = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=200)), ("user", models.ForeignKey("thirdapp.CustomUser"))])
author_proxy = ModelState("testapp", "AuthorProxy", [], {"proxy": True}, ("testapp.author", ))
author_proxy_notproxy = ModelState("testapp", "AuthorProxy", [], {}, ("testapp.author", ))
publisher = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=100))])
@@ -29,6 +30,7 @@ class AutodetectorTests(TestCase):
book_unique = ModelState("otherapp", "Book", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("title", models.CharField(max_length=200))], {"unique_together": [("author", "title")]})
book_unique_2 = ModelState("otherapp", "Book", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("title", models.CharField(max_length=200))], {"unique_together": [("title", "author")]})
edition = ModelState("thirdapp", "Edition", [("id", models.AutoField(primary_key=True)), ("book", models.ForeignKey("otherapp.Book"))])
+ custom_user = ModelState("thirdapp", "CustomUser", [("id", models.AutoField(primary_key=True)), ("username", models.CharField(max_length=255))])
def make_project_state(self, model_states):
"Shortcut to make ProjectStates from lists of predefined models"
@@ -355,3 +357,15 @@ def test_proxy_ignorance(self):
action = migration.operations[0]
self.assertEqual(action.__class__.__name__, "CreateModel")
self.assertEqual(action.name, "AuthorProxy")
+
+ @override_settings(AUTH_USER_MODEL="thirdapp.CustomUser")
+ def test_swappable(self):
+ before = self.make_project_state([self.custom_user])
+ after = self.make_project_state([self.custom_user, self.author_with_custom_user])
+ autodetector = MigrationAutodetector(before, after)
+ changes = autodetector._detect_changes()
+ # Right number of migrations?
+ self.assertEqual(len(changes), 1)
+ # Check the dependency is correct
+ migration = changes['testapp'][0]
+ self.assertEqual(migration.dependencies, [("__setting__", "AUTH_USER_MODEL")])
View
16 tests/migrations/test_writer.py
@@ -7,8 +7,9 @@
from django.core.validators import RegexValidator, EmailValidator
from django.db import models, migrations
-from django.db.migrations.writer import MigrationWriter
+from django.db.migrations.writer import MigrationWriter, SettingsReference
from django.test import TestCase
+from django.conf import settings
from django.utils import six
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
@@ -37,8 +38,8 @@ def serialize_round_trip(self, value):
def assertSerializedEqual(self, value):
self.assertEqual(self.serialize_round_trip(value), value)
- def assertSerializedIs(self, value):
- self.assertIs(self.serialize_round_trip(value), value)
+ def assertSerializedResultEqual(self, value, target):
+ self.assertEqual(MigrationWriter.serialize(value), target)
def assertSerializedFieldEqual(self, value):
new_value = self.serialize_round_trip(value)
@@ -92,6 +93,15 @@ def test_serialize(self):
# Django fields
self.assertSerializedFieldEqual(models.CharField(max_length=255))
self.assertSerializedFieldEqual(models.TextField(null=True, blank=True))
+ # Setting references
+ self.assertSerializedEqual(SettingsReference(settings.AUTH_USER_MODEL, "AUTH_USER_MODEL"))
+ self.assertSerializedResultEqual(
+ SettingsReference("someapp.model", "AUTH_USER_MODEL"),
+ (
+ "settings.AUTH_USER_MODEL",
+ set(["from django.conf import settings"]),
+ )
+ )
def test_simple_migration(self):
"""
Please sign in to comment.
Something went wrong with that request. Please try again.