Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Adding 'sqlmigrate' command and quote_parameter to support it.

  • Loading branch information...
commit efd1e6096ee87fe332cf989ba5479e9461d0fb3a 1 parent 5ca290f
Andrew Godwin authored September 06, 2013
52  django/core/management/commands/sqlmigrate.py
... ...
@@ -0,0 +1,52 @@
  1
+# encoding: utf8
  2
+from __future__ import unicode_literals
  3
+from optparse import make_option
  4
+
  5
+from django.core.management.base import BaseCommand, CommandError
  6
+from django.db import connections, DEFAULT_DB_ALIAS
  7
+from django.db.migrations.executor import MigrationExecutor
  8
+from django.db.migrations.loader import AmbiguityError
  9
+
  10
+
  11
+class Command(BaseCommand):
  12
+
  13
+    option_list = BaseCommand.option_list + (
  14
+        make_option('--database', action='store', dest='database',
  15
+            default=DEFAULT_DB_ALIAS, help='Nominates a database to create SQL for. '
  16
+                'Defaults to the "default" database.'),
  17
+        make_option('--backwards', action='store_true', dest='backwards',
  18
+            default=False, help='Creates SQL to unapply the migration, rather than to apply it'),
  19
+    )
  20
+
  21
+    help = "Prints the SQL statements for the named migration."
  22
+
  23
+    def handle(self, *args, **options):
  24
+
  25
+        # Get the database we're operating from
  26
+        db = options.get('database')
  27
+        connection = connections[db]
  28
+
  29
+        # Load up an executor to get all the migration data
  30
+        executor = MigrationExecutor(connection)
  31
+
  32
+        # Resolve command-line arguments into a migration
  33
+        if len(args) != 2:
  34
+            raise CommandError("Wrong number of arguments (expecting 'sqlmigrate appname migrationname')")
  35
+        else:
  36
+            app_label, migration_name = args
  37
+            if app_label not in executor.loader.migrated_apps:
  38
+                raise CommandError("App '%s' does not have migrations" % app_label)
  39
+            try:
  40
+                migration = executor.loader.get_migration_by_prefix(app_label, migration_name)
  41
+            except AmbiguityError:
  42
+                raise CommandError("More than one migration matches '%s' in app '%s'. Please be more specific." % (app_label, migration_name))
  43
+            except KeyError:
  44
+                raise CommandError("Cannot find a migration matching '%s' from app '%s'. Is it in INSTALLED_APPS?" % (app_label, migration_name))
  45
+            targets = [(app_label, migration.name)]
  46
+
  47
+        # Make a plan that represents just the requested migrations and show SQL
  48
+        # for it
  49
+        plan = [(executor.loader.graph.nodes[targets[0]], options.get("backwards", False))]
  50
+        sql_statements = executor.collect_sql(plan)
  51
+        for statement in sql_statements:
  52
+            self.stdout.write(statement)
11  django/db/backends/__init__.py
@@ -521,7 +521,7 @@ def _start_transaction_under_autocommit(self):
521 521
         """
522 522
         raise NotImplementedError
523 523
 
524  
-    def schema_editor(self):
  524
+    def schema_editor(self, *args, **kwargs):
525 525
         "Returns a new instance of this backend's SchemaEditor"
526 526
         raise NotImplementedError()
527 527
 
@@ -958,6 +958,15 @@ def quote_name(self, name):
958 958
         """
959 959
         raise NotImplementedError()
960 960
 
  961
+    def quote_parameter(self, value):
  962
+        """
  963
+        Returns a quoted version of the value so it's safe to use in an SQL
  964
+        string. This should NOT be used to prepare SQL statements to send to
  965
+        the database; it is meant for outputting SQL statements to a file
  966
+        or the console for later execution by a developer/DBA.
  967
+        """
  968
+        raise NotImplementedError()
  969
+
961 970
     def random_function_sql(self):
