]> git.ipfire.org Git - thirdparty/sqlalchemy/alembic.git/commitdiff
add inline_reference parameter
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 27 Jan 2026 19:35:59 +0000 (14:35 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 27 Jan 2026 21:04:19 +0000 (16:04 -0500)
Added ``inline_references`` parameter to :meth:`.Operations.add_column`
which allows rendering of ``REFERENCES`` clauses inline within the ``ADD
COLUMN`` directive rather than as a separate ``ADD CONSTRAINT`` directive.
This syntax is supported by PostgreSQL, Oracle, MySQL 5.7+, and MariaDB
10.5+, and can provide performance benefits on large tables by avoiding
full table validation when adding a nullable foreign key column.

Fixes: #1780
Change-Id: I6749c0a2b92af61e95decea552c8afdd93e11c19

alembic/ddl/base.py
alembic/ddl/impl.py
alembic/op.pyi
alembic/operations/base.py
alembic/operations/ops.py
alembic/operations/toimpl.py
docs/build/unreleased/1780.rst [new file with mode: 0644]
tests/test_batch.py
tests/test_op.py

index 1968e093dcc93bc482c4c013429e9af3e022c011..caab9098463bb25792be3e96c09d2462b794847a 100644 (file)
@@ -155,10 +155,12 @@ class AddColumn(AlterTable):
         column: Column[Any],
         schema: Optional[Union[quoted_name, str]] = None,
         if_not_exists: Optional[bool] = None,
+        inline_references: Optional[bool] = None,
     ) -> None:
         super().__init__(name, schema=schema)
         self.column = column
         self.if_not_exists = if_not_exists
+        self.inline_references = inline_references
 
 
 class DropColumn(AlterTable):
@@ -197,7 +199,11 @@ def visit_add_column(element: AddColumn, compiler: DDLCompiler, **kw) -> str:
     return "%s %s" % (
         alter_table(compiler, element.table_name, element.schema),
         add_column(
-            compiler, element.column, if_not_exists=element.if_not_exists, **kw
+            compiler,
+            element.column,
+            if_not_exists=element.if_not_exists,
+            inline_references=element.inline_references,
+            **kw,
         ),
     )
 
