migrate.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. from __future__ import annotations
  2. from sqlalchemy import text
  3. from sqlalchemy.engine import Engine
  4. def _table_exists(engine: Engine, table: str) -> bool:
  5. with engine.connect() as conn:
  6. r = conn.execute(text("SHOW TABLES LIKE :t"), {"t": table}).fetchone()
  7. return r is not None
  8. def _column_exists(engine: Engine, table: str, column: str) -> bool:
  9. with engine.connect() as conn:
  10. r = conn.execute(text(f"SHOW COLUMNS FROM `{table}` LIKE :c"), {"c": column}).fetchone()
  11. return r is not None
  12. def _add_column(engine: Engine, table: str, ddl: str) -> None:
  13. with engine.connect() as conn:
  14. conn.execute(text(f"ALTER TABLE `{table}` ADD COLUMN {ddl}"))
  15. conn.commit()
  16. def _column_info(engine: Engine, table: str, column: str) -> dict | None:
  17. with engine.connect() as conn:
  18. row = conn.execute(text(f"SHOW COLUMNS FROM `{table}` LIKE :c"), {"c": column}).mappings().fetchone()
  19. return dict(row) if row else None
  20. def _modify_column(engine: Engine, table: str, ddl: str) -> None:
  21. with engine.connect() as conn:
  22. conn.execute(text(f"ALTER TABLE `{table}` MODIFY COLUMN {ddl}"))
  23. conn.commit()
  24. def ensure_mysql_schema(engine: Engine) -> None:
  25. """
  26. 轻量迁移(不依赖 Alembic):
  27. - 若数据库已有旧版 cases 表,则自动补齐新版所需列
  28. - 新表(case_files/case_elements_versions/processing_tasks)由 create_all 创建;若表已存在则跳过
  29. 说明:此方法适用于毕业设计原型开发期;正式环境建议使用 Alembic 管理迁移。
  30. """
  31. if not _table_exists(engine, "cases"):
  32. return
  33. # 旧版 cases 表字段:id, case_name, source_files, raw_text, extracted_elements, case_profile, created_at
  34. # 新版需要字段:case_no, elements, portrait, applicant_name, respondent_name, case_cause, risk_level
  35. additions = [
  36. ("case_no", "`case_no` VARCHAR(64) NULL"),
  37. ("elements", "`elements` JSON NOT NULL"),
  38. ("portrait", "`portrait` JSON NOT NULL"),
  39. ("applicant_name", "`applicant_name` VARCHAR(64) NULL"),
  40. ("respondent_name", "`respondent_name` VARCHAR(128) NULL"),
  41. ("case_cause", "`case_cause` VARCHAR(64) NULL"),
  42. ("risk_level", "`risk_level` VARCHAR(16) NULL"),
  43. ("application_text", "`application_text` LONGTEXT NULL"),
  44. ("ruling_result", "`ruling_result` TEXT NULL"),
  45. ("arbitration_org", "`arbitration_org` VARCHAR(255) NULL"),
  46. ("created_at", "`created_at` DATETIME NULL"),
  47. ("material_fingerprint", "`material_fingerprint` VARCHAR(64) NULL"),
  48. ]
  49. for col, ddl in additions:
  50. if not _column_exists(engine, "cases", col):
  51. _add_column(engine, "cases", ddl)
  52. if _column_exists(engine, "cases", "material_fingerprint"):
  53. with engine.connect() as conn:
  54. idx = conn.execute(
  55. text(
  56. "SELECT 1 FROM information_schema.statistics "
  57. "WHERE table_schema = DATABASE() AND table_name = 'cases' AND index_name = 'ix_cases_material_fp'"
  58. )
  59. ).fetchone()
  60. if not idx:
  61. try:
  62. conn.execute(text("CREATE INDEX `ix_cases_material_fp` ON `cases` (`material_fingerprint`)"))
  63. conn.commit()
  64. except Exception:
  65. conn.rollback()
  66. # 旧表约束放宽:避免旧必填字段导致新插入失败
  67. # 常见旧字段:source_files/raw_text/extracted_elements/case_profile 可能为 NOT NULL 且无默认值
  68. relax = [
  69. ("source_files", "`source_files` JSON NULL"),
  70. ("raw_text", "`raw_text` LONGTEXT NULL"),
  71. ("extracted_elements", "`extracted_elements` JSON NULL"),
  72. ("case_profile", "`case_profile` JSON NULL"),
  73. ]
  74. for col, ddl in relax:
  75. info = _column_info(engine, "cases", col)
  76. if not info:
  77. continue
  78. # info: Field, Type, Null, Key, Default, Extra
  79. if str(info.get("Null", "")).upper() == "NO" and info.get("Default") is None:
  80. _modify_column(engine, "cases", ddl)
  81. # 兼容旧字段:如果旧表里没有 elements/portrait,但有 extracted_elements/case_profile,
  82. # 这里不做数据迁移(避免 JSON 兼容差异);后续写入会以新字段为主。
  83. _ensure_case_child_fks_cascade(engine)
  84. def _ensure_case_child_fks_cascade(engine: Engine) -> None:
  85. """
  86. 将 case_files / case_elements_versions / processing_tasks 指向 cases.id 的外键改为 ON DELETE CASCADE,
  87. 避免手工或 ORM 删除父行时出现 Error 1451。
  88. """
  89. child_tables = ("case_files", "case_elements_versions", "processing_tasks")
  90. with engine.connect() as conn:
  91. for table in child_tables:
  92. if not _table_exists(engine, table):
  93. continue
  94. rows = conn.execute(
  95. text(
  96. """
  97. SELECT rc.CONSTRAINT_NAME, rc.DELETE_RULE
  98. FROM information_schema.REFERENTIAL_CONSTRAINTS rc
  99. INNER JOIN information_schema.KEY_COLUMN_USAGE kcu
  100. ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
  101. AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
  102. AND rc.TABLE_NAME = kcu.TABLE_NAME
  103. WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
  104. AND rc.TABLE_NAME = :tbl
  105. AND kcu.REFERENCED_TABLE_NAME = 'cases'
  106. AND kcu.REFERENCED_COLUMN_NAME = 'id'
  107. """
  108. ),
  109. {"tbl": table},
  110. ).fetchall()
  111. for row in rows:
  112. cname, rule = row[0], str(row[1] or "").upper()
  113. if rule == "CASCADE":
  114. continue
  115. try:
  116. conn.execute(text(f"ALTER TABLE `{table}` DROP FOREIGN KEY `{cname}`"))
  117. conn.execute(
  118. text(
  119. f"ALTER TABLE `{table}` ADD CONSTRAINT `{cname}` "
  120. f"FOREIGN KEY (`case_id`) REFERENCES `cases` (`id`) ON DELETE CASCADE"
  121. )
  122. )
  123. conn.commit()
  124. except Exception:
  125. conn.rollback()