962 971
         """
963 972
         Returns an SQL expression that returns a random value.
9  django/db/backends/mysql/base.py
@@ -305,6 +305,11 @@ def quote_name(self, name):
305 305
             return name # Quoting once is enough.
306 306
         return "`%s`" % name
307 307
 
  308
+    def quote_parameter(self, value):
  309
+        # Inner import to allow module to fail to load gracefully
  310
+        import MySQLdb.converters
  311
+        return MySQLdb.escape(value, MySQLdb.converters.conversions)
  312
+
308 313
     def random_function_sql(self):
309 314
         return 'RAND()'
310 315
 
@@ -518,9 +523,9 @@ def check_constraints(self, table_names=None):
518 523
                         table_name, column_name, bad_row[1],
519 524
                         referenced_table_name, referenced_column_name))
520 525
 
521  
-    def schema_editor(self):
  526
+    def schema_editor(self, *args, **kwargs):
522 527
         "Returns a new instance of this backend's SchemaEditor"
523  
-        return DatabaseSchemaEditor(self)
  528
+        return DatabaseSchemaEditor(self, *args, **kwargs)
524 529
 
525 530
     def is_usable(self):
526 531
         try:
14  django/db/backends/oracle/base.py
@@ -320,6 +320,16 @@ def quote_name(self, name):
320 320
         name = name.replace('%', '%%')
321 321
         return name.upper()
322 322
 
  323
+    def quote_parameter(self, value):
  324
+        if isinstance(value, (datetime.date, datetime.time, datetime.datetime)):
  325
+            return "'%s'" % value
  326
+        elif isinstance(value, six.string_types):
  327
+            return repr(value)
  328
+        elif isinstance(value, bool):
  329
+            return "1" if value else "0"
  330
+        else:
  331
+            return str(value)
  332
+
323 333
     def random_function_sql(self):
324 334
         return "DBMS_RANDOM.RANDOM"
325 335
 
@@ -628,9 +638,9 @@ def _commit(self):
628 638
                     six.reraise(utils.IntegrityError, utils.IntegrityError(*tuple(e.args)), sys.exc_info()[2])
629 639
                 raise
630 640
 
631  
-    def schema_editor(self):
  641
+    def schema_editor(self, *args, **kwargs):
632 642
         "Returns a new instance of this backend's SchemaEditor"
633  
-        return DatabaseSchemaEditor(self)
  643
+        return DatabaseSchemaEditor(self, *args, **kwargs)
634 644
 
635 645
     # Oracle doesn't support savepoint commits.  Ignore them.
636 646
     def _savepoint_commit(self, sid):
9  django/db/backends/oracle/schema.py
@@ -93,11 +93,4 @@ def _generate_temp_name(self, for_name):
93 93
         return self.normalize_name(for_name + "_" + suffix)
94 94
 
95 95
     def prepare_default(self, value):
96  
-        if isinstance(value, (datetime.date, datetime.time, datetime.datetime)):
97  
-            return "'%s'" % value
98  
-        elif isinstance(value, six.string_types):
99  
-            return repr(value)
100  
-        elif isinstance(value, bool):
101  
-            return "1" if value else "0"
102  
-        else:
103  
-            return str(value)
  96
+        return self.connection.ops.quote_parameter(value)
4  django/db/backends/postgresql_psycopg2/base.py
@@ -205,9 +205,9 @@ def is_usable(self):
205 205
         else:
206 206
             return True
207 207
 
208  
-    def schema_editor(self):
  208
+    def schema_editor(self, *args, **kwargs):
209 209
         "Returns a new instance of this backend's SchemaEditor"
210  
-        return DatabaseSchemaEditor(self)
  210
+        return DatabaseSchemaEditor(self, *args, **kwargs)
211 211
 
212 212
     @cached_property
213 213
     def psycopg2_version(self):
5  django/db/backends/postgresql_psycopg2/operations.py
@@ -98,6 +98,11 @@ def quote_name(self, name):
98 98
             return name # Quoting once is enough.
99 99
         return '"%s"' % name
100 100
 
  101
+    def quote_parameter(self, value):
  102
+        # Inner import so backend fails nicely if it's not present
  103
+        import psycopg2
  104
+        return psycopg2.extensions.adapt(value)
  105
+
101 106
     def set_time_zone_sql(self):
102 107
         return "SET TIME ZONE %s"
103 108
 
12  django/db/backends/schema.py
@@ -54,14 +54,17 @@ class BaseDatabaseSchemaEditor(object):
54 54
     sql_create_fk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) REFERENCES %(to_table)s (%(to_column)s) DEFERRABLE INITIALLY DEFERRED"
55 55
     sql_delete_fk = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s"
56 56
 
57  
-    sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s;"
  57
+    sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s"
58 58
     sql_delete_index = "DROP INDEX %(name)s"
59 59
 
60 60
     sql_create_pk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)s)"
61 61
     sql_delete_pk = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s"
62 62
 
63  
-    def __init__(self, connection):
  63
+    def __init__(self, connection, collect_sql=False):
64 64
         self.connection = connection
  65
+        self.collect_sql = collect_sql
  66
+        if self.collect_sql:
  67
+            self.collected_sql = []
65 68
 
66 69
     # State-managing methods
67 70
 
@@ -86,7 +89,10 @@ def execute(self, sql, params=[]):
86 89
         cursor = self.connection.cursor()
87 90
         # Log the command we're running, then run it
88 91
         logger.debug("%s; (params %r)" % (sql, params))
89  
-        cursor.execute(sql, params)
  92
+        if self.collect_sql:
  93
+            self.collected_sql.append((sql % map(self.connection.ops.quote_parameter, params)) + ";")
  94
+        else:
  95
+            cursor.execute(sql, params)
90 96
 
91 97
     def quote_name(self, name):
92 98
         return self.connection.ops.quote_name(name)
23  django/db/backends/sqlite3/base.py
@@ -214,6 +214,25 @@ def quote_name(self, name):
214 214
             return name # Quoting once is enough.
215 215
         return '"%s"' % name
216 216
 
  217
+    def quote_parameter(self, value):
  218
+        # Inner import to allow nice failure for backend if not present
  219
+        import _sqlite3
  220
+        try:
  221
+            value = _sqlite3.adapt(value)
  222
+        except _sqlite3.ProgrammingError:
  223
+            pass
  224
+        # Manual emulation of SQLite parameter quoting
  225
+        if isinstance(value, six.integer_types):
  226
+            return str(value)
  227
+        elif isinstance(value, six.string_types):
  228
+            return six.text_type(value)
  229
+        elif isinstance(value, type(True)):
  230
+            return str(int(value))
  231
+        elif value is None:
  232
+            return "NULL"
  233
+        else:
  234
+            raise ValueError("Cannot quote parameter value %r" % value)
  235
+
217 236
     def no_limit_value(self):
218 237
         return -1
219 238
 
@@ -437,9 +456,9 @@ def _start_transaction_under_autocommit(self):
437 456
         """