@@ -348,6 +354,7 @@ def add_column(
     compiler: DDLCompiler,
     column: Column[Any],
     if_not_exists: Optional[bool] = None,
+    inline_references: Optional[bool] = None,
     **kw,
 ) -> str:
     text = "ADD COLUMN %s%s" % (
@@ -358,6 +365,34 @@ def add_column(
     if column.primary_key:
         text += " PRIMARY KEY"
 
+    # Handle inline REFERENCES if requested
+    # Only render inline if there's exactly one foreign key AND the
+    # ForeignKeyConstraint is single-column, to avoid non-deterministic
+    # behavior with sets and to ensure proper syntax
+    if (
+        inline_references
+        and len(column.foreign_keys) == 1
+        and (fk := list(column.foreign_keys)[0])
+        and fk.constraint is not None
+        and len(fk.constraint.columns) == 1
+    ):
+        ref_col = fk.column
+        ref_table = ref_col.table
+
+        # Format with proper quoting
+        if ref_table.schema:
+            table_name = "%s.%s" % (
+                compiler.preparer.quote_schema(ref_table.schema),
+                compiler.preparer.quote(ref_table.name),
+            )
+        else:
+            table_name = compiler.preparer.quote(ref_table.name)
+
+        text += " REFERENCES %s (%s)" % (
+            table_name,
+            compiler.preparer.quote(ref_col.name),
+        )
+
     const = " ".join(
         compiler.process(constraint) for constraint in column.constraints
     )
index c0d1751d7163fbac8e0832d0f10d1caa3cbda7c4..a6153106af16bc618a804ffda305faa4b1007518 100644 (file)
@@ -386,6 +386,7 @@ class DefaultImpl(metaclass=ImplMeta):
         *,
         schema: Optional[Union[str, quoted_name]] = None,
         if_not_exists: Optional[bool] = None,
+        inline_references: Optional[bool] = None,
     ) -> None:
         self._exec(
             base.AddColumn(
@@ -393,6 +394,7 @@ class DefaultImpl(metaclass=ImplMeta):
                 column,
                 schema=schema,
                 if_not_exists=if_not_exists,
+                inline_references=inline_references,
             )
         )
 
index 9c39648cbb0113d74378f432c1fed22c868818a0..40a480cc6f68be6f3a19ea9b8a01b906fb5965f7 100644 (file)
@@ -64,6 +64,7 @@ def add_column(
     *,
     schema: Optional[str] = None,
     if_not_exists: Optional[bool] = None,
+    inline_references: Optional[bool] = None,
 ) -> None:
     """Issue an "add column" instruction using the current
     migration context.
@@ -100,9 +101,9 @@ def add_column(
 
     The provided :class:`~sqlalchemy.schema.Column` object may include a
     :class:`~sqlalchemy.schema.ForeignKey` constraint directive,
-    referencing a remote table name. For this specific type of constraint,
-    Alembic will automatically emit a second ALTER statement in order to
-    add the single-column FOREIGN KEY constraint separately::
+    referencing a remote table name. By default, Alembic will automatically
+    emit a second ALTER statement in order to add the single-column FOREIGN
+    KEY constraint separately::
 
         from alembic import op
         from sqlalchemy import Column, INTEGER, ForeignKey
@@ -112,6 +113,20 @@ def add_column(
             Column("account_id", INTEGER, ForeignKey("accounts.id")),
         )
 
+    To render the FOREIGN KEY constraint inline within the ADD COLUMN
+    directive, use the ``inline_references`` parameter. This can improve
+    performance on large tables since the constraint is marked as valid
+    immediately for nullable columns::
+
+        from alembic import op
+        from sqlalchemy import Column, INTEGER, ForeignKey
+
+        op.add_column(
+            "organization",
+            Column("account_id", INTEGER, ForeignKey("accounts.id")),
+            inline_references=True,
+        )
+
     The column argument passed to :meth:`.Operations.add_column` is a
     :class:`~sqlalchemy.schema.Column` construct, used in the same way it's
     used in SQLAlchemy. In particular, values or functions to be indicated
@@ -140,6 +155,14 @@ def add_column(
 
      .. versionadded:: 1.16.0
 
+    :param inline_references: If True, renders FOREIGN KEY constraints
+     inline within the ADD COLUMN directive using REFERENCES syntax,
+     rather than as a separate ALTER TABLE ADD CONSTRAINT statement.
+     This is supported by PostgreSQL, Oracle, MySQL 5.7+, and
+     MariaDB 10.5+.
+
+     .. versionadded:: 1.18.2
+
     """
 
 def alter_column(
index 531f4ade6ee994f637644928349d795c4c70a7a6..218ea190607dba7ed8f23b1e4435014a060c531c 100644 (file)
@@ -630,6 +630,7 @@ class Operations(AbstractOperations):
             *,
             schema: Optional[str] = None,
             if_not_exists: Optional[bool] = None,
+            inline_references: Optional[bool] = None,
         ) -> None:
             """Issue an "add column" instruction using the current
             migration context.
@@ -666,9 +667,9 @@ class Operations(AbstractOperations):
 
             The provided :class:`~sqlalchemy.schema.Column` object may include a
             :class:`~sqlalchemy.schema.ForeignKey` constraint directive,
-            referencing a remote table name. For this specific type of constraint,
-            Alembic will automatically emit a second ALTER statement in order to
-            add the single-column FOREIGN KEY constraint separately::
+            referencing a remote table name. By default, Alembic will automatically
+            emit a second ALTER statement in order to add the single-column FOREIGN
+            KEY constraint separately::
 
                 from alembic import op
                 from sqlalchemy import Column, INTEGER, ForeignKey
@@ -678,6 +679,20 @@ class Operations(AbstractOperations):
                     Column("account_id", INTEGER, ForeignKey("accounts.id")),
                 )
 
+            To render the FOREIGN KEY constraint inline within the ADD COLUMN
+            directive, use the ``inline_references`` parameter. This can improve
+            performance on large tables since the constraint is marked as valid
+            immediately for nullable columns::
+
+                from alembic import op
+                from sqlalchemy import Column, INTEGER, ForeignKey
+
+                op.add_column(
+                    "organization",
+                    Column("account_id", INTEGER, ForeignKey("accounts.id")),
+                    inline_references=True,
+                )
+
             The column argument passed to :meth:`.Operations.add_column` is a
             :class:`~sqlalchemy.schema.Column` construct, used in the same way it's
             used in SQLAlchemy. In particular, values or functions to be indicated
@@ -706,6 +721,14 @@ class Operations(AbstractOperations):
 
              .. versionadded:: 1.16.0
 
+            :param inline_references: If True, renders FOREIGN KEY constraints
+             inline within the ADD COLUMN directive using REFERENCES syntax,
+             rather than as a separate ALTER TABLE ADD CONSTRAINT statement.
+             This is supported by PostgreSQL, Oracle, MySQL 5.7+, and
+             MariaDB 10.5+.
+
+             .. versionadded:: 1.18.2
+
             """  # noqa: E501
             ...
 
@@ -1667,6 +1690,7 @@ class BatchOperations(AbstractOperations):
             insert_before: Optional[str] = None,
             insert_after: Optional[str] = None,
             if_not_exists: Optional[bool] = None,
+            inline_references: Optional[bool] = None,
         ) -> None:
             """Issue an "add column" instruction using the current
             batch migration context.
index ba7f982478563f4f701d372b43dbea352d1463f9..575af2a9e8170e9049098f2e65870559e7a70da4 100644 (file)
@@ -2053,11 +2053,13 @@ class AddColumnOp(AlterTableOp):
         *,
         schema: Optional[str] = None,
         if_not_exists: Optional[bool] = None,
+        inline_references: Optional[bool] = None,
         **kw: Any,
     ) -> None:
         super().__init__(table_name, schema=schema)
         self.column = column
         self.if_not_exists = if_not_exists
+        self.inline_references = inline_references
         self.kw = kw
 
     def reverse(self) -> DropColumnOp:
@@ -2097,6 +2099,7 @@ class AddColumnOp(AlterTableOp):
         *,
         schema: Optional[str] = None,
         if_not_exists: Optional[bool] = None,
+        inline_references: Optional[bool] = None,
     ) -> None:
         """Issue an "add column" instruction using the current
         migration context.
