This is an automated email from the ASF dual-hosted git repository.
potiuk 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 e99d6c6f025 Require team names to be lower case with no doubled
underscores (#70886)
e99d6c6f025 is described below
commit e99d6c6f025eb5d01b5050432df7a9700d79223b
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Aug 17 22:54:57 2026 +0200
Require team names to be lower case with no doubled underscores (#70886)
* Require team names to be lower case with no doubled underscores
A team name is embedded in the AIRFLOW_CONN__<TEAM>___<ID> environment
variable
namespace that holds team scoped secrets, and the backend upper-cases the
name when
it builds that variable. Two names differing only in case therefore
resolved a single
namespace between them, and a name able to contain the ___ separator could
not be told
apart from it. Restricting what a name may be spelled with is what makes
the namespace
unambiguous, rather than another check at the point of use.
Stored names are migrated rather than left to rot: db migrate lower-cases
them and
carries each team's Dag bundles, Connections, Variables, Pools and Triggers
across.
Two stored names that fold together would silently merge two teams, and
nothing can
choose which survives, so that case is refused before the migration starts.
closes: #70886
* Address migration 0131 test columns through their mapped columns
Hand-written SQL spelled `variable.key` and `slot_pool.pool` unquoted, which
MySQL rejects as reserved words, so the test could not get far enough to
check
what the migration actually did.
* Let migration 0131 rename team names on MySQL
MySQL reads the two spellings as one value, which leaves the closing update
as
the statement that performs the rename -- and InnoDB refuses to update a
primary
key that rows still reference. Any deployment whose teams own a connection,
variable, pool, trigger or bundle could not run db migrate at all.
---
airflow-core/docs/migrations-ref.rst | 4 +-
airflow-core/newsfragments/70886.significant.rst | 19 +++
.../src/airflow/cli/commands/team_command.py | 41 ++++--
.../versions/0131_3_4_0_lower_case_team_names.py | 108 ++++++++++++++++
airflow-core/src/airflow/models/team.py | 21 ++++
airflow-core/src/airflow/utils/db.py | 34 ++++-
.../tests/unit/cli/commands/test_team_command.py | 89 +++++++++++++
.../migrations/test_0131_lower_case_team_names.py | 138 +++++++++++++++++++++
airflow-core/tests/unit/utils/test_db.py | 35 +++++-
9 files changed, 478 insertions(+), 11 deletions(-)
diff --git a/airflow-core/docs/migrations-ref.rst
b/airflow-core/docs/migrations-ref.rst
index b6f7fbcf879..cc18c48f493 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are
executed via when you ru
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| Revision ID | Revises ID | Airflow Version | Description
|
+=========================+==================+===================+==============================================================+
-| ``76c46545c91e`` (head) | ``3c525f44bea8`` | ``3.4.0`` | Add new
index for trigger. |
+| ``c7f0a5d2e9b4`` (head) | ``76c46545c91e`` | ``3.4.0`` | Lower case
team names. |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``76c46545c91e`` | ``3c525f44bea8`` | ``3.4.0`` | Add new
index for trigger. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| ``3c525f44bea8`` | ``b2f1a9c7d4e0`` | ``3.4.0`` | Add indexes
on serialized_dag and dag_code. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
diff --git a/airflow-core/newsfragments/70886.significant.rst
b/airflow-core/newsfragments/70886.significant.rst
new file mode 100644
index 00000000000..d772b43d169
--- /dev/null
+++ b/airflow-core/newsfragments/70886.significant.rst
@@ -0,0 +1,19 @@
+Team names must now be lower case, and cannot contain two consecutive
underscores
+
+``airflow teams create`` and ``airflow teams sync`` now accept only names
matching
+``(?!.*__)[a-z0-9_-]{3,50}`` -- lower case letters, digits, hyphens and single
+underscores. Upper case letters and ``__`` were previously accepted.
+
+A team name is embedded in the ``AIRFLOW_CONN__<TEAM>___<ID>`` environment
variable
+namespace that holds team scoped secrets, and the backend upper-cases the name
when it
+builds that variable. ``data_eng`` and ``Data_Eng`` therefore resolved a single
+namespace between them, and a name containing ``___`` could not be told apart
from the
+separator.
+
+``airflow db migrate`` lower-cases every stored team name, moving that team's
Dag
+bundles, Connections, Variables, Pools and Triggers with it. Two stored names
that
+differ only in case would collapse onto one row and silently merge two teams,
so
+migration refuses to start while any such pair exists -- rename all but one
first. Names
+that stay invalid for another reason, such as containing ``__``, are migrated
as they
+are; they keep working, but ``airflow teams sync`` exits non-zero until they
are
+renamed.
diff --git a/airflow-core/src/airflow/cli/commands/team_command.py
b/airflow-core/src/airflow/cli/commands/team_command.py
index 855a42071de..3dc2b5aa748 100644
--- a/airflow-core/src/airflow/cli/commands/team_command.py
+++ b/airflow-core/src/airflow/cli/commands/team_command.py
@@ -30,7 +30,12 @@ from airflow.configuration import conf
from airflow.dag_processing.bundles.manager import DagBundlesManager
from airflow.models.connection import Connection
from airflow.models.pool import Pool
-from airflow.models.team import Team, dag_bundle_team_association_table
+from airflow.models.team import (
+ TEAM_NAME_PATTERN,
+ Team,
+ dag_bundle_team_association_table,
+ find_invalid_team_names,
+)
from airflow.models.variable import Variable
from airflow.utils import cli as cli_utils
from airflow.utils.providers_configuration_loader import
providers_configuration_loaded
@@ -54,12 +59,23 @@ def _show_teams(teams, output):
def _extract_team_name(args):
- """Extract and validate team name from args."""
+ """
+ Extract a team name from args, without holding it to the name rule.
+
+ ``teams delete`` has to accept any name a row can hold, including one
stored before the
+ current rule applied -- otherwise a team that violates it could never be
removed.
+ """
team_name = args.name.strip()
if not team_name:
raise SystemExit("Team name cannot be empty")
- if not re.match(r"^[a-zA-Z0-9_-]{3,50}$", team_name):
- raise SystemExit("Invalid team name: must match regex
^[a-zA-Z0-9_-]{3,50}$")
+ return team_name
+
+
+def _extract_new_team_name(args):
+ """Extract a team name for a creation path, holding it to the name rule."""
+ team_name = _extract_team_name(args)
+ if not re.fullmatch(TEAM_NAME_PATTERN, team_name):
+ raise SystemExit(f"Invalid team name: must match regex
{TEAM_NAME_PATTERN}")
return team_name
@@ -78,8 +94,8 @@ def _create_default_team_pool(team_name: str, *, session:
Session) -> None:
@providers_configuration_loaded
@provide_session
def team_create(args, *, session=NEW_SESSION):
- """Create a new team. Team names must be 3-50 characters long and contain
only alphanumeric characters, hyphens, and underscores."""
- team_name = _extract_team_name(args)
+ """Create a new team. Team names must be 3-50 characters long and contain
only lower case alphanumeric characters, hyphens, and single underscores."""
+ team_name = _extract_new_team_name(args)
# Check if team with this name already exists
if session.scalar(select(Team).where(Team.name == team_name)):
@@ -210,11 +226,22 @@ def team_sync(args, *, session=NEW_SESSION):
for bundle in DagBundlesManager()._bundle_config.values()
if bundle.team_name is not None
}
+ existing_teams = Team.get_all_team_names(session=session)
+
+ # The bundle config is a second creation path for teams, so it enforces
the same rule as
+ # `teams create`. Stored names are checked too: `teams sync` shipped
without any validation,
+ # so a deployment can hold a name the rule rejects, and checking only the
incoming config
+ # would miss a team created by an earlier sync and since dropped from it.
+ if invalid := find_invalid_team_names(dag_bundle_teams | existing_teams):
+ raise SystemExit(
+ f"Invalid team name(s): {', '.join(invalid)}. "
+ f"Team names must match regex {TEAM_NAME_PATTERN}. "
+ "Names already stored must be corrected before syncing."
+ )
teams_added = 0
try:
- existing_teams = Team.get_all_team_names(session=session)
for team_name in dag_bundle_teams:
if team_name not in existing_teams:
session.add(Team(name=team_name))
diff --git
a/airflow-core/src/airflow/migrations/versions/0131_3_4_0_lower_case_team_names.py
b/airflow-core/src/airflow/migrations/versions/0131_3_4_0_lower_case_team_names.py
new file mode 100644
index 00000000000..ba1253724c0
--- /dev/null
+++
b/airflow-core/src/airflow/migrations/versions/0131_3_4_0_lower_case_team_names.py
@@ -0,0 +1,108 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Lower case team names.
+
+Revision ID: c7f0a5d2e9b4
+Revises: 76c46545c91e
+Create Date: 2026-08-12 09:14:22.518233
+
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import sqlalchemy as sa
+from alembic import op
+
+if TYPE_CHECKING:
+ from sqlalchemy.sql import ClauseElement
+
+# revision identifiers, used by Alembic.
+revision = "c7f0a5d2e9b4"
+down_revision = "76c46545c91e"
+branch_labels = None
+depends_on = None
+airflow_version = "3.4.0"
+
+# Tables carrying a foreign key to ``team.name``.
+_REFERRING_TABLES = ("dag_bundle_team", "connection", "variable", "slot_pool",
"trigger")
+
+_TEAM = sa.table("team", sa.column("name", sa.String))
+
+
+def build_lower_casing_statements(dialect_name: str) -> list[ClauseElement]:
+ """
+ Build the statements that fold every stored team name to lower case.
+
+ Kept free of anything read back from a row so that ``db migrate
--show-sql-only`` can emit
+ it: an offline migration has no rows to read.
+
+ Their order carries both collations a database may have, without having to
tell them apart:
+
+ * Case sensitive, where ``name <> lower(name)`` selects the names to fold.
``team.name`` is
+ the primary key and the foreign keys do not cascade on update, so the
lower case row has to
+ exist before anything can point at it, and the old one can only go once
nothing does. Two
+ stored names folding onto one collide on the primary key at the insert,
deliberately --
+ merging two teams is not a migration's decision to make, and ``db
migrate`` refuses earlier
+ with an explanation.
+ * Case insensitive, where the two spellings are a single value: the insert
and the delete
+ match nothing, the referring rows keep matching their team while they
are rewritten, and
+ the closing unconditional update renames the row in place.
+
+ MySQL is the second case, which makes that closing update the statement
that does the rename
+ -- and InnoDB refuses to update a primary key while rows still reference
it, the foreign keys
+ carrying no ``ON UPDATE CASCADE``. The referring rows were folded above
and already read as
+ the new name under this collation, so the check is suspended over the
rename it would other-
+ wise block. A name that would collide still collides: the primary key is
not what is lifted.
+ """
+ lowered = sa.func.lower(_TEAM.c.name)
+ statements: list[ClauseElement] = [
+ _TEAM.insert().from_select(["name"],
sa.select(lowered).where(_TEAM.c.name != lowered))
+ ]
+ for table_name in _REFERRING_TABLES:
+ referring = sa.table(table_name, sa.column("team_name", sa.String))
+ # ``connection`` and ``trigger`` carry the rows of team-less
deployments as well; only
+ # the team scoped ones are rewritten.
+ statements.append(
+ referring.update()
+ .where(referring.c.team_name.is_not(None))
+ .values(team_name=sa.func.lower(referring.c.team_name))
+ )
+ statements.append(_TEAM.delete().where(_TEAM.c.name != lowered))
+ if dialect_name == "mysql":
+ statements.append(sa.text("SET FOREIGN_KEY_CHECKS=0"))
+ statements.append(_TEAM.update().values(name=lowered))
+ statements.append(sa.text("SET FOREIGN_KEY_CHECKS=1"))
+ else:
+ statements.append(_TEAM.update().values(name=lowered))
+ return statements
+
+
+def upgrade():
+ """Lower case team names."""
+ for statement in build_lower_casing_statements(op.get_bind().dialect.name):
+ op.execute(statement)
+
+
+def downgrade():
+ """Unapply Lower case team names."""
+ # The original casing is recorded nowhere, so it cannot be restored. Lower
case names are
+ # valid under every rule that preceded this one, so leaving them as they
are is safe.
diff --git a/airflow-core/src/airflow/models/team.py
b/airflow-core/src/airflow/models/team.py
index 64877489c66..fab0e272409 100644
--- a/airflow-core/src/airflow/models/team.py
+++ b/airflow-core/src/airflow/models/team.py
@@ -17,6 +17,7 @@
# under the License.
from __future__ import annotations
+import re
from typing import TYPE_CHECKING
from sqlalchemy import Column, ForeignKey, Index, String, Table, select
@@ -26,8 +27,28 @@ from airflow.models.base import Base, StringID
from airflow.utils.session import NEW_SESSION, provide_session
if TYPE_CHECKING:
+ from collections.abc import Iterable
+
from sqlalchemy.orm import Session
+# What ``airflow teams create`` and ``airflow teams sync`` accept as a team
name.
+#
+# Lower case only: the environment secrets backend upper-cases the team name
to build
+# ``AIRFLOW_CONN__<TEAM>___<ID>``, so ``data_eng`` and ``Data_Eng`` resolve
one namespace between
+# them and each team would read the other's Connections and Variables. Two
consecutive
+# underscores are excluded so that a name can never contain the ``___``
separator itself.
+#
+# Deliberately unanchored and always used with ``re.fullmatch``: ``re.match``
against a
+# ``$``-anchored pattern also accepts a trailing newline, and unlike ``teams
create`` the
+# ``teams sync`` path does not strip what the bundle config gives it.
+TEAM_NAME_PATTERN = r"(?!.*__)[a-z0-9_-]{3,50}"
+
+
+def find_invalid_team_names(names: Iterable[str]) -> list[str]:
+ """Return the names that do not satisfy :data:`TEAM_NAME_PATTERN`,
sorted."""
+ return sorted({name for name in names if not
re.fullmatch(TEAM_NAME_PATTERN, name)})
+
+
dag_bundle_team_association_table = Table(
"dag_bundle_team",
Base.metadata,
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index 4b873b4214e..19f96a2ea6e 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -117,7 +117,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
"3.1.8": "509b94a1042d",
"3.2.0": "1d6611b6ab7c",
"3.3.0": "d2f4e1b3c5a7",
- "3.4.0": "76c46545c91e",
+ "3.4.0": "c7f0a5d2e9b4",
}
# Prefix used to identify tables holding data moved during migration.
@@ -1117,10 +1117,40 @@ def reflect_tables(tables: list[MappedClassProtocol |
str] | None, session, sche
return metadata
+def check_team_names_can_be_lower_cased(*, session: Session) -> Iterable[str]:
+ """
+ Yield an error for each group of stored team names that cannot be folded
to lower case.
+
+ Team names became lower case only, and a migration rewrites the stored
ones. Names that
+ differ only in case collapse onto a single row, which would silently merge
two teams -- one
+ would inherit the other's Dag bundles, Connections, Variables, Pools and
Triggers. Nothing
+ here can choose which name survives, so migration stops and the operator
renames one first.
+ """
+ from collections import defaultdict
+
+ from airflow.models.team import Team
+
+ if not inspect(session.get_bind()).has_table(Team.__tablename__):
+ return
+
+ by_lower: dict[str, list[str]] = defaultdict(list)
+ for name in Team.get_all_team_names(session=session):
+ by_lower[name.lower()].append(name)
+
+ for lowered, names in sorted(by_lower.items()):
+ if len(names) > 1:
+ yield (
+ f"Teams {', '.join(repr(name) for name in sorted(names))}
differ only in case and "
+ f"would both become '{lowered}'. Rename all but one with
`airflow teams create` / "
+ "`airflow teams delete`, moving its Dag bundles, Connections,
Variables and Pools "
+ "across, before migrating."
+ )
+
+
@provide_session
def _check_migration_errors(*, session: Session = NEW_SESSION) ->
Iterable[str]:
""":session: session of the sqlalchemy."""
- check_functions: Iterable[Callable[..., Iterable[str]]] = ()
+ check_functions: Iterable[Callable[..., Iterable[str]]] =
(check_team_names_can_be_lower_cased,)
for check_fn in check_functions:
log.debug("running check function %s", check_fn.__name__)
yield from check_fn(session=session)
diff --git a/airflow-core/tests/unit/cli/commands/test_team_command.py
b/airflow-core/tests/unit/cli/commands/test_team_command.py
index 3af2e4ee5b8..65c1b3e1d13 100644
--- a/airflow-core/tests/unit/cli/commands/test_team_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_team_command.py
@@ -107,6 +107,34 @@ class TestCliTeams:
with pytest.raises(SystemExit, match="Invalid team name"):
team_command.team_create(self.parser.parse_args(["teams",
"create", "test with space"]))
+ @pytest.mark.parametrize(
+ "bad_name",
+ [
+ pytest.param("Data-Eng", id="upper-case"),
+ pytest.param("team__x", id="consecutive-underscores"),
+ pytest.param("team_a___prod", id="spans-the-separator"),
+ ],
+ )
+ def
test_team_create_rejects_a_name_the_secrets_namespace_cannot_hold(self,
bad_name):
+ with pytest.raises(SystemExit, match="Invalid team name"):
+ team_command.team_create(self.parser.parse_args(["teams",
"create", bad_name]))
+
+ assert self.session.scalar(select(Team).where(Team.name == bad_name))
is None
+
+ def test_team_create_allows_a_single_underscore(self):
+ team_command.team_create(self.parser.parse_args(["teams", "create",
"team_a"]))
+
+ assert self.session.scalar(select(Team).where(Team.name == "team_a"))
is not None
+
+ def test_team_delete_accepts_a_name_the_current_rule_rejects(self):
+ """A team stored before the rule tightened still has to be
removable."""
+ self.session.add(Team(name="Data-Eng"))
+ self.session.commit()
+
+ team_command.team_delete(self.parser.parse_args(["teams", "delete",
"Data-Eng", "--yes"]))
+
+ assert self.session.scalar(select(Team).where(Team.name ==
"Data-Eng")) is None
+
def test_team_create_whitespace_name(self):
"""Test team creation with whitespace-only name."""
with pytest.raises(SystemExit, match="Team name cannot be empty"):
@@ -393,6 +421,67 @@ class TestCliTeams:
assert "integration-2" not in team_names
assert "integration-3" in team_names
+ @pytest.mark.parametrize(
+ "bad_name",
+ [
+ pytest.param("a", id="too-short"),
+ pytest.param("Team1", id="upper-case"),
+ pytest.param("team__x", id="consecutive-underscores"),
+ pytest.param("team1\n", id="trailing-newline"),
+ ],
+ )
+ def test_team_sync_rejects_an_invalid_bundle_team_name(self, bad_name):
+ """``teams sync`` is a second creation path, so it holds names to the
same rule.
+
+ Unlike ``teams create`` this path does not strip its input, so a
trailing newline
+ reaches the pattern -- which is why the check is ``re.fullmatch``
against an unanchored
+ pattern rather than ``re.match`` against a ``$``-anchored one.
+ """
+ bundle_config = [
+ {
+ "name": "bundleone",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": "/dev/null", "refresh_interval": 0},
+ "team_name": bad_name,
+ },
+ ]
+
+ with conf_vars(
+ {
+ ("core", "multi_team"): "True",
+ ("dag_processor", "dag_bundle_config_list"):
json.dumps(bundle_config),
+ }
+ ):
+ with pytest.raises(SystemExit, match="Invalid team name"):
+ team_command.team_sync(self.parser.parse_args(["teams",
"sync"]))
+
+ assert self.session.scalars(select(Team)).all() == []
+
+ def test_team_sync_rejects_an_invalid_name_already_stored(self):
+ """``teams sync`` shipped with no validation, so stored names can
already break the rule."""
+ self.session.add(Team(name="Data-Eng"))
+ self.session.commit()
+
+ bundle_config = [
+ {
+ "name": "bundleone",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": "/dev/null", "refresh_interval": 0},
+ "team_name": "team1",
+ },
+ ]
+
+ with conf_vars(
+ {
+ ("core", "multi_team"): "True",
+ ("dag_processor", "dag_bundle_config_list"):
json.dumps(bundle_config),
+ }
+ ):
+ with pytest.raises(SystemExit, match="Data-Eng"):
+ team_command.team_sync(self.parser.parse_args(["teams",
"sync"]))
+
+ assert self.session.scalar(select(Team).where(Team.name == "team1"))
is None
+
def test_team_sync(self):
bundle_config = [
{
diff --git
a/airflow-core/tests/unit/migrations/test_0131_lower_case_team_names.py
b/airflow-core/tests/unit/migrations/test_0131_lower_case_team_names.py
new file mode 100644
index 00000000000..15008679853
--- /dev/null
+++ b/airflow-core/tests/unit/migrations/test_0131_lower_case_team_names.py
@@ -0,0 +1,138 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Tests for migration 0131 (c7f0a5d2e9b4), which lower-cases stored team names.
+
+``team.name`` is a primary key whose foreign keys do not cascade on update, so
the rename has
+to carry every referring row with it. A row left behind would either break the
constraint or
+strand that team's Connections, Variables and Pools under a name nothing
resolves.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+import sqlalchemy as sa
+from alembic import command
+
+from airflow import settings
+from airflow.models import Connection, Pool, Variable
+from airflow.models.team import Team
+from airflow.utils.db import _get_alembic_config
+from airflow.utils.session import create_session
+
+from tests_common.test_utils.paths import AIRFLOW_CORE_SOURCES_PATH
+
+pytestmark = pytest.mark.db_test
+
+# Migration filenames start with a digit so they cannot be imported via the
normal import
+# system; load the module by file path instead.
+_MIGRATION_PATH = (
+ Path(AIRFLOW_CORE_SOURCES_PATH) /
"airflow/migrations/versions/0131_3_4_0_lower_case_team_names.py"
+)
+_spec = importlib.util.spec_from_file_location("migration_0131",
_MIGRATION_PATH)
+_migration = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
+_spec.loader.exec_module(_migration) # type: ignore[union-attr]
+
+_OLD = "Data-Eng"
+_NEW = "data-eng"
+
+
+# Addressed through the mapped columns rather than by name: ``variable.key``
and
+# ``slot_pool.pool`` are reserved words on MySQL, which only quotes them
correctly when
+# SQLAlchemy renders the identifier for the dialect.
+_REFERRING_ROWS = (
+ (Connection, Connection.conn_id, "c_0131"),
+ (Variable, Variable.key, "v_0131"),
+ (Pool, Pool.pool, "p_0131"),
+)
+
+
+def _team_names(conn) -> set[str]:
+ return set(conn.scalars(sa.select(Team.name)))
+
+
+class TestMigration0131:
+ def test_rename_carries_referring_rows_with_the_team(self):
+ with create_session() as session:
+ session.add(Team(name=_OLD))
+ session.flush()
+ session.add_all(
+ [
+ Connection(conn_id="c_0131", conn_type="http",
team_name=_OLD),
+ Variable(key="v_0131", val="x", team_name=_OLD),
+ Pool(pool="p_0131", slots=1, description="",
include_deferred=False, team_name=_OLD),
+ ]
+ )
+ session.commit()
+
+ try:
+ with settings.engine.begin() as conn:
+ for statement in
_migration.build_lower_casing_statements(conn.dialect.name):
+ conn.execute(statement)
+
+ with settings.engine.connect() as conn:
+ assert _OLD not in _team_names(conn)
+ assert _NEW in _team_names(conn)
+ for model, key_column, key in _REFERRING_ROWS:
+ team_name =
conn.scalar(sa.select(model.team_name).where(key_column == key))
+ assert team_name == _NEW, model.__tablename__
+ finally:
+ with settings.engine.begin() as conn:
+ for model, key_column, key in _REFERRING_ROWS:
+ conn.execute(sa.delete(model).where(key_column == key))
+ conn.execute(sa.delete(Team).where(Team.name.in_([_OLD,
_NEW])))
+
+ def test_upgrade_emits_sql_offline(self, capsys):
+ """``db migrate --show-sql-only`` has no rows to read, so the
migration must not read any."""
+ command.upgrade(_get_alembic_config(),
f"{_migration.down_revision}:{_migration.revision}", sql=True)
+
+ emitted = capsys.readouterr().out
+ assert "INSERT INTO team (name) SELECT lower(team.name)" in emitted
+ assert "UPDATE team SET name=lower(team.name)" in emitted
+
+ @pytest.mark.parametrize(
+ ("dialect_name", "closing"),
+ [
+ pytest.param(
+ "mysql",
+ [
+ "SET FOREIGN_KEY_CHECKS=0",
+ "UPDATE team SET name=lower(team.name)",
+ "SET FOREIGN_KEY_CHECKS=1",
+ ],
+ id="mysql-suspends-the-check-over-the-rename",
+ ),
+ pytest.param(
+ "postgresql",
+ ["UPDATE team SET name=lower(team.name)"],
+ id="others-rename-under-the-check",
+ ),
+ ],
+ )
+ def test_only_mysql_suspends_foreign_key_checks(self, dialect_name,
closing):
+ """InnoDB refuses the closing rename while the foreign keys still
guard the old name."""
+ rendered = [
+ str(statement).strip() for statement in
_migration.build_lower_casing_statements(dialect_name)
+ ]
+
+ assert rendered[-len(closing) :] == closing
+ assert "SET FOREIGN_KEY_CHECKS=0" not in rendered[: -len(closing)]
diff --git a/airflow-core/tests/unit/utils/test_db.py
b/airflow-core/tests/unit/utils/test_db.py
index c81859edbdc..a33f7b667f4 100644
--- a/airflow-core/tests/unit/utils/test_db.py
+++ b/airflow-core/tests/unit/utils/test_db.py
@@ -31,16 +31,18 @@ from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.runtime.environment import EnvironmentContext
from alembic.script import ScriptDirectory
-from sqlalchemy import Column, Integer, MetaData, Table, select
+from sqlalchemy import Column, Integer, MetaData, Table, delete, select
from airflow import settings
from airflow.models import Base as airflow_base
+from airflow.models.team import Team
from airflow.utils.db import (
AutocommitEngineForMySQL,
LazySelectSequence,
_get_alembic_config,
_get_current_revision,
check_migrations,
+ check_team_names_can_be_lower_cased,
compare_server_default,
compare_type,
create_default_connections,
@@ -50,6 +52,7 @@ from airflow.utils.db import (
upgradedb,
)
from airflow.utils.db_manager import RunDBManager
+from airflow.utils.session import create_session
from tests_common.test_utils.config import conf_vars
@@ -237,6 +240,36 @@ class TestDb:
check_migrations(0)
check_migrations(1)
+ @pytest.mark.usefixtures("initialized_db")
+ def
test_check_team_names_can_be_lower_cased_passes_when_no_names_collide(self):
+ with create_session() as session:
+ session.add_all([Team(name="data-eng"), Team(name="Platform")])
+ session.commit()
+ try:
+ assert
list(check_team_names_can_be_lower_cased(session=session)) == []
+ finally:
+ session.execute(delete(Team))
+ session.commit()
+
+ # MySQL's default collation is case insensitive, so the two rows this
needs cannot coexist
+ # there -- which is also why the collision it guards against cannot arise
on MySQL.
+ @pytest.mark.backend("postgres", "sqlite")
+ @pytest.mark.usefixtures("initialized_db")
+ def test_check_team_names_can_be_lower_cased_reports_a_collision(self):
+ """Two names folding onto one row would merge two teams, so migration
has to stop."""
+ with create_session() as session:
+ session.add_all([Team(name="data-eng"), Team(name="Data-Eng")])
+ session.commit()
+ try:
+ errors =
list(check_team_names_can_be_lower_cased(session=session))
+ finally:
+ session.execute(delete(Team))
+ session.commit()
+
+ assert len(errors) == 1
+ assert "'Data-Eng', 'data-eng'" in errors[0]
+ assert "would both become 'data-eng'" in errors[0]
+
@pytest.mark.parametrize(
("auth", "expected"),
[