From: Mike Bayer Date: Tue, 27 Jan 2026 19:35:59 +0000 (-0500) Subject: add inline_reference parameter X-Git-Tag: rel_1_18_2~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=8bf6926bb386018ff41a7c92a5eb55ef56bed175;p=thirdparty%2Fsqlalchemy%2Falembic.git add inline_reference parameter 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 --- diff --git a/alembic/ddl/base.py b/alembic/ddl/base.py index 1968e093..caab9098 100644 --- a/alembic/ddl/base.py +++ b/alembic/ddl/base.py @@ -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 ) diff --git a/alembic/ddl/impl.py b/alembic/ddl/impl.py index c0d1751d..a6153106 100644 --- a/alembic/ddl/impl.py +++ b/alembic/ddl/impl.py @@ -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, ) ) diff --git a/alembic/op.pyi b/alembic/op.pyi index 9c39648c..40a480cc 100644 --- a/alembic/op.pyi +++ b/alembic/op.pyi @@ -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( diff --git a/alembic/operations/base.py b/alembic/operations/base.py index 531f4ade..218ea190 100644 --- a/alembic/operations/base.py +++ b/alembic/operations/base.py @@ -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. diff --git a/alembic/operations/ops.py b/alembic/operations/ops.py index ba7f9824..575af2a9 100644 --- a/alembic/operations/ops.py +++ b/alembic/operations/ops.py @@ -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) diff --git a/alembic/operations/toimpl.py b/alembic/operations/toimpl.py index 5f93464e..b9b4b711 100644 --- a/alembic/operations/toimpl.py +++ b/alembic/operations/toimpl.py @@ -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 index 00000000..69fa23fd --- /dev/null +++ b/docs/build/unreleased/1780.rst @@ -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. diff --git a/tests/test_batch.py b/tests/test_batch.py index bef3dc10..f56e2d33 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -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 ) diff --git a/tests/test_op.py b/tests/test_op.py index 0b727f8b..73f81085 100644 --- a/tests/test_op.py +++ b/tests/test_op.py @@ -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(