@@ -2133,9 +2136,9 @@ class AddColumnOp(AlterTableOp):
 
         The provided :class:`~sqlalchemy.schema.Column` object may include a
         :class:`~sqlalchemy.schema.ForeignKey` constraint directive,
-        referencing a remote table name. For this specific type of constraint,
-        Alembic will automatically emit a second ALTER statement in order to
-        add the single-column FOREIGN KEY constraint separately::
+        referencing a remote table name. By default, Alembic will automatically
+        emit a second ALTER statement in order to add the single-column FOREIGN
+        KEY constraint separately::
 
             from alembic import op
             from sqlalchemy import Column, INTEGER, ForeignKey
@@ -2145,6 +2148,20 @@ class AddColumnOp(AlterTableOp):
                 Column("account_id", INTEGER, ForeignKey("accounts.id")),
             )
 
+        To render the FOREIGN KEY constraint inline within the ADD COLUMN
+        directive, use the ``inline_references`` parameter. This can improve
+        performance on large tables since the constraint is marked as valid
+        immediately for nullable columns::
+
+            from alembic import op
+            from sqlalchemy import Column, INTEGER, ForeignKey
+
+            op.add_column(
+                "organization",
+                Column("account_id", INTEGER, ForeignKey("accounts.id")),
+                inline_references=True,
+            )
+
         The column argument passed to :meth:`.Operations.add_column` is a
         :class:`~sqlalchemy.schema.Column` construct, used in the same way it's
         used in SQLAlchemy. In particular, values or functions to be indicated
@@ -2173,6 +2190,14 @@ class AddColumnOp(AlterTableOp):
 
          .. versionadded:: 1.16.0
 
+        :param inline_references: If True, renders FOREIGN KEY constraints
+         inline within the ADD COLUMN directive using REFERENCES syntax,
+         rather than as a separate ALTER TABLE ADD CONSTRAINT statement.
+         This is supported by PostgreSQL, Oracle, MySQL 5.7+, and
+         MariaDB 10.5+.
+
+         .. versionadded:: 1.18.2
+
         """
 
         op = cls(
@@ -2180,6 +2205,7 @@ class AddColumnOp(AlterTableOp):
             column,
             schema=schema,
             if_not_exists=if_not_exists,
+            inline_references=inline_references,
         )
         return operations.invoke(op)
 
@@ -2192,6 +2218,7 @@ class AddColumnOp(AlterTableOp):
         insert_before: Optional[str] = None,
         insert_after: Optional[str] = None,
         if_not_exists: Optional[bool] = None,
+        inline_references: Optional[bool] = None,
     ) -> None:
         """Issue an "add column" instruction using the current
         batch migration context.
