Files
group_fqcd_jr/tools/audit_schema.py
T

111 lines
3.7 KiB
Python

"""表存在性审计:`baseline_generated.sql` 声明的表 vs MySQL 实际表。
与另两个工具互补:
- `tools/audit_constraints.py`:唯一键 ↔ 文档基线、ORM 映射 ↔ 库列名;
- `tools/schema_fingerprint.py`:字段、索引与外键的结构指纹;
- 本脚本:表名集合与列存在性。
凭据来自 `.env` 的 `MYSQL_DSN`,不在脚本内硬编码口令。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.core.config import get_settings # noqa: E402
SQL_FILE = ROOT / "alembic" / "baseline_generated.sql"
# baseline_generated.sql 只覆盖基线表;以下平台增量表由 Alembic 迁移创建。
INCREMENTAL_TABLES = {
"agent_run",
"domain_event_outbox",
"request_idempotency",
"config_release",
"platform_config_item",
"model_endpoint_config",
"model_routing_rule",
"model_routing_fallback",
"prompt_template_version",
"outbox_delivery",
"svc_conversation_session",
"api_request_receipt",
# 投顾增量表:全部为新增表,基线表和场内交易表保持不变。
"advisor_investment_goal",
"advisor_product_industry_exposure",
"advisor_portfolio_projection_checkpoint",
"advisor_product_metric_snapshot",
"advisor_product_asset_classification",
"advisor_product_reference_snapshot",
"advisor_offsite_fund_reference",
"advisor_product_price_history",
"advisor_product_suitability_reference",
"advisor_product_contract_snapshot",
"advisor_product_governance_candidate_snapshot",
"advisor_product_governance_sync_run",
"advisor_product_market_quote_snapshot",
"advisor_market_quote_source_run",
"advisor_market_quote_source_health",
"advisor_market_quote_alert",
"advisor_product_data_quality_snapshot",
"advisor_allocation_backtest_run",
"advisor_goal_conversation_extraction",
"advisor_profile_drift_review",
"advisor_profile_tag",
}
def expected_tables() -> set[str]:
content = SQL_FILE.read_text(encoding="utf-8")
return set(re.findall(r"CREATE TABLE `?([A-Za-z0-9_]+)`?", content))
def main() -> int:
engine = create_engine(get_settings().mysql_dsn.replace("mysql+asyncmy", "mysql+pymysql"))
with engine.connect() as connection:
actual = set(
connection.execute(
text(
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE()"
)
)
.scalars()
.all()
) - {"alembic_version"}
expected = expected_tables() | INCREMENTAL_TABLES
missing = expected - actual
unexpected = actual - expected
if missing or unexpected:
print(f"schema audit FAILED: missing={sorted(missing)} unexpected={sorted(unexpected)}")
return 1
rows = connection.execute(
text(
"SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE()"
)
).all()
engine.dispose()
column_counts: dict[str, int] = {}
for table, _column in rows:
column_counts[table] = column_counts.get(table, 0) + 1
without_columns = sorted(table for table in expected if not column_counts.get(table))
if without_columns:
print(f"schema audit FAILED: tables without columns: {without_columns}")
return 1
print(f"schema audit passed: {len(expected)} business tables, no missing or unexpected tables")
return 0
if __name__ == "__main__":
raise SystemExit(main())