diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py new file mode 100644 index 0000000000..3eea1d7291 --- /dev/null +++ b/django/db/migrations/autodetector.py @@ -0,0 +1,69 @@ +from django.db.migrations import operations +from django.db.migrations.migration import Migration + + +class AutoDetector(object): + """ + Takes a pair of ProjectStates, and compares them to see what the + first would need doing to make it match the second (the second + usually being the project's current state). + + Note that this naturally operates on entire projects at a time, + as it's likely that changes interact (for example, you can't + add a ForeignKey without having a migration to add the table it + depends on first). A user interface may offer single-app detection + if it wishes, with the caveat that it may not always be possible. + """ + + def __init__(self, from_state, to_state): + self.from_state = from_state + self.to_state = to_state + + def changes(self): + """ + Returns a set of migration plans which will achieve the + change from from_state to to_state. + """ + # We'll store migrations as lists by app names for now + self.migrations = {} + # Stage one: Adding models. + added_models = set(self.to_state.keys()) - set(self.from_state.keys()) + for app_label, model_name in added_models: + model_state = self.to_state[app_label, model_name] + self.add_to_migration( + app_label, + operations.CreateModel( + model_state.name, + model_state.fields, + model_state.options, + model_state.bases, + ) + ) + # Removing models + removed_models = set(self.from_state.keys()) - set(self.to_state.keys()) + for app_label, model_name in removed_models: + model_state = self.from_state[app_label, model_name] + self.add_to_migration( + app_label, + operations.DeleteModel( + model_state.name, + ) + ) + # Alright, now sort out and return the migrations + for app_label, migrations in self.migrations.items(): + for m1, m2 in zip(migrations, migrations[1:]): + m2.dependencies.append((app_label, m1.name)) + # Flatten and return + result = set() + for app_label, migrations in self.migrations.items(): + for migration in migrations: + subclass = type("Migration", (Migration,), migration) + instance = subclass(migration['name'], app_label) + result.append(instance) + return result + + def add_to_migration(self, app_label, operation): + migrations = self.migrations.setdefault(app_label, []) + if not migrations: + migrations.append({"name": "temp-%i" % len(migrations) + 1, "operations": [], "dependencies": []}) + migrations[-1].operations.append(operation) diff --git a/django/db/migrations/operations/base.py b/django/db/migrations/operations/base.py index f1b30d79f5..084ce14959 100644 --- a/django/db/migrations/operations/base.py +++ b/django/db/migrations/operations/base.py @@ -15,6 +15,24 @@ class Operation(object): # Some operations are impossible to reverse, like deleting data. reversible = True + def __new__(cls, *args, **kwargs): + # We capture the arguments to make returning them trivial + self = object.__new__(cls) + self._constructor_args = (args, kwargs) + return self + + def deconstruct(self): + """ + Returns a 3-tuple of class import path (or just name if it lives + under django.db.migrations), positional arguments, and keyword + arguments. + """ + return ( + self.__class__.__name__, + self._constructor_args[0], + self._constructor_args[1], + ) + def state_forwards(self, app_label, state): """ Takes the state from the previous migration, and mutates it diff --git a/django/db/migrations/writer.py b/django/db/migrations/writer.py new file mode 100644 index 0000000000..b32bb987d9 --- /dev/null +++ b/django/db/migrations/writer.py @@ -0,0 +1,123 @@ +import datetime +import types + + +class MigrationWriter(object): + """ + Takes a Migration instance and is able to produce the contents + of the migration file from it. + """ + + def __init__(self, migration): + self.migration = migration + + def as_string(self): + """ + Returns a string of the file contents. + """ + items = { + "dependencies": repr(self.migration.dependencies), + } + imports = set() + # Deconstruct operations + operation_strings = [] + for operation in self.migration.operations: + name, args, kwargs = operation.deconstruct() + arg_strings = [] + for arg in args: + arg_string, arg_imports = self.serialize(arg) + arg_strings.append(arg_string) + imports.update(arg_imports) + for kw, arg in kwargs.items(): + arg_string, arg_imports = self.serialize(arg) + imports.update(arg_imports) + 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 imports nicely + if not imports: + items["imports"] = "" + else: + items["imports"] = "\n".join(imports) + "\n" + return MIGRATION_TEMPLATE % items + + @property + def filename(self): + return "%s.py" % self.migration.name + + @classmethod + def serialize(cls, value): + """ + Serializes the value to a string that's parsable by Python, along + with any needed imports to make that string work. + More advanced than repr() as it can encode things + like datetime.datetime.now. + """ + # Sequences + if isinstance(value, (list, set, tuple)): + imports = set() + strings = [] + for item in value: + item_string, item_imports = cls.serialize(item) + imports.update(item_imports) + strings.append(item_string) + if isinstance(value, set): + format = "set([%s])" + elif isinstance(value, tuple): + format = "(%s,)" + else: + format = "[%s]" + return format % (", ".join(strings)), imports + # Dictionaries + elif isinstance(value, dict): + imports = set() + strings = [] + for k, v in value.items(): + k_string, k_imports = cls.serialize(k) + v_string, v_imports = cls.serialize(v) + imports.update(k_imports) + imports.update(v_imports) + strings.append((k_string, v_string)) + return "{%s}" % (", ".join(["%s: %s" % (k, v) for k, v in strings])), imports + # Datetimes + elif isinstance(value, (datetime.datetime, datetime.date)): + return repr(value), set(["import datetime"]) + # Simple types + elif isinstance(value, (int, long, float, str, unicode, bool, types.NoneType)): + return repr(value), set() + # Functions + elif isinstance(value, (types.FunctionType, types.BuiltinFunctionType)): + # Special-cases, as these don't have im_class + special_cases = [ + (datetime.datetime.now, "datetime.datetime.now", ["import datetime"]), + (datetime.datetime.utcnow, "datetime.datetime.utcnow", ["import datetime"]), + (datetime.date.today, "datetime.date.today", ["import datetime"]), + ] + for func, string, imports in special_cases: + if func == value: # For some reason "utcnow is not utcnow" + return string, set(imports) + # Method? + if hasattr(value, "im_class"): + klass = value.im_class + module = klass.__module__ + return "%s.%s.%s" % (module, klass.__name__, value.__name__), set(["import %s" % module]) + else: + module = value.__module__ + if module is None: + raise ValueError("Cannot serialize function %r: No module" % value) + return "%s.%s" % (module, value.__name__), set(["import %s" % module]) + # Uh oh. + else: + raise ValueError("Cannot serialize: %r" % value) + + +MIGRATION_TEMPLATE = """# encoding: utf8 +from django.db import models, migrations +%(imports)s + +class Migration(migrations.Migration): + + dependencies = %(dependencies)s + + operations = %(operations)s +""" diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index bf8549e092..9c25e43990 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -68,6 +68,12 @@ class OperationTests(TransactionTestCase): with connection.schema_editor() as editor: operation.database_backwards("test_crmo", editor, new_state, project_state) self.assertTableNotExists("test_crmo_pony") + # And deconstruction + definition = operation.deconstruct() + self.assertEqual(definition[0], "CreateModel") + self.assertEqual(len(definition[1]), 2) + self.assertEqual(len(definition[2]), 0) + self.assertEqual(definition[1][0], "Pony") def test_delete_model(self): """ diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py new file mode 100644 index 0000000000..8c1753e794 --- /dev/null +++ b/tests/migrations/test_writer.py @@ -0,0 +1,64 @@ +# encoding: utf8 +import datetime +from django.test import TransactionTestCase +from django.db.migrations.writer import MigrationWriter +from django.db import migrations + + +class WriterTests(TransactionTestCase): + """ + Tests the migration writer (makes migration files from Migration instances) + """ + + def safe_exec(self, value, string): + l = {} + try: + exec(string, {}, l) + except: + self.fail("Could not serialize %r: failed to exec %r" % (value, string.strip())) + return l + + def assertSerializedEqual(self, value): + string, imports = MigrationWriter.serialize(value) + new_value = self.safe_exec(value, "%s\ntest_value_result = %s" % ("\n".join(imports), string))['test_value_result'] + self.assertEqual(new_value, value) + + def assertSerializedIs(self, value): + string, imports = MigrationWriter.serialize(value) + new_value = self.safe_exec(value, "%s\ntest_value_result = %s" % ("\n".join(imports), string))['test_value_result'] + self.assertIs(new_value, value) + + def test_serialize(self): + """ + Tests various different forms of the serializer. + This does not care about formatting, just that the parsed result is + correct, so we always exec() the result and check that. + """ + # Basic values + self.assertSerializedEqual(1) + self.assertSerializedEqual(None) + self.assertSerializedEqual("foobar") + self.assertSerializedEqual(u"föobár") + self.assertSerializedEqual({1: 2}) + self.assertSerializedEqual(["a", 2, True, None]) + self.assertSerializedEqual(set([2, 3, "eighty"])) + self.assertSerializedEqual({"lalalala": ["yeah", "no", "maybe"]}) + # Datetime stuff + self.assertSerializedEqual(datetime.datetime.utcnow()) + self.assertSerializedEqual(datetime.datetime.utcnow) + self.assertSerializedEqual(datetime.date.today()) + self.assertSerializedEqual(datetime.date.today) + + def test_simple_migration(self): + """ + Tests serializing a simple migration. + """ + migration = type("Migration", (migrations.Migration,), { + "operations": [ + migrations.DeleteModel("MyModel"), + ], + "dependencies": [("testapp", "some_other_one")], + }) + writer = MigrationWriter(migration) + output = writer.as_string() + print output