First stab at some migration creation commands
This commit is contained in:
parent
2ae8a8a77d
commit
ab5cbae9b7
|
@ -0,0 +1,52 @@
|
||||||
|
import sys
|
||||||
|
from optparse import make_option
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.management.color import color_style
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.db import connections
|
||||||
|
from django.db.migrations.loader import MigrationLoader
|
||||||
|
from django.db.migrations.autodetector import MigrationAutodetector, InteractiveMigrationQuestioner
|
||||||
|
from django.db.migrations.state import ProjectState
|
||||||
|
from django.db.models.loading import cache
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
option_list = BaseCommand.option_list + (
|
||||||
|
make_option('--empty', action='store_true', dest='empty', default=False,
|
||||||
|
help='Make a blank migration.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
help = "Creates new migration(s) for apps."
|
||||||
|
usage_str = "Usage: ./manage.py createmigration [--empty] [app [app ...]]"
|
||||||
|
|
||||||
|
def handle(self, *app_labels, **options):
|
||||||
|
|
||||||
|
self.verbosity = int(options.get('verbosity'))
|
||||||
|
self.interactive = options.get('interactive')
|
||||||
|
self.style = color_style()
|
||||||
|
|
||||||
|
# Make sure the app they asked for exists
|
||||||
|
app_labels = set(app_labels)
|
||||||
|
for app_label in app_labels:
|
||||||
|
try:
|
||||||
|
cache.get_app(app_label)
|
||||||
|
except ImproperlyConfigured:
|
||||||
|
self.stderr.write("The app you specified - '%s' - could not be found. Is it in INSTALLED_APPS?" % app_label)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
# Load the current graph state
|
||||||
|
loader = MigrationLoader(connections["default"])
|
||||||
|
|
||||||
|
# Detect changes
|
||||||
|
autodetector = MigrationAutodetector(
|
||||||
|
loader.graph.project_state(),
|
||||||
|
ProjectState.from_app_cache(cache),
|
||||||
|
InteractiveMigrationQuestioner(specified_apps=app_labels),
|
||||||
|
)
|
||||||
|
changes = autodetector.changes()
|
||||||
|
changes = autodetector.arrange_for_graph(changes, loader.graph)
|
||||||
|
if app_labels:
|
||||||
|
changes = autodetector.trim_to_apps(changes, app_labels)
|
||||||
|
|
||||||
|
print changes
|
|
@ -5,7 +5,7 @@ import traceback
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import NoArgsCommand
|
from django.core.management.base import NoArgsCommand
|
||||||
from django.core.management.color import color_style
|
from django.core.management.color import color_style, no_style
|
||||||
from django.core.management.sql import custom_sql_for_model, emit_post_sync_signal, emit_pre_sync_signal
|
from django.core.management.sql import custom_sql_for_model, emit_post_sync_signal, emit_pre_sync_signal
|
||||||
from django.db import connections, router, transaction, models, DEFAULT_DB_ALIAS
|
from django.db import connections, router, transaction, models, DEFAULT_DB_ALIAS
|
||||||
from django.db.migrations.executor import MigrationExecutor
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
|
@ -32,6 +32,7 @@ class Command(NoArgsCommand):
|
||||||
self.interactive = options.get('interactive')
|
self.interactive = options.get('interactive')
|
||||||
self.show_traceback = options.get('traceback')
|
self.show_traceback = options.get('traceback')
|
||||||
self.load_initial_data = options.get('load_initial_data')
|
self.load_initial_data = options.get('load_initial_data')
|
||||||
|
self.test_database = options.get('test_database', False)
|
||||||
|
|
||||||
self.style = color_style()
|
self.style = color_style()
|
||||||
|
|
||||||
|
@ -144,14 +145,14 @@ class Command(NoArgsCommand):
|
||||||
# Create the model's database table, if it doesn't already exist.
|
# Create the model's database table, if it doesn't already exist.
|
||||||
if self.verbosity >= 3:
|
if self.verbosity >= 3:
|
||||||
self.stdout.write(" Processing %s.%s model\n" % (app_name, model._meta.object_name))
|
self.stdout.write(" Processing %s.%s model\n" % (app_name, model._meta.object_name))
|
||||||
sql, references = connection.creation.sql_create_model(model, self.style, seen_models)
|
sql, references = connection.creation.sql_create_model(model, no_style(), seen_models)
|
||||||
seen_models.add(model)
|
seen_models.add(model)
|
||||||
created_models.add(model)
|
created_models.add(model)
|
||||||
for refto, refs in references.items():
|
for refto, refs in references.items():
|
||||||
pending_references.setdefault(refto, []).extend(refs)
|
pending_references.setdefault(refto, []).extend(refs)
|
||||||
if refto in seen_models:
|
if refto in seen_models:
|
||||||
sql.extend(connection.creation.sql_for_pending_references(refto, self.style, pending_references))
|
sql.extend(connection.creation.sql_for_pending_references(refto, no_style(), pending_references))
|
||||||
sql.extend(connection.creation.sql_for_pending_references(model, self.style, pending_references))
|
sql.extend(connection.creation.sql_for_pending_references(model, no_style(), pending_references))
|
||||||
if self.verbosity >= 1 and sql:
|
if self.verbosity >= 1 and sql:
|
||||||
self.stdout.write(" Creating table %s\n" % model._meta.db_table)
|
self.stdout.write(" Creating table %s\n" % model._meta.db_table)
|
||||||
for statement in sql:
|
for statement in sql:
|
||||||
|
@ -172,7 +173,7 @@ class Command(NoArgsCommand):
|
||||||
for app_name, model_list in manifest.items():
|
for app_name, model_list in manifest.items():
|
||||||
for model in model_list:
|
for model in model_list:
|
||||||
if model in created_models:
|
if model in created_models:
|
||||||
custom_sql = custom_sql_for_model(model, self.style, connection)
|
custom_sql = custom_sql_for_model(model, no_style(), connection)
|
||||||
if custom_sql:
|
if custom_sql:
|
||||||
if self.verbosity >= 2:
|
if self.verbosity >= 2:
|
||||||
self.stdout.write(" Installing custom SQL for %s.%s model\n" % (app_name, model._meta.object_name))
|
self.stdout.write(" Installing custom SQL for %s.%s model\n" % (app_name, model._meta.object_name))
|
||||||
|
@ -194,7 +195,7 @@ class Command(NoArgsCommand):
|
||||||
for app_name, model_list in manifest.items():
|
for app_name, model_list in manifest.items():
|
||||||
for model in model_list:
|
for model in model_list:
|
||||||
if model in created_models:
|
if model in created_models:
|
||||||
index_sql = connection.creation.sql_indexes_for_model(model, self.style)
|
index_sql = connection.creation.sql_indexes_for_model(model, no_style())
|
||||||
if index_sql:
|
if index_sql:
|
||||||
if self.verbosity >= 2:
|
if self.verbosity >= 2:
|
||||||
self.stdout.write(" Installing index for %s.%s model\n" % (app_name, model._meta.object_name))
|
self.stdout.write(" Installing index for %s.%s model\n" % (app_name, model._meta.object_name))
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import re
|
import re
|
||||||
|
from django.utils.six.moves import input
|
||||||
from django.db.migrations import operations
|
from django.db.migrations import operations
|
||||||
from django.db.migrations.migration import Migration
|
from django.db.migrations.migration import Migration
|
||||||
|
from django.db.models.loading import cache
|
||||||
|
|
||||||
|
|
||||||
class MigrationAutodetector(object):
|
class MigrationAutodetector(object):
|
||||||
|
@ -16,9 +18,10 @@ class MigrationAutodetector(object):
|
||||||
if it wishes, with the caveat that it may not always be possible.
|
if it wishes, with the caveat that it may not always be possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, from_state, to_state):
|
def __init__(self, from_state, to_state, questioner=None):
|
||||||
self.from_state = from_state
|
self.from_state = from_state
|
||||||
self.to_state = to_state
|
self.to_state = to_state
|
||||||
|
self.questioner = questioner or MigrationQuestioner()
|
||||||
|
|
||||||
def changes(self):
|
def changes(self):
|
||||||
"""
|
"""
|
||||||
|
@ -54,7 +57,7 @@ class MigrationAutodetector(object):
|
||||||
model_state.name,
|
model_state.name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Alright, now sort out and return the migrations
|
# Alright, now add internal dependencies
|
||||||
for app_label, migrations in self.migrations.items():
|
for app_label, migrations in self.migrations.items():
|
||||||
for m1, m2 in zip(migrations, migrations[1:]):
|
for m1, m2 in zip(migrations, migrations[1:]):
|
||||||
m2.dependencies.append((app_label, m1.name))
|
m2.dependencies.append((app_label, m1.name))
|
||||||
|
@ -68,6 +71,77 @@ class MigrationAutodetector(object):
|
||||||
migrations.append(instance)
|
migrations.append(instance)
|
||||||
migrations[-1].operations.append(operation)
|
migrations[-1].operations.append(operation)
|
||||||
|
|
||||||
|
def arrange_for_graph(self, changes, graph):
|
||||||
|
"""
|
||||||
|
Takes in a result from changes() and a MigrationGraph,
|
||||||
|
and fixes the names and dependencies of the changes so they
|
||||||
|
extend the graph from the leaf nodes for each app.
|
||||||
|
"""
|
||||||
|
leaves = graph.leaf_nodes()
|
||||||
|
name_map = {}
|
||||||
|
for app_label, migrations in list(changes.items()):
|
||||||
|
if not migrations:
|
||||||
|
continue
|
||||||
|
# Find the app label's current leaf node
|
||||||
|
app_leaf = None
|
||||||
|
for leaf in leaves:
|
||||||
|
if leaf[0] == app_label:
|
||||||
|
app_leaf = leaf
|
||||||
|
break
|
||||||
|
# Do they want an initial migration for this app?
|
||||||
|
if app_leaf is None and not self.questioner.ask_initial(app_label):
|
||||||
|
# They don't.
|
||||||
|
for migration in migrations:
|
||||||
|
name_map[(app_label, migration.name)] = (app_label, "__first__")
|
||||||
|
del changes[app_label]
|
||||||
|
# Work out the next number in the sequence
|
||||||
|
if app_leaf is None:
|
||||||
|
next_number = 1
|
||||||
|
else:
|
||||||
|
next_number = (self.parse_number(app_leaf[1]) or 0) + 1
|
||||||
|
# Name each migration
|
||||||
|
for i, migration in enumerate(migrations):
|
||||||
|
if i == 0 and app_leaf:
|
||||||
|
migration.dependencies.append(app_leaf)
|
||||||
|
if i == 0 and not app_leaf:
|
||||||
|
new_name = "0001_initial"
|
||||||
|
else:
|
||||||
|
new_name = "%04i_%s" % (next_number, self.suggest_name(migration.operations))
|
||||||
|
name_map[(app_label, migration.name)] = (app_label, new_name)
|
||||||
|
migration.name = new_name
|
||||||
|
# Now fix dependencies
|
||||||
|
for app_label, migrations in changes.items():
|
||||||
|
for migration in migrations:
|
||||||
|
migration.dependencies = [name_map.get(d, d) for d in migration.dependencies]
|
||||||
|
return changes
|
||||||
|
|
||||||
|
def trim_to_apps(self, changes, app_labels):
|
||||||
|
"""
|
||||||
|
Takes changes from arrange_for_graph and set of app labels and
|
||||||
|
returns a modified set of changes which trims out as many migrations
|
||||||
|
that are not in app_labels as possible.
|
||||||
|
Note that some other migrations may still be present, as they may be
|
||||||
|
required dependencies.
|
||||||
|
"""
|
||||||
|
# Gather other app dependencies in a first pass
|
||||||
|
app_dependencies = {}
|
||||||
|
for app_label, migrations in changes.items():
|
||||||
|
for migration in migrations:
|
||||||
|
for dep_app_label, name in migration.dependencies:
|
||||||
|
app_dependencies.setdefault(app_label, set()).add(dep_app_label)
|
||||||
|
required_apps = set(app_labels)
|
||||||
|
# Keep resolving till there's no change
|
||||||
|
old_required_apps = None
|
||||||
|
while old_required_apps != required_apps:
|
||||||
|
old_required_apps = set(required_apps)
|
||||||
|
for app_label in list(required_apps):
|
||||||
|
required_apps.update(app_dependencies.get(app_label, set()))
|
||||||
|
# Remove all migrations that aren't needed
|
||||||
|
for app_label in list(changes.keys()):
|
||||||
|
if app_label not in required_apps:
|
||||||
|
del changes[app_label]
|
||||||
|
return changes
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def suggest_name(cls, ops):
|
def suggest_name(cls, ops):
|
||||||
"""
|
"""
|
||||||
|
@ -94,41 +168,40 @@ class MigrationAutodetector(object):
|
||||||
return int(name.split("_")[0])
|
return int(name.split("_")[0])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def arrange_for_graph(cls, changes, graph):
|
class MigrationQuestioner(object):
|
||||||
"""
|
"""
|
||||||
Takes in a result from changes() and a MigrationGraph,
|
Gives the autodetector responses to questions it might have.
|
||||||
and fixes the names and dependencies of the changes so they
|
This base class has a built-in noninteractive mode, but the
|
||||||
extend the graph from the leaf nodes for each app.
|
interactive subclass is what the command-line arguments will use.
|
||||||
"""
|
"""
|
||||||
leaves = graph.leaf_nodes()
|
|
||||||
name_map = {}
|
def __init__(self, defaults=None):
|
||||||
for app_label, migrations in changes.items():
|
self.defaults = defaults or {}
|
||||||
if not migrations:
|
|
||||||
continue
|
def ask_initial(self, app_label):
|
||||||
# Find the app label's current leaf node
|
"Should we create an initial migration for the app?"
|
||||||
app_leaf = None
|
return self.defaults.get("ask_initial", False)
|
||||||
for leaf in leaves:
|
|
||||||
if leaf[0] == app_label:
|
|
||||||
app_leaf = leaf
|
class InteractiveMigrationQuestioner(MigrationQuestioner):
|
||||||
break
|
|
||||||
# Work out the next number in the sequence
|
def __init__(self, specified_apps=set()):
|
||||||
if app_leaf is None:
|
self.specified_apps = specified_apps
|
||||||
next_number = 1
|
|
||||||
else:
|
def _boolean_input(self, question):
|
||||||
next_number = (cls.parse_number(app_leaf[1]) or 0) + 1
|
result = input("%s " % question)
|
||||||
# Name each migration
|
while len(result) < 1 or result[0].lower() not in "yn":
|
||||||
for i, migration in enumerate(migrations):
|
result = input("Please answer yes or no: ")
|
||||||
if i == 0 and app_leaf:
|
return result[0].lower() == "y"
|
||||||
migration.dependencies.append(app_leaf)
|
|
||||||
if i == 0 and not app_leaf:
|
def ask_initial(self, app_label):
|
||||||
new_name = "0001_initial"
|
# Don't ask for django.contrib apps
|
||||||
else:
|
app = cache.get_app(app_label)
|
||||||
new_name = "%04i_%s" % (next_number, cls.suggest_name(migration.operations))
|
if app.__name__.startswith("django.contrib"):
|
||||||
name_map[(app_label, migration.name)] = (app_label, new_name)
|
return False
|
||||||
migration.name = new_name
|
# If it was specified on the command line, definitely true
|
||||||
# Now fix dependencies
|
if app_label in self.specified_apps:
|
||||||
for app_label, migrations in changes.items():
|
return True
|
||||||
for migration in migrations:
|
# Now ask
|
||||||
migration.dependencies = [name_map.get(d, d) for d in migration.dependencies]
|
return self._boolean_input("Do you want to enable migrations for app '%s'?" % app_label)
|
||||||
return changes
|
|
||||||
|
|
|
@ -49,7 +49,7 @@ class MigrationGraph(object):
|
||||||
a database.
|
a database.
|
||||||
"""
|
"""
|
||||||
if node not in self.nodes:
|
if node not in self.nodes:
|
||||||
raise ValueError("Node %r not a valid node" % node)
|
raise ValueError("Node %r not a valid node" % (node, ))
|
||||||
return self.dfs(node, lambda x: self.dependencies.get(x, set()))
|
return self.dfs(node, lambda x: self.dependencies.get(x, set()))
|
||||||
|
|
||||||
def backwards_plan(self, node):
|
def backwards_plan(self, node):
|
||||||
|
@ -60,7 +60,7 @@ class MigrationGraph(object):
|
||||||
a database.
|
a database.
|
||||||
"""
|
"""
|
||||||
if node not in self.nodes:
|
if node not in self.nodes:
|
||||||
raise ValueError("Node %r not a valid node" % node)
|
raise ValueError("Node %r not a valid node" % (node, ))
|
||||||
return self.dfs(node, lambda x: self.dependents.get(x, set()))
|
return self.dfs(node, lambda x: self.dependents.get(x, set()))
|
||||||
|
|
||||||
def root_nodes(self):
|
def root_nodes(self):
|
||||||
|
@ -120,11 +120,16 @@ class MigrationGraph(object):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Graph: %s nodes, %s edges" % (len(self.nodes), sum(len(x) for x in self.dependencies.values()))
|
return "Graph: %s nodes, %s edges" % (len(self.nodes), sum(len(x) for x in self.dependencies.values()))
|
||||||
|
|
||||||
def project_state(self, nodes, at_end=True):
|
def project_state(self, nodes=None, at_end=True):
|
||||||
"""
|
"""
|
||||||
Given a migration node or nodes, returns a complete ProjectState for it.
|
Given a migration node or nodes, returns a complete ProjectState for it.
|
||||||
If at_end is False, returns the state before the migration has run.
|
If at_end is False, returns the state before the migration has run.
|
||||||
|
If nodes is not provided, returns the overall most current project state.
|
||||||
"""
|
"""
|
||||||
|
if nodes is None:
|
||||||
|
nodes = list(self.leaf_nodes())
|
||||||
|
if len(nodes) == 0:
|
||||||
|
return ProjectState()
|
||||||
if not isinstance(nodes[0], tuple):
|
if not isinstance(nodes[0], tuple):
|
||||||
nodes = [nodes]
|
nodes = [nodes]
|
||||||
plan = []
|
plan = []
|
||||||
|
|
|
@ -60,3 +60,10 @@ class MigrationRecorder(object):
|
||||||
"""
|
"""
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
self.Migration.objects.filter(app=app, name=name).delete()
|
self.Migration.objects.filter(app=app, name=name).delete()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def flush(cls):
|
||||||
|
"""
|
||||||
|
Deletes all migration records. Useful if you're testing migrations.
|
||||||
|
"""
|
||||||
|
cls.Migration.objects.all().delete()
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
# encoding: utf8
|
# encoding: utf8
|
||||||
from django.test import TransactionTestCase
|
from django.test import TestCase
|
||||||
from django.db.migrations.autodetector import MigrationAutodetector
|
from django.db.migrations.autodetector import MigrationAutodetector, MigrationQuestioner
|
||||||
from django.db.migrations.state import ProjectState, ModelState
|
from django.db.migrations.state import ProjectState, ModelState
|
||||||
from django.db.migrations.graph import MigrationGraph
|
from django.db.migrations.graph import MigrationGraph
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class AutodetectorTests(TransactionTestCase):
|
class AutodetectorTests(TestCase):
|
||||||
"""
|
"""
|
||||||
Tests the migration autodetector.
|
Tests the migration autodetector.
|
||||||
"""
|
"""
|
||||||
|
@ -14,6 +14,7 @@ class AutodetectorTests(TransactionTestCase):
|
||||||
author_empty = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True))])
|
author_empty = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True))])
|
||||||
other_pony = ModelState("otherapp", "Pony", [("id", models.AutoField(primary_key=True))])
|
other_pony = ModelState("otherapp", "Pony", [("id", models.AutoField(primary_key=True))])
|
||||||
other_stable = ModelState("otherapp", "Stable", [("id", models.AutoField(primary_key=True))])
|
other_stable = ModelState("otherapp", "Stable", [("id", models.AutoField(primary_key=True))])
|
||||||
|
third_thing = ModelState("thirdapp", "Thing", [("id", models.AutoField(primary_key=True))])
|
||||||
|
|
||||||
def make_project_state(self, model_states):
|
def make_project_state(self, model_states):
|
||||||
"Shortcut to make ProjectStates from lists of predefined models"
|
"Shortcut to make ProjectStates from lists of predefined models"
|
||||||
|
@ -44,6 +45,23 @@ class AutodetectorTests(TransactionTestCase):
|
||||||
self.assertEqual(changes["otherapp"][0].name, "0002_pony_stable")
|
self.assertEqual(changes["otherapp"][0].name, "0002_pony_stable")
|
||||||
self.assertEqual(changes["otherapp"][0].dependencies, [("otherapp", "0001_initial")])
|
self.assertEqual(changes["otherapp"][0].dependencies, [("otherapp", "0001_initial")])
|
||||||
|
|
||||||
|
def test_trim_apps(self):
|
||||||
|
"Tests that trim does not remove dependencies but does remove unwanted apps"
|
||||||
|
# Use project state to make a new migration change set
|
||||||
|
before = self.make_project_state([])
|
||||||
|
after = self.make_project_state([self.author_empty, self.other_pony, self.other_stable, self.third_thing])
|
||||||
|
autodetector = MigrationAutodetector(before, after, MigrationQuestioner({"ask_initial": True}))
|
||||||
|
changes = autodetector.changes()
|
||||||
|
# Run through arrange_for_graph
|
||||||
|
graph = MigrationGraph()
|
||||||
|
changes = autodetector.arrange_for_graph(changes, graph)
|
||||||
|
changes["testapp"][0].dependencies.append(("otherapp", "0001_initial"))
|
||||||
|
changes = autodetector.trim_to_apps(changes, set(["testapp"]))
|
||||||
|
# Make sure there's the right set of migrations
|
||||||
|
self.assertEqual(changes["testapp"][0].name, "0001_initial")
|
||||||
|
self.assertEqual(changes["otherapp"][0].name, "0001_initial")
|
||||||
|
self.assertNotIn("thirdapp", changes)
|
||||||
|
|
||||||
def test_new_model(self):
|
def test_new_model(self):
|
||||||
"Tests autodetection of new models"
|
"Tests autodetection of new models"
|
||||||
# Make state
|
# Make state
|
||||||
|
|
|
@ -18,6 +18,8 @@ class SchemaTests(TransactionTestCase):
|
||||||
as the code it is testing.
|
as the code it is testing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
available_apps = []
|
||||||
|
|
||||||
models = [Author, AuthorWithM2M, Book, BookWithSlug, BookWithM2M, Tag, TagUniqueRename, UniqueTest]
|
models = [Author, AuthorWithM2M, Book, BookWithSlug, BookWithM2M, Tag, TagUniqueRename, UniqueTest]
|
||||||
no_table_strings = ["no such table", "unknown table", "does not exist"]
|
no_table_strings = ["no such table", "unknown table", "does not exist"]
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue