Files
dpb/backend/scripts/schema_align.py
T
34047007@qq.com b95053c52c init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
2026-08-06 00:17:49 +08:00

199 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""波次5.1 数据治理:bre_* 表审计列统一到新契约。
新契约(与 core/base_model.py ModelMixin + UserMixin 一致):
id / uuid(unique) / is_deleted(bool) / created_time / updated_time / deleted_time /
created_id / updated_id / deleted_id
处理三类历史表(全部幂等,可重复执行):
- 旧式表(is_deleted=int,遗留 created_by/updated_by 列,缺 uuid/deleted_time):
补 uuid(回填 gen_random_uuid + 唯一索引)、补 deleted_time、
is_deleted int→boolean、删除 created_by/updated_by(先并入 created_id/updated_id)。
- 缺审计列表(bre_prediction_value / bre_audit_log / bre_statistics_job):
补缺列(uuid/is_deleted/deleted_time/updated_time)。
- 新式表残留死列(created_by/updated_by):直接删除。
说明:
- 旧式表既有 created_id(新)又有 created_by(旧),合并时以 created_id 优先。
- 统计 4 表(prediction/prediction_value/combining_ability/statistics_job)已接 ORM
补 created_deleted 索引与 ORM __table_args__ 对齐。
"""
import asyncio
import os
import sys
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BACKEND_DIR)
os.environ.setdefault("ENVIRONMENT", "dev")
# 旧式表:is_deleted=int + created_by/updated_by 遗留,缺 uuid/deleted_time
GROUP_A = [
"bre_marker", "bre_genotype_call", "bre_genotype_sample", "bre_genotyping_dataset",
"bre_environment_condition", "bre_field_operation", "bre_group_member", "bre_observation",
"bre_planting_treatment", "bre_prediction", "bre_propagation", "bre_report", "bre_seed_lot",
"bre_combining_ability",
]
# 特殊:bre_statistics_job 缺 uuid/deleted_time/updated_time,且有 created_by 遗留
GROUP_C = ["bre_statistics_job"]
# 缺审计列:uuid/is_deleted/deleted_time/updated_time 全缺(无 created_by 遗留)
GROUP_B = ["bre_prediction_value", "bre_audit_log"]
# 新式表残留 created_by/updated_by 死列
GROUP_D = [
"bre_group", "bre_pedigree", "bre_plan", "bre_selection_rule", "bre_trait",
"bre_treatment", "bre_trial", "bre_trial_study", "bre_trial_study_entry",
]
# 统计 4 表补 created_deleted 索引(与 ORM __table_args__ 对齐)
INDEX_TABLES = ["bre_prediction", "bre_prediction_value", "bre_combining_ability", "bre_statistics_job"]
async def column_exists(db, table: str, col: str) -> bool:
from sqlalchemy import text
r = await db.execute(text(
"SELECT 1 FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
), {"t": table, "c": col})
return r.scalar() is not None
async def index_exists(db, table: str, idx: str) -> bool:
from sqlalchemy import text
r = await db.execute(text(
"SELECT 1 FROM pg_indexes WHERE schemaname='public' AND tablename=:t AND indexname=:i"
), {"t": table, "i": idx})
return r.scalar() is not None
async def column_type(db, table: str, col: str) -> str:
from sqlalchemy import text
r = await db.execute(text(
"SELECT data_type FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
), {"t": table, "c": col})
return (r.scalar() or "").lower()
async def ensure_uuid(db, table: str) -> None:
from sqlalchemy import text
if not await column_exists(db, table, "uuid"):
await db.execute(text(f"ALTER TABLE {table} ADD COLUMN uuid VARCHAR(64)"))
await db.execute(text(
f"UPDATE {table} SET uuid = gen_random_uuid()::varchar WHERE uuid IS NULL OR btrim(uuid) = ''"
))
await db.execute(text(f"ALTER TABLE {table} ALTER COLUMN uuid SET NOT NULL"))
idx = f"ix_{table}_uuid"
if not await index_exists(db, table, idx):
await db.execute(text(f"CREATE UNIQUE INDEX {idx} ON {table} (uuid)"))
async def ensure_column(db, table: str, col: str, ddl: str) -> None:
from sqlalchemy import text
if not await column_exists(db, table, col):
await db.execute(text(ddl))
async def drop_legacy_audit(db, table: str) -> None:
"""合并旧列数据到新列后删除 created_by/updated_by 死列。"""
from sqlalchemy import text
if await column_exists(db, table, "created_by"):
await db.execute(text(
f"UPDATE {table} SET created_id = created_by "
f"WHERE created_id IS NULL AND created_by IS NOT NULL"
))
await db.execute(text(f"ALTER TABLE {table} DROP COLUMN created_by"))
if await column_exists(db, table, "updated_by"):
await db.execute(text(
f"UPDATE {table} SET updated_id = updated_by "
f"WHERE updated_id IS NULL AND updated_by IS NOT NULL"
))
await db.execute(text(f"ALTER TABLE {table} DROP COLUMN updated_by"))
async def convert_is_deleted(db, table: str) -> None:
from sqlalchemy import text
t = await column_type(db, table, "is_deleted")
if t != "integer":
return
bad = (await db.execute(text(
f"SELECT COUNT(*) FROM {table} WHERE is_deleted IS NULL OR is_deleted NOT IN (0,1)"
))).scalar() or 0
if bad:
raise RuntimeError(f"{table}.is_deleted 含非 0/1 值 {bad} 行,拒绝转换")
# 先去掉整数默认值(0),否则类型转换会因默认值无法转换而失败
await db.execute(text(f"ALTER TABLE {table} ALTER COLUMN is_deleted DROP DEFAULT"))
await db.execute(text(
f"ALTER TABLE {table} ALTER COLUMN is_deleted TYPE BOOLEAN USING (is_deleted::int::boolean)"
))
await db.execute(text(f"ALTER TABLE {table} ALTER COLUMN is_deleted SET DEFAULT false"))
async def ensure_created_deleted_index(db, table: str) -> None:
from sqlalchemy import text
idx = f"ix_{table}_created_deleted"
if not await index_exists(db, table, idx):
await db.execute(text(
f"CREATE INDEX {idx} ON {table} (created_time, is_deleted)"
))
async def main() -> None:
from sqlalchemy import text
from app.core.database import async_db_session
done: list[str] = []
async with async_db_session() as db:
for t in GROUP_A:
await ensure_uuid(db, t)
await ensure_column(db, t, "deleted_time", f"ALTER TABLE {t} ADD COLUMN deleted_time TIMESTAMPTZ")
await convert_is_deleted(db, t)
await drop_legacy_audit(db, t)
await db.commit()
done.append(f"{t} (old-style)")
for t in GROUP_C:
await ensure_uuid(db, t)
await ensure_column(db, t, "deleted_time", f"ALTER TABLE {t} ADD COLUMN deleted_time TIMESTAMPTZ")
await convert_is_deleted(db, t)
await drop_legacy_audit(db, t)
if not await column_exists(db, t, "updated_time"):
await db.execute(text(
f"ALTER TABLE {t} ADD COLUMN updated_time TIMESTAMPTZ DEFAULT now() NOT NULL"
))
await db.commit()
done.append(f"{t} (old-style, 补 updated_time)")
for t in GROUP_B:
await ensure_uuid(db, t)
await ensure_column(db, t, "is_deleted", f"ALTER TABLE {t} ADD COLUMN is_deleted BOOLEAN DEFAULT false NOT NULL")
await ensure_column(db, t, "deleted_time", f"ALTER TABLE {t} ADD COLUMN deleted_time TIMESTAMPTZ")
if not await column_exists(db, t, "updated_time"):
await db.execute(text(
f"ALTER TABLE {t} ADD COLUMN updated_time TIMESTAMPTZ DEFAULT now() NOT NULL"
))
await db.commit()
done.append(f"{t} (缺审计列)")
for t in GROUP_D:
await drop_legacy_audit(db, t)
await db.commit()
done.append(f"{t} (删死列)")
for t in INDEX_TABLES:
await ensure_created_deleted_index(db, t)
await db.commit()
done.append(f"{t} (created_deleted 索引)")
print("完成 " + str(len(done)) + " 项迁移:")
for d in done:
print(" - " + d)
if __name__ == "__main__":
asyncio.run(main())