]> git.ipfire.org Git - thirdparty/sqlalchemy/alembic.git/commitdiff
- Batch mode generates a FOREIGN KEY constraint that is self-referential
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 15 Dec 2015 17:37:04 +0000 (12:37 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 15 Dec 2015 17:37:04 +0000 (12:37 -0500)
using the ultimate table name, rather than ``_alembic_batch_temp``.
When the table is renamed from ``_alembic_batch_temp`` back to the
original name, the FK now points to the right name.  This
will **not** work if referential integrity is being enforced (eg. SQLite
"PRAGMA FOREIGN_KEYS=ON") since the original table is dropped and
the new table then renamed to that name, however this is now consistent
with how foreign key constraints on **other** tables already operate
with batch mode; these don't support batch mode if referential integrity
is enabled in any case.
fixes #345

alembic/operations/batch.py
alembic/testing/exclusions.py
alembic/util/sqla_compat.py
docs/build/batch.rst
docs/build/changelog.rst
tests/test_batch.py

index 369d08dd0f9bca967d213b772c2e5446e34f365f..b34377309669b4bda42093a1ffd770818e92a6a8 100644 (file)
@@ -4,7 +4,8 @@ from sqlalchemy import types as sqltypes
 from sqlalchemy import schema as sql_schema
 from sqlalchemy.util import OrderedDict
 from .. import util
-from ..util.sqla_compat import _columns_for_constraint, _is_type_bound
+from ..util.sqla_compat import _columns_for_constraint, \
+    _is_type_bound, _fk_is_self_referential
 
 
 class BatchOperationsImpl(object):
@@ -163,7 +164,24 @@ class ApplyBatchImpl(object):
 
             if not const_columns.issubset(self.column_transfers):
                 continue
-            const_copy = const.copy(schema=schema, target_table=new_table)
+
+            if isinstance(const, ForeignKeyConstraint):
+                if _fk_is_self_referential(const):
+                    # for self-referential constraint, refer to the
+                    # *original* table name, and not _alembic_batch_temp.
+                    # This is consistent with how we're handling
+                    # FK constraints from other tables; we assume SQLite
+                    # no foreign keys just keeps the names unchanged, so
+                    # when we rename back, they match again.
+                    const_copy = const.copy(
+                        schema=schema, target_table=self.table)
+                else:
+                    # "target_table" for ForeignKeyConstraint.copy() is
+                    # only used if the FK is detected as being
+                    # self-referential, which we are handling above.
+                    const_copy = const.copy(schema=schema)
+            else:
+                const_copy = const.copy(schema=schema, target_table=new_table)
             if isinstance(const, ForeignKeyConstraint):
                 self._setup_referent(m, const)
             new_table.append_constraint(const_copy)
@@ -189,6 +207,7 @@ class ApplyBatchImpl(object):
             referent_schema = parts[0]
         else:
             referent_schema = None
+
         if tname != '_alembic_batch_temp':
             key = sql_schema._get_table_key(tname, referent_schema)
             if key in metadata.tables:
index 90f8bc6dee5a9c83a000a8e43191859be493662f..9d62d3aa9ac95a4296a5e2004192e59ffbf14995 100644 (file)
@@ -404,8 +404,8 @@ def closed():
     return skip_if(BooleanPredicate(True, "marked as skip"))
 
 
-def fails():
-    return fails_if(BooleanPredicate(True, "expected to fail"))
+def fails(msg=None):
+    return fails_if(BooleanPredicate(True, msg or "expected to fail"))
 
 
 @decorator
index ddebbee916c208a83dc10b5fbac2afce92284b98..88bfa3b2f538cb44d6abd2a2e30b76900c786d4d 100644 (file)
@@ -74,6 +74,14 @@ def _fk_spec(constraint):
         onupdate, ondelete, deferrable, initially)
 
 
+def _fk_is_self_referential(constraint):
+    spec = constraint.elements[0]._get_colspec()
+    tokens = spec.split(".")
+    tokens.pop(-1)  # colname
+    tablekey = ".".join(tokens)
+    return tablekey == constraint.parent.key
+
+
 def _is_type_bound(constraint):
     # this deals with SQLAlchemy #3260, don't copy CHECK constraints
     # that will be generated by the type.
index 64eeefb247d6f1e2f7345d3c9a7a6d1d355bce30..b14b86069f696e51b2c0eca2fed8f7d4debdd421 100644 (file)
@@ -209,15 +209,37 @@ recreated table::
 Dealing with Referencing Foreign Keys
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-If the SQLite database is enforcing referential integrity with
-``PRAGMA FOREIGN KEYS``, this pragma may need to be disabled when the workflow
-mode proceeds, else remote constraints which refer to this table may prevent
-it from being dropped; additionally, for referential integrity to be
-re-enabled, it may be necessary to recreate the
-foreign keys on those remote tables to refer again to the new table (this
-is definitely the case on other databases, at least).  SQLite is normally used
-without referential integrity enabled so this won't be a problem for most
-users.
+It is important to note that batch table operations **do not work** with
+foreign keys that enforce referential integrity.  This because the
+target table is dropped; if foreign keys refer to it, this will raise
+an error.   On SQLite, whether or not foreign keys actually enforce is
+controlled by the ``PRAGMA FOREIGN KEYS`` pragma; this pragma, if in use,
+must be disabled when the workflow mode proceeds.   When the operation is
+complete, the batch-migrated table will have the same name
+that it started with, so those referring foreign keys will again
+refer to this table.
+
+A special case is dealing with self-referring foreign keys.  Here,
+Alembic takes a special step of recreating the self-referring foreign key
+as referring to the original table name, rather than at the "temp" table,
+so that like in the case of other foreign key constraints, when the table
+is renamed to its original name, the foreign key
+again references the correct table.   This operation only works when
+referential integrity is disabled, consistent with the same requirement
+for referring foreign keys from other tables.
+
+.. versionchanged:: 0.8.4 Self-referring foreign keys are created with the
+   target table name in batch mode, even though this table will temporarily
+   not exist when dropped.  This requires that the target database is not
+   enforcing referential integrity.
+
+When SQLite's ``PRAGMA FOREIGN KEYS`` mode is turned on, it does provide
+the service that foreign key constraints, including self-referential, will
+automatically be modified to point to their table across table renames,
+however this mode prevents the target table from being dropped as is required
+by a batch migration.  Therefore it may be necessary to manipulate the
+``PRAGMA FOREIGN KEYS`` setting if a migration seeks to rename a table vs.
+batch migrate it.
 
 .. _batch_offline_mode:
 