@@ -2213,6 +2240,7 @@ class AddColumnOp(AlterTableOp):
             column,
             schema=operations.impl.schema,
             if_not_exists=if_not_exists,
+            inline_references=inline_references,
             **kw,
         )
         return operations.invoke(op)
index 5f93464e73cdea4f4faf6bef91e138cd01cef50e..b9b4b7117cc7311002bae00aaa13b9b332531501 100644 (file)
@@ -172,6 +172,7 @@ def add_column(operations: "Operations", operation: "ops.AddColumnOp") -> None:
     column = operation.column
     schema = operation.schema
     kw = operation.kw
+    inline_references = operation.inline_references
 
     if column.table is not None:
         column = _copy(column)
@@ -182,11 +183,22 @@ def add_column(operations: "Operations", operation: "ops.AddColumnOp") -> None:
         column,
         schema=schema,
         if_not_exists=operation.if_not_exists,
+        inline_references=inline_references,
         **kw,
     )
 
     for constraint in t.constraints:
         if not isinstance(constraint, sa_schema.PrimaryKeyConstraint):
+            # Skip ForeignKeyConstraint if it was rendered inline
+            # This only happens when inline_references=True AND there's exactly
+            # one FK AND the constraint is single-column
+            if (
+                inline_references
+                and isinstance(constraint, sa_schema.ForeignKeyConstraint)
+                and len(column.foreign_keys) == 1
+                and len(constraint.columns) == 1
+            ):
+                continue
             operations.impl.add_constraint(constraint)
     for index in t.indexes:
         operations.impl.create_index(index)
diff --git a/docs/build/unreleased/1780.rst b/docs/build/unreleased/1780.rst
new file mode 100644 (file)
index 0000000..69fa23f
--- /dev/null
@@ -0,0 +1,10 @@
+.. change::
+    :tags: usecase, operations
+    :tickets: 1780
+
+    Added ``inline_references`` parameter to :meth:`.Operations.add_column`
+    which allows rendering of ``REFERENCES`` clauses inline within the ``ADD
+    COLUMN`` directive rather than as a separate ``ADD CONSTRAINT`` directive.
+    This syntax is supported by PostgreSQL, Oracle, MySQL 5.7+, and MariaDB
+    10.5+, and can provide performance benefits on large tables by avoiding
+    full table validation when adding a nullable foreign key column.
index bef3dc10caf3d8ca741ae8642879b454fe29b376..f56e2d335c8d1d26490ee91ec852da3afd5c53f5 100644 (file)
@@ -935,7 +935,11 @@ class BatchAPITest(TestBase):
 
         assert (
             mock.call.add_column(
-                "tname", column, schema=None, if_not_exists=None
+                "tname",
+                column,
+                schema=None,
+                if_not_exists=None,
+                inline_references=None,
             )
             in batch.impl.operations.impl.mock_calls
         )
index 0b727f8ba44be92a3762c255754a4e48345043ba..73f8108575bbd4dfd1743db2070f4407ff70398d 100644 (file)
@@ -200,6 +200,17 @@ class OpTest(TestBase):
             "ALTER TABLE t1 ADD FOREIGN KEY(c1) REFERENCES c2 (id)",
         )
 
+    def test_add_column_fk_inline_references(self):
+        context = op_fixture()
+        op.add_column(
+            "t1",
+            Column("c1", Integer, ForeignKey("c2.id"), nullable=False),
+            inline_references=True,
+        )
+        context.assert_(
+            "ALTER TABLE t1 ADD COLUMN c1 INTEGER NOT NULL REFERENCES c2 (id)"
+        )
+
     def test_add_column_schema_fk(self):
         context = op_fixture()
         op.add_column(
@@ -212,6 +223,19 @@ class OpTest(TestBase):
             "ALTER TABLE foo.t1 ADD FOREIGN KEY(c1) REFERENCES c2 (id)",
         )
 