438 457
         self.cursor().execute("BEGIN")
439 458
 
440  
-    def schema_editor(self):
  459
+    def schema_editor(self, *args, **kwargs):
441 460
         "Returns a new instance of this backend's SchemaEditor"
442  
-        return DatabaseSchemaEditor(self)
  461
+        return DatabaseSchemaEditor(self, *args, **kwargs)
443 462
 
444 463
 FORMAT_QMARK_REGEX = re.compile(r'(?<!%)%s')
445 464
 
4  django/db/backends/sqlite3/schema.py
@@ -55,7 +55,7 @@ def _remake_table(self, model, create_fields=[], delete_fields=[], alter_fields=
55 55
         self.create_model(temp_model)
56 56
         # Copy data from the old table
57 57
         field_maps = list(mapping.items())
58  
-        self.execute("INSERT INTO %s (%s) SELECT %s FROM %s;" % (
  58
+        self.execute("INSERT INTO %s (%s) SELECT %s FROM %s" % (
59 59
             self.quote_name(temp_model._meta.db_table),
60 60
             ', '.join(x for x, y in field_maps),
61 61
             ', '.join(y for x, y in field_maps),
@@ -137,7 +137,7 @@ def _alter_many_to_many(self, model, old_field, new_field, strict):
137 137
         # Make a new through table
138 138
         self.create_model(new_field.rel.through)
139 139
         # Copy the data across
140  
-        self.execute("INSERT INTO %s (%s) SELECT %s FROM %s;" % (
  140
+        self.execute("INSERT INTO %s (%s) SELECT %s FROM %s" % (
141 141
             self.quote_name(new_field.rel.through._meta.db_table),
142 142
             ', '.join([
143 143
                 "id",
16  django/db/migrations/executor.py
@@ -61,6 +61,22 @@ def migrate(self, targets, plan=None, fake=False):
61 61
             else:
62 62
                 self.unapply_migration(migration, fake=fake)
63 63
 
  64
+    def collect_sql(self, plan):
  65
+        """
  66
+        Takes a migration plan and returns a list of collected SQL
  67
+        statements that represent the best-efforts version of that plan.
  68
+        """
  69
+        statements = []
  70
+        for migration, backwards in plan:
  71
+            with self.connection.schema_editor(collect_sql=True) as schema_editor:
  72
+                project_state = self.loader.graph.project_state((migration.app_label, migration.name), at_end=False)
  73
+                if not backwards:
  74
+                    migration.apply(project_state, schema_editor)
  75
+                else:
  76
+                    migration.unapply(project_state, schema_editor)
  77
+                statements.extend(schema_editor.collected_sql)
  78
+        return statements
  79
+
64 80
     def apply_migration(self, migration, fake=False):
65 81
         """
66 82
         Runs a migration forwards.
18  docs/ref/django-admin.txt
@@ -993,6 +993,24 @@ Prints the CREATE INDEX SQL statements for the given app name(s).
993 993
 The :djadminopt:`--database` option can be used to specify the database for
994 994
 which to print the SQL.
995 995
 
  996
+sqlmigrate <appname> <migrationname>
  997
+------------------------------------
  998
+
  999
+.. django-admin:: sqlmigrate
  1000
+
  1001
+Prints the SQL for the named migration. This requires an active database
  1002
+connection, which it will use to resolve constraint names; this means you must
  1003
+generate the SQL against a copy of the database you wish to later apply it on.
  1004
+
  1005
+The :djadminopt:`--database` option can be used to specify the database for
  1006
+which to generate the SQL.
  1007
+
  1008
+.. django-admin-option:: --backwards
  1009
+
  1010
+By default, the SQL created is for running the migration in the forwards
  1011
+direction. Pass ``--backwards`` to generate the SQL for
  1012
+un-applying the migration instead.
  1013
+
996 1014
 sqlsequencereset <appname appname ...>
997 1015
 --------------------------------------
998 1016
 
14  tests/migrations/test_commands.py
@@ -48,6 +48,20 @@ def test_migrate(self):
48 48
         self.assertTableNotExists("migrations_tribble")
49 49
         self.assertTableNotExists("migrations_book")
50 50
 
  51
+    @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"})
  52
+    def test_sqlmigrate(self):
  53
+        """
  54
+        Makes sure that sqlmigrate does something.
  55
+        """
  56
+        # Test forwards. All the databases agree on CREATE TABLE, at least.
  57
+        stdout = six.StringIO()
  58
+        call_command("sqlmigrate", "migrations", "0001", stdout=stdout)
  59
+        self.assertIn("create table", stdout.getvalue().lower())
  60
+        # And backwards is a DROP TABLE
  61
+        stdout = six.StringIO()
  62
+        call_command("sqlmigrate", "migrations", "0001", stdout=stdout, backwards=True)
  63
+        self.assertIn("drop table", stdout.getvalue().lower())
  64
+
51 65
 
52 66
 class MakeMigrationsTests(MigrationTestBase):
53 67
     """

0 notes on commit efd1e60

Please sign in to comment.
Something went wrong with that request. Please try again.