index d9b8de5bd90416a1612c915c03d2bfcf3d85112b..17be02809728d476d831cf31c57894cef2616f12 100644 (file)
@@ -6,6 +6,21 @@ Changelog
 .. changelog::
     :version: 0.8.4
 
+    .. change::
+      :tags: bug, batch
+      :tickets: 345
+
+      Batch mode generates a FOREIGN KEY constraint that is self-referential
+      using the ultimate table name, rather than ``_alembic_batch_temp``.
+      When the table is renamed from ``_alembic_batch_temp`` back to the
+      original name, the FK now points to the right name.  This
+      will **not** work if referential integrity is being enforced (eg. SQLite
+      "PRAGMA FOREIGN_KEYS=ON") since the original table is dropped and
+      the new table then renamed to that name, however this is now consistent
+      with how foreign key constraints on **other** tables already operate
+      with batch mode; these don't support batch mode if referential integrity
+      is enabled in any case.
+
     .. change::
       :tags: bug, autogenerate
       :tickets: 341
index 6842216cbc74b1b23a34dab8a65d6173daa2a57d..181c3dc6007a7466fef70eea248b43e48a44daf0 100644 (file)
@@ -854,6 +854,14 @@ class BatchRoundTripTest(TestBase):
         context = MigrationContext.configure(self.conn)
         self.op = Operations(context)
 
+    @contextmanager
+    def _sqlite_referential_integrity(self):
+        self.conn.execute("PRAGMA foreign_keys=ON")
+        try:
+            yield
+        finally:
+            self.conn.execute("PRAGMA foreign_keys=OFF")
+
     def _no_pk_fixture(self):
         nopk = Table(
             'nopk', self.metadata,
@@ -924,6 +932,14 @@ class BatchRoundTripTest(TestBase):
     def test_fk_points_to_me_recreate(self):
         self._test_fk_points_to_me("always")
 
+    @exclusions.only_on("sqlite")
+    @exclusions.fails(
+        "intentionally asserting that this "
+        "doesn't work w/ pragma foreign keys")
+    def test_fk_points_to_me_sqlite_refinteg(self):
+        with self._sqlite_referential_integrity():
+            self._test_fk_points_to_me("auto")
+
     def _test_fk_points_to_me(self, recreate):
         bar = Table(
             'bar', self.metadata,
@@ -938,6 +954,55 @@ class BatchRoundTripTest(TestBase):
             batch_op.alter_column(
                 'data', new_column_name='newdata', existing_type=String(50))
 
+        insp = Inspector.from_engine(self.conn)
+        eq_(
+            [(key['referred_table'],
+             key['referred_columns'], key['constrained_columns'])
+             for key in insp.get_foreign_keys('bar')],
+            [('foo', ['id'], ['foo_id'])]
+        )
+
+    def test_selfref_fk_auto(self):
+        self._test_selfref_fk("auto")
+
+    @config.requirements.no_referential_integrity
+    def test_selfref_fk_recreate(self):
+        self._test_selfref_fk("always")
+
+    @exclusions.only_on("sqlite")
+    @exclusions.fails(
+        "intentionally asserting that this "
+        "doesn't work w/ pragma foreign keys")
+    def test_selfref_fk_sqlite_refinteg(self):
+        with self._sqlite_referential_integrity():
+            self._test_selfref_fk("auto")
+
+    def _test_selfref_fk(self, recreate):
+        bar = Table(
+            'bar', self.metadata,
+            Column('id', Integer, primary_key=True),
+            Column('bar_id', Integer, ForeignKey('bar.id')),
+            Column('data', String(50)),
+            mysql_engine='InnoDB'
+        )
+        bar.create(self.conn)
+        self.conn.execute(bar.insert(), {'id': 1, 'data': 'x', 'bar_id': None})
+        self.conn.execute(bar.insert(), {'id': 2, 'data': 'y', 'bar_id': 1})
+
+        with self.op.batch_alter_table("bar", recreate=recreate) as batch_op:
+            batch_op.alter_column(
+                'data', new_column_name='newdata', existing_type=String(50))
+
+        insp = Inspector.from_engine(self.conn)
+
+        insp = Inspector.from_engine(self.conn)
+        eq_(
+            [(key['referred_table'],
+             key['referred_columns'], key['constrained_columns'])
+             for key in insp.get_foreign_keys('bar')],
+            [('bar', ['id'], ['bar_id'])]
+        )
+
     def test_change_type(self):
         with self.op.batch_alter_table("foo") as batch_op:
             batch_op.alter_column('data', type_=Integer)