+    def test_add_column_schema_fk_inline_references(self):
+        context = op_fixture()
+        op.add_column(
+            "t1",
+            Column("c1", Integer, ForeignKey("c2.id"), nullable=False),
+            schema="foo",
+            inline_references=True,
+        )
+        context.assert_(
+            "ALTER TABLE foo.t1 ADD COLUMN c1 INTEGER NOT NULL "
+            "REFERENCES c2 (id)"
+        )
+
     def test_add_column_schema_type(self):
         """Test that a schema type generates its constraints...."""
         context = op_fixture()
@@ -255,6 +279,17 @@ class OpTest(TestBase):
             "ALTER TABLE t1 ADD FOREIGN KEY(c1) REFERENCES t1 (c2)",
         )
 
+    def test_add_column_fk_self_referential_inline_references(self):
+        context = op_fixture()
+        op.add_column(
+            "t1",
+            Column("c1", Integer, ForeignKey("t1.c2"), nullable=False),
+            inline_references=True,
+        )
+        context.assert_(
+            "ALTER TABLE t1 ADD COLUMN c1 INTEGER NOT NULL REFERENCES t1 (c2)"
+        )
+
     def test_add_column_schema_fk_self_referential(self):
         context = op_fixture()
         op.add_column(
@@ -278,6 +313,72 @@ class OpTest(TestBase):
             "ALTER TABLE t1 ADD FOREIGN KEY(c1) REFERENCES remote.t2 (c2)",
         )
 
+    def test_add_column_fk_schema_inline_references(self):
+        context = op_fixture()
+        op.add_column(
+            "t1",
+            Column("c1", Integer, ForeignKey("remote.t2.c2"), nullable=False),
+            inline_references=True,
+        )
+        context.assert_(
+            "ALTER TABLE t1 ADD COLUMN c1 INTEGER NOT NULL "
+            "REFERENCES remote.t2 (c2)"
+        )
+
+    @combinations(
+        ("MixedCase.id", 'REFERENCES "MixedCase" (id)'),
+        ("t2.MixedCase", 'REFERENCES t2 ("MixedCase")'),
+        (
+            "MixedCaseTable.MixedCaseColumn",
+            'REFERENCES "MixedCaseTable" ("MixedCaseColumn")',
+        ),
+        ("MixedSchema.t2.id", 'REFERENCES "MixedSchema".t2 (id)'),
+        (
+            "MixedSchema.MixedTable.MixedCol",
+            'REFERENCES "MixedSchema"."MixedTable" ("MixedCol")',
+        ),
+        argnames="fk_ref,expected_ref",
+    )
+    def test_add_column_fk_inline_references_quoting(
+        self, fk_ref, expected_ref
+    ):
+        context = op_fixture()
+        op.add_column(
+            "t1",
+            Column("c1", Integer, ForeignKey(fk_ref), nullable=False),
+            inline_references=True,
+        )
+        context.assert_(
+            f"ALTER TABLE t1 ADD COLUMN c1 INTEGER NOT NULL {expected_ref}"
+        )
+
+    def test_add_column_multiple_fk_inline_references(self):
+        """Test that inline_references is ignored when a column has multiple
+        ForeignKey objects to avoid non-deterministic behavior."""
+        context = op_fixture()
+        # A column with two ForeignKey objects
+        col = Column(
+            "c1",
+            Integer,
+            ForeignKey("t2.id"),
+            ForeignKey("t3.id"),
+            nullable=False,
+        )
+        op.add_column("t1", col, inline_references=True)
+
+        # Should NOT render inline REFERENCES because there are 2 FKs
+        # Falls back to separate ADD CONSTRAINT statements
+        # Order of FK constraints is non-deterministic due to set ordering
+        context.assert_contains(
+            "ALTER TABLE t1 ADD COLUMN c1 INTEGER NOT NULL"
+        )
+        context.assert_contains(
+            "ALTER TABLE t1 ADD FOREIGN KEY(c1) REFERENCES t2 (id)"
+        )
+        context.assert_contains(
+            "ALTER TABLE t1 ADD FOREIGN KEY(c1) REFERENCES t3 (id)"
+        )
+
     def test_add_column_schema_fk_schema(self):
         context = op_fixture()
         op.add_column(