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"]
|
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"]
|
||||||
| frozenset({"tenant_finance_report_configs", "tenant_finance_report_runs"})
|
| 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")
|
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||||
|
|
||||||
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
|
||||||
@@ -410,6 +413,7 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
|
|||||||
"20260717_0026",
|
"20260717_0026",
|
||||||
"20260717_0027",
|
"20260717_0027",
|
||||||
"20260717_0028",
|
"20260717_0028",
|
||||||
|
"20260718_0029",
|
||||||
}
|
}
|
||||||
else frozenset()
|
else frozenset()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,9 +46,29 @@ from app.models.risk_observation import RiskObservation
|
|||||||
|
|
||||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||||
HEAD_REVISION = "20260717_0028"
|
HEAD_REVISION = "20260718_0029"
|
||||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
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:
|
class _UnsupportedDialectOperationGuard:
|
||||||
@@ -185,6 +205,30 @@ def _assert_indexes(
|
|||||||
assert indexes.get(index_name) == expected_columns
|
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(
|
def _assert_postgresql_index_predicate(
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
table_name: str,
|
table_name: str,
|
||||||
@@ -1652,6 +1696,8 @@ def _assert_base_schema(engine: Engine) -> None:
|
|||||||
("20260717_0027_knowledge_tenant_security.py", "downgrade"),
|
("20260717_0027_knowledge_tenant_security.py", "downgrade"),
|
||||||
("20260717_0028_hermes_ontology_tenant_security.py", "upgrade"),
|
("20260717_0028_hermes_ontology_tenant_security.py", "upgrade"),
|
||||||
("20260717_0028_hermes_ontology_tenant_security.py", "downgrade"),
|
("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(
|
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 == []
|
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:
|
def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
|
||||||
migration = _load_migration_module("20260716_0012_risk_disposition_response_snapshot.py")
|
migration = _load_migration_module("20260716_0012_risk_disposition_response_snapshot.py")
|
||||||
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
|
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)
|
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(
|
@pytest.mark.parametrize(
|
||||||
"database_url",
|
"database_url",
|
||||||
[
|
[
|
||||||
@@ -1973,10 +2199,29 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
|||||||
|
|
||||||
_upgrade_revision(migration_database_url, "20260716_0019")
|
_upgrade_revision(migration_database_url, "20260716_0019")
|
||||||
_create_legacy_connector_payload_probe(engine)
|
_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)
|
_upgrade_head(migration_database_url)
|
||||||
_assert_and_delete_legacy_connector_payload_probe(engine)
|
_assert_and_delete_legacy_connector_payload_probe(engine)
|
||||||
_assert_head_schema(engine)
|
_assert_head_schema(engine)
|
||||||
|
_assert_tenant_identity_lookup_indexes(engine, unique=False)
|
||||||
assert validate_migration_state(engine).revision == HEAD_REVISION
|
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_savings_runtime_invariants(engine)
|
||||||
_assert_commercial_runtime_invariants(engine)
|
_assert_commercial_runtime_invariants(engine)
|
||||||
_assert_financial_connector_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'"
|
"WHERE id = 'downgrade-refusal-baseline'"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
create_legacy_schema(engine)
|
|
||||||
assert "expense_claims" in _table_names(engine)
|
assert "expense_claims" in _table_names(engine)
|
||||||
|
|
||||||
_upgrade_head(migration_database_url)
|
_upgrade_head(migration_database_url)
|
||||||
@@ -2059,6 +2303,7 @@ def test_alembic_migration_cycle_on_disposable_postgres(
|
|||||||
|
|
||||||
_upgrade_head(migration_database_url)
|
_upgrade_head(migration_database_url)
|
||||||
_assert_head_schema(engine)
|
_assert_head_schema(engine)
|
||||||
|
_assert_tenant_identity_lookup_indexes(engine, unique=False)
|
||||||
_assert_legacy_sentinel(engine)
|
_assert_legacy_sentinel(engine)
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
|||||||
- {"knowledge_onlyoffice_sessions"},
|
- {"knowledge_onlyoffice_sessions"},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"20260717_0028",
|
"20260718_0029",
|
||||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"]
|
MIGRATION_OWNED_TABLES_BY_REVISION["20260718_0029"]
|
||||||
- {"tenant_finance_report_runs"},
|
- {"tenant_finance_report_runs"},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user