This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new f90a678357c Fix FAB migration failing on MySQL under the pymysql 
driver (#73220)
f90a678357c is described below

commit f90a678357ce829389023294efabdb9fa4d7eef9
Author: rjgoyln <[email protected]>
AuthorDate: Mon Sep 21 21:28:42 2026 +0800

    Fix FAB migration failing on MySQL under the pymysql driver (#73220)
    
    Revision 02ca36b0235b guards its MySQL DDL with throwaway stored
    procedures, each emitted as a single multi-statement string. mysqlclient
    turns CLIENT_MULTI_STATEMENTS on and accepts that; pymysql leaves it off,
    so the server rejects the script and `airflow db migrate` dies part-way
    through. MySQL DDL is not transactional, so the revision is left
    unstamped over a partly-changed schema, and the Helm upgrade hook that
    runs it reports only a timeout.
    
    A live connection can answer the same existence questions by
    introspection, which is what the rest of this revision already does for
    indexes and foreign-key names. The stored procedures are only needed
    offline, where there is no connection to ask.
---
 .../0001_3_5_0_fix_fab_db_inconsistencies.py       |  70 ++++++++++----
 .../tests/unit/fab/auth_manager/models/test_db.py  | 102 +++++++++++++++++++++
 2 files changed, 153 insertions(+), 19 deletions(-)

diff --git 
a/providers/fab/src/airflow/providers/fab/migrations/versions/0001_3_5_0_fix_fab_db_inconsistencies.py
 
b/providers/fab/src/airflow/providers/fab/migrations/versions/0001_3_5_0_fix_fab_db_inconsistencies.py
index 3913ae1b923..52c1046996d 100644
--- 
a/providers/fab/src/airflow/providers/fab/migrations/versions/0001_3_5_0_fix_fab_db_inconsistencies.py
+++ 
b/providers/fab/src/airflow/providers/fab/migrations/versions/0001_3_5_0_fix_fab_db_inconsistencies.py
@@ -49,7 +49,20 @@ _naming_convention = {
 }
 
 
+def _get_live_bind():
+    """Return the live database connection, or ``None`` in offline (``--sql``) 
mode."""
+    # Do not reach for op.get_bind() directly: in --sql mode alembic swaps the 
connection for a
+    # MockConnection that writes to the output buffer, so it is never None 
there and sa.inspect()
+    # on it raises NoInspectionAvailable. as_sql is the only reliable 
discriminator.
+    return None if op.get_context().as_sql else op.get_bind()
+
+
 def _mysql_run_procedure(procedure_name: str, body: str) -> str:
+    """Wrap ``body`` in a throwaway procedure so MySQL can guard DDL behind an 
``IF``."""
+    # Offline (--sql) only. The result is a multi-statement script: drivers 
that leave
+    # CLIENT_MULTI_STATEMENTS off (pymysql) reject it, and the `mysql` client 
needs a DELIMITER
+    # around the procedure body. A live connection has nothing to guess at, so 
the callers
+    # below introspect the schema and emit plain single statements instead.
     return f"""
     DROP PROCEDURE IF EXISTS {procedure_name};
     CREATE PROCEDURE {procedure_name}()
@@ -147,17 +160,28 @@ def 
_mysql_drop_unique_constraints_on_ab_register_user_email() -> str:
     )
 
 
+def _find_unique_constraint_names(bind, table_name: str, column_name: str) -> 
list[str]:
+    """Names of the unique constraints on ``table_name`` that cover 
``column_name``."""
+    return [
+        uq["name"]
+        for uq in sa.inspect(bind).get_unique_constraints(table_name)
+        if uq["name"] is not None and column_name in uq["column_names"]
+    ]
+
+
 def _drop_unique_constraint_if_exists(table_name: str, constraint_name: str) 
-> None:
     dialect_name = op.get_context().dialect.name
+    bind = _get_live_bind()
 
     if dialect_name == "postgresql":
         op.execute(sa.text(f'ALTER TABLE "{table_name}" DROP CONSTRAINT IF 
EXISTS "{constraint_name}"'))
     elif dialect_name == "mysql":
-        op.execute(
-            sa.text(
-                _mysql_run_procedure(
-                    "DropUniqueIfExists",
-                    f"""
+        if bind is None:
+            op.execute(
+                sa.text(
+                    _mysql_run_procedure(
+                        "DropUniqueIfExists",
+                        f"""
                 IF EXISTS (
                     SELECT 1
                     FROM information_schema.TABLE_CONSTRAINTS
@@ -172,9 +196,11 @@ def _drop_unique_constraint_if_exists(table_name: str, 
constraint_name: str) ->
                     SELECT 1;
                 END IF;
                     """,
+                    )
                 )
             )
-        )
+        elif any(uq["name"] == constraint_name for uq in 
sa.inspect(bind).get_unique_constraints(table_name)):
+            op.drop_constraint(constraint_name, table_name, type_="unique")
     else:
         with op.batch_alter_table(table_name, schema=None) as batch_op:
             with contextlib.suppress(ValueError):
@@ -192,13 +218,15 @@ def _resolve_fk_name(bind, table_name: str, column_name: 
str, default: str) -> s
 
 def _drop_index_if_exists(table_name: str, index_name: str) -> None:
     dialect_name = op.get_context().dialect.name
+    bind = _get_live_bind()
 
     if dialect_name == "mysql":
-        op.execute(
-            sa.text(
-                _mysql_run_procedure(
-                    "DropIndexIfExists",
-                    f"""
+        if bind is None:
+            op.execute(
+                sa.text(
+                    _mysql_run_procedure(
+                        "DropIndexIfExists",
+                        f"""
                 IF EXISTS (
                     SELECT 1
                     FROM information_schema.STATISTICS
@@ -210,16 +238,18 @@ def _drop_index_if_exists(table_name: str, index_name: 
str) -> None:
                     DROP INDEX `{index_name}` ON `{table_name}`;
                 END IF;
                     """,
+                    )
                 )
             )
-        )
+        elif any(idx["name"] == index_name for idx in 
sa.inspect(bind).get_indexes(table_name)):
+            op.drop_index(index_name, table_name=table_name)
     else:
         op.drop_index(index_name, table_name=table_name, if_exists=True)
 
 
 def upgrade() -> None:
     dialect_name = op.get_context().dialect.name
-    bind = op.get_bind()
+    bind = _get_live_bind()
     if dialect_name == "postgresql":
         op.create_index(
             "idx_ab_user_username",
@@ -286,18 +316,20 @@ def upgrade() -> None:
         )
 
     # Drop any existing unique constraint on email, regardless of its name.
-    # Raw SQL is used so this works in both online and offline (--sql) mode.
     if dialect_name == "postgresql":
         
op.execute(sa.text(_postgresql_drop_unique_constraints_on_ab_register_user_email()))
     elif dialect_name == "mysql":
-        
op.execute(sa.text(_mysql_drop_unique_constraints_on_ab_register_user_email()))
+        if bind is None:
+            
op.execute(sa.text(_mysql_drop_unique_constraints_on_ab_register_user_email()))
+        else:
+            for name in _find_unique_constraint_names(bind, 
"ab_register_user", "email"):
+                op.drop_constraint(name, "ab_register_user", type_="unique")
     elif dialect_name == "sqlite" and bind is not None:
         # SQLite: batch mode rewrites the table; requires a live connection.
         # Offline mode for SQLite is not supported by Airflow.
-        for uq in sa.inspect(bind).get_unique_constraints("ab_register_user"):
-            if "email" in uq["column_names"] and uq["name"] is not None:
-                with op.batch_alter_table("ab_register_user", schema=None) as 
batch_op:
-                    batch_op.drop_constraint(uq["name"], type_="unique")
+        for name in _find_unique_constraint_names(bind, "ab_register_user", 
"email"):
+            with op.batch_alter_table("ab_register_user", schema=None) as 
batch_op:
+                batch_op.drop_constraint(name, type_="unique")
     with op.batch_alter_table("ab_register_user", schema=None) as batch_op:
         
batch_op.create_unique_constraint(batch_op.f("ab_register_user_email_uq"), 
["email"])
 
diff --git a/providers/fab/tests/unit/fab/auth_manager/models/test_db.py 
b/providers/fab/tests/unit/fab/auth_manager/models/test_db.py
index 452e5522fb7..50073d28126 100644
--- a/providers/fab/tests/unit/fab/auth_manager/models/test_db.py
+++ b/providers/fab/tests/unit/fab/auth_manager/models/test_db.py
@@ -21,11 +21,13 @@ from unittest import mock
 
 import pytest
 import sqlalchemy as sa
+from alembic import command
 from alembic.autogenerate import compare_metadata
 from alembic.migration import MigrationContext
 from sqlalchemy import MetaData
 
 import airflow.providers.fab as provider_fab
+from airflow import settings
 from airflow.settings import engine
 from airflow.utils.db import (
     compare_server_default,
@@ -33,6 +35,16 @@ from airflow.utils.db import (
 )
 
 pytestmark = [pytest.mark.db_test]
+
+
+def _email_unique_constraint_names(bind) -> list[str]:
+    return sorted(
+        uq["name"]
+        for uq in sa.inspect(bind).get_unique_constraints("ab_register_user")
+        if "email" in uq["column_names"]
+    )
+
+
 try:
     from airflow.providers.fab.auth_manager.models.db import FABDBManager
 
@@ -118,6 +130,46 @@ try:
                 with pytest.raises(SystemExit, match="Offline migration not 
supported for SQLite"):
                     FABDBManager(session).upgradedb(from_revision=None, 
to_revision=None, show_sql_only=True)
 
+        @pytest.mark.parametrize(
+            ("direction", "revision_range", "expected_statements"),
+            [
+                (
+                    "upgrade",
+                    "6709f7a774b9:02ca36b0235b",
+                    (
+                        "CREATE PROCEDURE CreateIdxPermissionViewId()",
+                        "CREATE PROCEDURE CreateIdxRoleId()",
+                        "CREATE PROCEDURE DropEmailUqIfExists()",
+                        "UPDATE alembic_version_fab SET 
version_num='02ca36b0235b'",
+                    ),
+                ),
+                (
+                    "downgrade",
+                    "02ca36b0235b:6709f7a774b9",
+                    (
+                        "CREATE PROCEDURE DropUniqueIfExists()",
+                        "CREATE PROCEDURE DropIndexIfExists()",
+                        "UPDATE alembic_version_fab SET 
version_num='6709f7a774b9'",
+                    ),
+                ),
+            ],
+        )
+        @mock.patch("airflow.settings.SQL_ALCHEMY_CONN", 
"mysql+pymysql://user:pass@host/airflow")
+        def test_offline_mysql_sql_generation(
+            self, session, capsys, direction, revision_range, 
expected_statements
+        ):
+            # Offline mode hands the migration a MockConnection rather than 
None, so every
+            # introspection has to be gated on the context's as_sql flag or it 
raises
+            # NoInspectionAvailable and no script is produced at all. The 
version stamp is
+            # emitted last, so asserting it proves the whole revision ran.
+            config = FABDBManager(session=session).get_alembic_config()
+
+            getattr(command, direction)(config, revision_range, sql=True)
+
+            script = capsys.readouterr().out
+            for statement in expected_statements:
+                assert statement in script
+
         @mock.patch("alembic.command.upgrade")
         @mock.patch.object(FABDBManager, "create_db_from_orm")
         @mock.patch.object(FABDBManager, "_has_existing_manager_tables", 
return_value=False)
@@ -292,5 +344,55 @@ try:
                 if original_revision and current_revision != original_revision:
                     manager.upgradedb(to_revision=original_revision)
 
+        @pytest.mark.backend("mysql")
+        def test_upgradedb_and_downgrade_mysql_run_under_pymysql(self, 
session):
+            # pymysql leaves CLIENT_MULTI_STATEMENTS off (mysqlclient, which 
CI uses, turns it
+            # on), so the server rejects any revision that packs several 
statements into one
+            # op.execute(). Drive the revision over a pymysql connection to 
keep it split.
+            pytest.importorskip("pymysql")
+
+            manager = FABDBManager(session=session)
+            original_revision = manager.get_current_revision()
+            pymysql_url = 
sa.engine.make_url(settings.SQL_ALCHEMY_CONN).set(drivername="mysql+pymysql")
+            pymysql_engine = sa.create_engine(pymysql_url)
+
+            try:
+                manager.downgrade(to_revision="6709f7a774b9")
+
+                # An Airflow 2.x database reaches this revision with FAB's own 
unique index on
+                # email, named after the column. That is what the revision has 
to find and drop.
+                with pymysql_engine.begin() as setup:
+                    for name in _email_unique_constraint_names(setup):
+                        setup.execute(sa.text(f"ALTER TABLE `ab_register_user` 
DROP INDEX `{name}`"))
+                    setup.execute(sa.text("ALTER TABLE `ab_register_user` ADD 
UNIQUE KEY `email` (`email`)"))
+
+                config = manager.get_alembic_config()
+                with pymysql_engine.connect() as connection:
+                    config.attributes["connection"] = connection
+                    assert connection.dialect.driver == "pymysql"
+
+                    command.upgrade(config, revision="02ca36b0235b")
+                    assert _email_unique_constraint_names(connection) == 
["ab_register_user_email_uq"]
+
+                    command.downgrade(config, revision="6709f7a774b9")
+                    assert _email_unique_constraint_names(connection) == []
+                    index_names = {
+                        index["name"]
+                        for index in 
sa.inspect(connection).get_indexes("ab_permission_view_role")
+                    }
+                    assert "idx_permission_view_id" not in index_names
+                    assert "idx_role_id" not in index_names
+
+                    # The downgrade left no unique constraint on email at all, 
so this second
+                    # upgrade runs the drops against objects that are already 
gone — the state a
+                    # database is in after the revision failed part-way 
through.
+                    command.upgrade(config, revision="02ca36b0235b")
+                    assert _email_unique_constraint_names(connection) == 
["ab_register_user_email_uq"]
+            finally:
+                pymysql_engine.dispose()
+                current_revision = manager.get_current_revision()
+                if original_revision and current_revision != original_revision:
+                    manager.upgradedb(to_revision=original_revision)
+
 except ModuleNotFoundError:
     pass

Reply via email to