fix(migrations): repair tenant identity lookup indexes
This commit is contained in:
@@ -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