fix(migrations): repair tenant identity lookup indexes
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# 员工与组织残留全局唯一索引阻断多租户复用
|
||||
|
||||
日期:2026-07-18
|
||||
文档路径:document/development/2026-07-18/dev-logs/bugs/tenant-identity-global-unique-indexes.md
|
||||
|
||||
## 修复记录
|
||||
|
||||
- 15:54:记录数据库迁移修复:员工编号、员工邮箱和组织编码仍保留单列全局唯一索引,第二个企业无法使用相同企业内部标识。
|
||||
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。
|
||||
- 原因:历史迁移已增加正确的租户复合唯一约束,但三个旧单列唯一索引未被替换,PostgreSQL 仍按全局范围拒绝重复值。
|
||||
- 修改:新增 Alembic `20260718_0029`,在 PostgreSQL 中校验复合唯一约束和现有索引形态后,将三个同名单列唯一索引替换为普通查询索引;降级前检查跨租户重复并在无法安全恢复全局唯一时拒绝降级。
|
||||
- 操作:更新迁移前置检查和迁移测试;只读核验当前数据库仍位于 `20260717_0028`,未执行 live DDL、数据写入或主容器重启。
|
||||
- 验证:容器内迁移测试 `167 passed, 1 skipped`,Ruff 通过,Alembic HEAD 为 `20260718_0029`;跳过项仅因未配置专用一次性 PostgreSQL 迁移测试库,未使用当前业务库替代。
|
||||
- 影响:代码层已具备租户内唯一、跨租户可复用的索引迁移;正式生效会短暂获取索引 DDL 锁,须在用户确认维护窗口后应用。
|
||||
@@ -0,0 +1,249 @@
|
||||
"""repair tenant identity lookup indexes
|
||||
|
||||
Revision ID: 20260718_0029
|
||||
Revises: 20260717_0028
|
||||
Create Date: 2026-07-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260718_0029"
|
||||
down_revision: str | None = "20260717_0028"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
class _TargetIndex:
|
||||
__slots__ = (
|
||||
"table_name",
|
||||
"column_name",
|
||||
"index_name",
|
||||
"tenant_unique_constraint",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
table_name: str,
|
||||
column_name: str,
|
||||
index_name: str,
|
||||
tenant_unique_constraint: str,
|
||||
) -> None:
|
||||
self.table_name = table_name
|
||||
self.column_name = column_name
|
||||
self.index_name = index_name
|
||||
self.tenant_unique_constraint = tenant_unique_constraint
|
||||
|
||||
|
||||
_TARGET_INDEXES = (
|
||||
_TargetIndex(
|
||||
table_name="organization_units",
|
||||
column_name="unit_code",
|
||||
index_name="ix_organization_units_unit_code",
|
||||
tenant_unique_constraint="uq_organization_units_tenant_code",
|
||||
),
|
||||
_TargetIndex(
|
||||
table_name="employees",
|
||||
column_name="employee_no",
|
||||
index_name="ix_employees_employee_no",
|
||||
tenant_unique_constraint="uq_employees_tenant_employee_no",
|
||||
),
|
||||
_TargetIndex(
|
||||
table_name="employees",
|
||||
column_name="email",
|
||||
index_name="ix_employees_email",
|
||||
tenant_unique_constraint="uq_employees_tenant_email",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260718_0029 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional index DDL"
|
||||
)
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _assert_tenant_unique_constraint(target: _TargetIndex) -> None:
|
||||
constraints = {
|
||||
str(item.get("name") or ""): tuple(item.get("column_names") or ())
|
||||
for item in _inspector().get_unique_constraints(target.table_name)
|
||||
}
|
||||
expected_columns = ("tenant_id", target.column_name)
|
||||
actual_columns = constraints.get(target.tenant_unique_constraint)
|
||||
if actual_columns != expected_columns:
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"{target.tenant_unique_constraint} must cover {expected_columns}, "
|
||||
f"found {actual_columns!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validated_present_targets() -> tuple[_TargetIndex, ...]:
|
||||
inspector = _inspector()
|
||||
present_targets: list[_TargetIndex] = []
|
||||
for target in _TARGET_INDEXES:
|
||||
if not inspector.has_table(target.table_name):
|
||||
continue
|
||||
columns = {
|
||||
str(item.get("name") or ""): item
|
||||
for item in inspector.get_columns(target.table_name)
|
||||
}
|
||||
required_columns = {"tenant_id", target.column_name}
|
||||
missing_columns = required_columns - columns.keys()
|
||||
if missing_columns:
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"{target.table_name} is missing columns "
|
||||
f"{', '.join(sorted(missing_columns))}"
|
||||
)
|
||||
if bool(columns["tenant_id"].get("nullable", True)):
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"{target.table_name}.tenant_id must be non-nullable"
|
||||
)
|
||||
_assert_tenant_unique_constraint(target)
|
||||
present_targets.append(target)
|
||||
return tuple(present_targets)
|
||||
|
||||
|
||||
def _index_uniqueness(target: _TargetIndex) -> bool | None:
|
||||
indexes = [
|
||||
item
|
||||
for item in _inspector().get_indexes(target.table_name)
|
||||
if str(item.get("name") or "") == target.index_name
|
||||
]
|
||||
if not indexes:
|
||||
return None
|
||||
if len(indexes) != 1:
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"found multiple indexes named {target.index_name}"
|
||||
)
|
||||
|
||||
index = indexes[0]
|
||||
actual_columns = tuple(index.get("column_names") or ())
|
||||
if actual_columns != (target.column_name,):
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"{target.index_name} must cover only {target.column_name}, "
|
||||
f"found {actual_columns!r}"
|
||||
)
|
||||
if index.get("duplicates_constraint"):
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"{target.index_name} backs a constraint and is not safe to replace"
|
||||
)
|
||||
|
||||
dialect_options = dict(index.get("dialect_options") or {})
|
||||
predicate = dialect_options.get("postgresql_where")
|
||||
index_method = dialect_options.get("postgresql_using")
|
||||
operator_classes = dialect_options.get("postgresql_ops") or {}
|
||||
included_columns = (
|
||||
index.get("include_columns")
|
||||
or dialect_options.get("postgresql_include")
|
||||
or ()
|
||||
)
|
||||
column_sorting = index.get("column_sorting") or {}
|
||||
if (
|
||||
predicate is not None
|
||||
or included_columns
|
||||
or index_method not in (None, "btree")
|
||||
or operator_classes
|
||||
or column_sorting
|
||||
):
|
||||
raise RuntimeError(
|
||||
"cannot repair tenant identity lookup indexes: "
|
||||
f"{target.index_name} is not a plain single-column btree index"
|
||||
)
|
||||
return bool(index.get("unique", False))
|
||||
|
||||
|
||||
def _replace_index(target: _TargetIndex, *, unique: bool) -> None:
|
||||
current_uniqueness = _index_uniqueness(target)
|
||||
if current_uniqueness == unique:
|
||||
return
|
||||
if current_uniqueness is not None:
|
||||
op.drop_index(target.index_name, table_name=target.table_name)
|
||||
op.create_index(
|
||||
target.index_name,
|
||||
target.table_name,
|
||||
[target.column_name],
|
||||
unique=unique,
|
||||
)
|
||||
if _index_uniqueness(target) != unique:
|
||||
raise RuntimeError(
|
||||
"tenant identity lookup index replacement did not reach the expected state: "
|
||||
f"{target.index_name} unique={unique}"
|
||||
)
|
||||
|
||||
|
||||
def _cross_tenant_duplicate_count(target: _TargetIndex) -> int:
|
||||
table = sa.table(
|
||||
target.table_name,
|
||||
sa.column("tenant_id"),
|
||||
sa.column(target.column_name),
|
||||
)
|
||||
value_column = table.c[target.column_name]
|
||||
duplicate_values = (
|
||||
sa.select(value_column)
|
||||
.where(value_column.is_not(None))
|
||||
.group_by(value_column)
|
||||
.having(sa.func.count(sa.distinct(table.c.tenant_id)) > 1)
|
||||
.subquery()
|
||||
)
|
||||
return int(
|
||||
op.get_bind().scalar(
|
||||
sa.select(sa.func.count()).select_from(duplicate_values)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
targets = _validated_present_targets()
|
||||
# 先校验所有旧索引形态,防止修到一半才发现同名索引承载了其他用途。
|
||||
for target in targets:
|
||||
_index_uniqueness(target)
|
||||
for target in targets:
|
||||
_replace_index(target, unique=False)
|
||||
for target in targets:
|
||||
_assert_tenant_unique_constraint(target)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
targets = _validated_present_targets()
|
||||
# downgrade 会恢复旧的全局唯一索引;必须在任何 DDL 前一次性排除跨租户重复。
|
||||
for target in targets:
|
||||
_index_uniqueness(target)
|
||||
violations = {
|
||||
f"{target.table_name}.{target.column_name}": duplicate_count
|
||||
for target in targets
|
||||
if (duplicate_count := _cross_tenant_duplicate_count(target)) > 0
|
||||
}
|
||||
if violations:
|
||||
details = ", ".join(
|
||||
f"{name}={count}" for name, count in sorted(violations.items())
|
||||
)
|
||||
raise RuntimeError(
|
||||
"cannot downgrade tenant identity lookup indexes: "
|
||||
f"cross-tenant duplicate values exist ({details})"
|
||||
)
|
||||
for target in targets:
|
||||
_replace_index(target, unique=True)
|
||||
for target in targets:
|
||||
_assert_tenant_unique_constraint(target)
|
||||
@@ -320,7 +320,10 @@ MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] = (
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"]
|
||||
| frozenset({"tenant_finance_report_configs", "tenant_finance_report_runs"})
|
||||
)
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] != MIGRATION_OWNED_TABLES:
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"] = (
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"]
|
||||
)
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
||||
@@ -410,6 +413,7 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
||||
"20260717_0026",
|
||||
"20260717_0027",
|
||||
"20260717_0028",
|
||||
"20260718_0029",
|
||||
}
|
||||
else frozenset()
|
||||
)
|
||||
|
||||
@@ -46,9 +46,29 @@ from app.models.risk_observation import RiskObservation
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260717_0028"
|
||||
HEAD_REVISION = "20260718_0029"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
TENANT_IDENTITY_LOOKUP_INDEXES = (
|
||||
(
|
||||
"organization_units",
|
||||
"unit_code",
|
||||
"ix_organization_units_unit_code",
|
||||
"uq_organization_units_tenant_code",
|
||||
),
|
||||
(
|
||||
"employees",
|
||||
"employee_no",
|
||||
"ix_employees_employee_no",
|
||||
"uq_employees_tenant_employee_no",
|
||||
),
|
||||
(
|
||||
"employees",
|
||||
"email",
|
||||
"ix_employees_email",
|
||||
"uq_employees_tenant_email",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _UnsupportedDialectOperationGuard:
|
||||
@@ -185,6 +205,30 @@ def _assert_indexes(
|
||||
assert indexes.get(index_name) == expected_columns
|
||||
|
||||
|
||||
def _assert_tenant_identity_lookup_indexes(
|
||||
engine: Engine,
|
||||
*,
|
||||
unique: bool,
|
||||
) -> None:
|
||||
inspector = inspect(engine)
|
||||
for table_name, column_name, index_name, constraint_name in (
|
||||
TENANT_IDENTITY_LOOKUP_INDEXES
|
||||
):
|
||||
indexes = {
|
||||
str(item["name"]): item
|
||||
for item in inspector.get_indexes(table_name, schema="public")
|
||||
}
|
||||
index = indexes[index_name]
|
||||
assert tuple(index["column_names"]) == (column_name,)
|
||||
assert bool(index.get("unique", False)) is unique
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
table_name,
|
||||
constraint_name,
|
||||
("tenant_id", column_name),
|
||||
)
|
||||
|
||||
|
||||
def _assert_postgresql_index_predicate(
|
||||
engine: Engine,
|
||||
table_name: str,
|
||||
@@ -1652,6 +1696,8 @@ def _assert_base_schema(engine: Engine) -> None:
|
||||
("20260717_0027_knowledge_tenant_security.py", "downgrade"),
|
||||
("20260717_0028_hermes_ontology_tenant_security.py", "upgrade"),
|
||||
("20260717_0028_hermes_ontology_tenant_security.py", "downgrade"),
|
||||
("20260718_0029_tenant_identity_lookup_indexes.py", "upgrade"),
|
||||
("20260718_0029_tenant_identity_lookup_indexes.py", "downgrade"),
|
||||
],
|
||||
)
|
||||
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
|
||||
@@ -1668,6 +1714,116 @@ def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("direction", "initial_unique", "expected_unique"),
|
||||
[
|
||||
("upgrade", True, False),
|
||||
("downgrade", False, True),
|
||||
],
|
||||
)
|
||||
def test_tenant_identity_lookup_index_migration_replaces_legacy_index_uniqueness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
direction: str,
|
||||
initial_unique: bool,
|
||||
expected_unique: bool,
|
||||
) -> None:
|
||||
migration = _load_migration_module(
|
||||
"20260718_0029_tenant_identity_lookup_indexes.py"
|
||||
)
|
||||
targets = migration._TARGET_INDEXES
|
||||
index_states = {
|
||||
target.index_name: initial_unique
|
||||
for target in targets
|
||||
}
|
||||
mutation_calls: list[tuple[str, str, str, tuple[str, ...] | None, bool | None]] = []
|
||||
|
||||
class _IndexOperation:
|
||||
bind = SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
|
||||
|
||||
def get_bind(self) -> SimpleNamespace:
|
||||
return self.bind
|
||||
|
||||
def drop_index(self, index_name: str, *, table_name: str) -> None:
|
||||
mutation_calls.append(("drop", table_name, index_name, None, None))
|
||||
index_states.pop(index_name)
|
||||
|
||||
def create_index(
|
||||
self,
|
||||
index_name: str,
|
||||
table_name: str,
|
||||
columns: list[str],
|
||||
*,
|
||||
unique: bool,
|
||||
) -> None:
|
||||
mutation_calls.append(
|
||||
("create", table_name, index_name, tuple(columns), unique)
|
||||
)
|
||||
index_states[index_name] = unique
|
||||
|
||||
migration.op = _IndexOperation()
|
||||
monkeypatch.setattr(migration, "_validated_present_targets", lambda: targets)
|
||||
monkeypatch.setattr(
|
||||
migration,
|
||||
"_index_uniqueness",
|
||||
lambda target: index_states.get(target.index_name),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
migration,
|
||||
"_assert_tenant_unique_constraint",
|
||||
lambda _target: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
migration,
|
||||
"_cross_tenant_duplicate_count",
|
||||
lambda _target: 0,
|
||||
)
|
||||
|
||||
getattr(migration, direction)()
|
||||
|
||||
assert index_states == {
|
||||
target.index_name: expected_unique
|
||||
for target in targets
|
||||
}
|
||||
assert len(mutation_calls) == len(targets) * 2
|
||||
assert all(
|
||||
call[4] is expected_unique
|
||||
for call in mutation_calls
|
||||
if call[0] == "create"
|
||||
)
|
||||
|
||||
|
||||
def test_tenant_identity_lookup_index_migration_refuses_lossy_downgrade(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
migration = _load_migration_module(
|
||||
"20260718_0029_tenant_identity_lookup_indexes.py"
|
||||
)
|
||||
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
|
||||
migration.op = operation_guard
|
||||
monkeypatch.setattr(
|
||||
migration,
|
||||
"_validated_present_targets",
|
||||
lambda: migration._TARGET_INDEXES,
|
||||
)
|
||||
monkeypatch.setattr(migration, "_index_uniqueness", lambda _target: False)
|
||||
monkeypatch.setattr(
|
||||
migration,
|
||||
"_cross_tenant_duplicate_count",
|
||||
lambda _target: 1,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="cross-tenant duplicate values exist") as error:
|
||||
migration.downgrade()
|
||||
|
||||
for expected_dimension in (
|
||||
"organization_units.unit_code",
|
||||
"employees.employee_no",
|
||||
"employees.email",
|
||||
):
|
||||
assert expected_dimension in str(error.value)
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
|
||||
migration = _load_migration_module("20260716_0012_risk_disposition_response_snapshot.py")
|
||||
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
|
||||
@@ -1933,6 +2089,76 @@ def test_risk_waiver_model_declares_decision_metadata_constraints() -> None:
|
||||
assert "reject_waiver" in str(event_action_constraint.sqltext)
|
||||
|
||||
|
||||
def _recreate_legacy_global_tenant_identity_indexes(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
for table_name, column_name, index_name, _constraint_name in (
|
||||
TENANT_IDENTITY_LOOKUP_INDEXES
|
||||
):
|
||||
connection.execute(text(f'DROP INDEX IF EXISTS "{index_name}"'))
|
||||
connection.execute(
|
||||
text(
|
||||
f'CREATE UNIQUE INDEX "{index_name}" '
|
||||
f'ON "{table_name}" ("{column_name}")'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _create_cross_tenant_identity_duplicate_probe(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO organization_units (
|
||||
id, tenant_id, unit_code, name, unit_type
|
||||
) VALUES
|
||||
(
|
||||
'tenant-index-org-default', 'default',
|
||||
'TENANT-INDEX-DUP', '租户索引默认组织', 'department'
|
||||
),
|
||||
(
|
||||
'tenant-index-org-platform', 'platform',
|
||||
'TENANT-INDEX-DUP', '租户索引平台组织', 'department'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO employees (
|
||||
id, tenant_id, employee_no, name, email, position, grade,
|
||||
employment_status, sync_state, compliance_score, spotlight,
|
||||
organization_unit_id
|
||||
) VALUES
|
||||
(
|
||||
'tenant-index-employee-default', 'default',
|
||||
'TENANT-INDEX-DUP', '租户索引默认员工',
|
||||
'tenant-index-dup@example.com', '员工', 'P3',
|
||||
'在职', '已同步', 100, false,
|
||||
'tenant-index-org-default'
|
||||
),
|
||||
(
|
||||
'tenant-index-employee-platform', 'platform',
|
||||
'TENANT-INDEX-DUP', '租户索引平台员工',
|
||||
'tenant-index-dup@example.com', '员工', 'P3',
|
||||
'在职', '已同步', 100, false,
|
||||
'tenant-index-org-platform'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _delete_cross_tenant_identity_duplicate_probe(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text("DELETE FROM employees WHERE id LIKE 'tenant-index-employee-%'")
|
||||
)
|
||||
connection.execute(
|
||||
text("DELETE FROM organization_units WHERE id LIKE 'tenant-index-org-%'")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"database_url",
|
||||
[
|
||||
@@ -1973,10 +2199,29 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
||||
|
||||
_upgrade_revision(migration_database_url, "20260716_0019")
|
||||
_create_legacy_connector_payload_probe(engine)
|
||||
_upgrade_revision(migration_database_url, "20260717_0028")
|
||||
create_legacy_schema(engine)
|
||||
_recreate_legacy_global_tenant_identity_indexes(engine)
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_and_delete_legacy_connector_payload_probe(engine)
|
||||
_assert_head_schema(engine)
|
||||
_assert_tenant_identity_lookup_indexes(engine, unique=False)
|
||||
assert validate_migration_state(engine).revision == HEAD_REVISION
|
||||
_create_cross_tenant_identity_duplicate_probe(engine)
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="cross-tenant duplicate values exist",
|
||||
) as tenant_index_error:
|
||||
_downgrade_revision(migration_database_url, "20260717_0028")
|
||||
for expected_dimension in (
|
||||
"organization_units.unit_code",
|
||||
"employees.employee_no",
|
||||
"employees.email",
|
||||
):
|
||||
assert expected_dimension in str(tenant_index_error.value)
|
||||
assert validate_migration_state(engine).revision == HEAD_REVISION
|
||||
_assert_tenant_identity_lookup_indexes(engine, unique=False)
|
||||
_delete_cross_tenant_identity_duplicate_probe(engine)
|
||||
_assert_savings_runtime_invariants(engine)
|
||||
_assert_commercial_runtime_invariants(engine)
|
||||
_assert_financial_connector_runtime_invariants(engine)
|
||||
@@ -2016,7 +2261,6 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
||||
"WHERE id = 'downgrade-refusal-baseline'"
|
||||
)
|
||||
)
|
||||
create_legacy_schema(engine)
|
||||
assert "expense_claims" in _table_names(engine)
|
||||
|
||||
_upgrade_head(migration_database_url)
|
||||
@@ -2059,6 +2303,7 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
||||
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_head_schema(engine)
|
||||
_assert_tenant_identity_lookup_indexes(engine, unique=False)
|
||||
_assert_legacy_sentinel(engine)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
@@ -214,8 +214,8 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
||||
- {"knowledge_onlyoffice_sessions"},
|
||||
),
|
||||
(
|
||||
"20260717_0028",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"]
|
||||
"20260718_0029",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"]
|
||||
- {"tenant_finance_report_runs"},
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user