schema_align.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. package fixture
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "io"
  7. "os"
  8. "path/filepath"
  9. "regexp"
  10. "sort"
  11. "strings"
  12. )
  13. // 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的 created_at / updated_at
  14. // 对齐到 DDL 文件的口径(补 DEFAULT CURRENT_TIMESTAMP(3),updated_at 另有 ON UPDATE)。
  15. //
  16. // 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
  17. // GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这两列 **NOT NULL 且无默认值**,
  18. // 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。
  19. //
  20. // 纪律(不可妥协):
  21. // 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移);
  22. // 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径
  23. // —— 这样除了这两个属性,其它一个字节都不动;
  24. // 3. NULL 性、类型、注释等**其它**差异只报告不执行,等人工定夺;
  25. // 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY),
  26. // 语法或属性不合法的语句在真正执行前就会暴露。
  27. // DDL 目录(与 core DDL 同一目录)。
  28. const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model`
  29. // 本工具只处理这两列。
  30. var alignedColumns = []string{"created_at", "updated_at"}
  31. // ddlColumn 是从 DDL 文件里解析出来的一列。
  32. type ddlColumn struct {
  33. Table string
  34. Column string
  35. RawDef string // DDL 里的原始定义(不含列名、不含逗号)
  36. Type string // 如 DATETIME(3)
  37. NotNull bool
  38. HasDefault bool
  39. OnUpdate bool
  40. Comment string
  41. }
  42. // liveColumn 是实库的一列(取自 information_schema)。
  43. type liveColumn struct {
  44. Table string
  45. Column string
  46. ColumnType string // 如 datetime(3)
  47. Nullable bool
  48. Default sql.NullString
  49. Extra string
  50. Comment string
  51. }
  52. // AlignAction 是一张表一列的对齐判定。
  53. type AlignAction struct {
  54. Table string
  55. Column string
  56. Kind string // ok / alignable / drift-nullable / drift-type / ddl-missing
  57. LiveDesc string
  58. DDLDefault string
  59. SQL string // Kind=alignable 时的 ALTER 语句
  60. Note string
  61. }
  62. // AlignReport 是整轮比对结果。
  63. type AlignReport struct {
  64. ModelDir string
  65. Actions []AlignAction
  66. DryRunOK map[int]bool // Actions 下标 → dry-run 是否通过
  67. Applied int
  68. Failed int
  69. Errors []string
  70. }
  71. // Alignable 返回需要补 DEFAULT 的条目下标。
  72. func (r *AlignReport) Alignable() []int {
  73. var out []int
  74. for i, a := range r.Actions {
  75. if a.Kind == "alignable" {
  76. out = append(out, i)
  77. }
  78. }
  79. return out
  80. }
  81. // Problems 返回「只报告不执行」的条目。
  82. func (r *AlignReport) Problems() []AlignAction {
  83. var out []AlignAction
  84. for _, a := range r.Actions {
  85. if strings.HasPrefix(a.Kind, "drift") || a.Kind == "ddl-missing" {
  86. out = append(out, a)
  87. }
  88. }
  89. return out
  90. }
  91. var (
  92. createTableLine = regexp.MustCompile("^\\s*CREATE\\s+TABLE\\s+`([A-Za-z0-9_]+)`")
  93. columnLine = regexp.MustCompile("^\\s*`([A-Za-z0-9_]+)`\\s+(.+?),?\\s*$")
  94. defaultExpr = regexp.MustCompile(`(?i)\bDEFAULT\s+(CURRENT_TIMESTAMP(?:\(\d*\))?|'[^']*'|[^\s]+)`)
  95. commentExpr = regexp.MustCompile(`(?i)\bCOMMENT\s+'((?:[^']|'')*)'`)
  96. firstToken = regexp.MustCompile(`^[A-Za-z]+(?:\([0-9,]+\))?`)
  97. )
  98. // ParseDDLFile 解析一个 DDL 文件,返回其中 created_at / updated_at 的列定义。
  99. func ParseDDLFile(path string) ([]ddlColumn, error) {
  100. raw, err := os.ReadFile(path)
  101. if err != nil {
  102. return nil, fmt.Errorf("读 DDL 失败(%s):%w", path, err)
  103. }
  104. var out []ddlColumn
  105. curTable := ""
  106. for _, line := range strings.Split(string(raw), "\n") {
  107. trimmed := strings.TrimSpace(line)
  108. if trimmed == "" || strings.HasPrefix(trimmed, "--") {
  109. continue
  110. }
  111. if m := createTableLine.FindStringSubmatch(trimmed); m != nil {
  112. curTable = m[1]
  113. continue
  114. }
  115. if curTable == "" {
  116. continue
  117. }
  118. if strings.HasPrefix(trimmed, ")") {
  119. curTable = ""
  120. continue
  121. }
  122. m := columnLine.FindStringSubmatch(line)
  123. if m == nil {
  124. continue
  125. }
  126. col := m[1]
  127. if len(alignedColumns) > 0 && !contains(alignedColumns, col) {
  128. continue
  129. }
  130. def := strings.TrimSuffix(strings.TrimSpace(m[2]), ",")
  131. c := ddlColumn{Table: curTable, Column: col, RawDef: def}
  132. if t := firstToken.FindString(def); t != "" {
  133. c.Type = t
  134. }
  135. c.NotNull = strings.Contains(strings.ToUpper(def), "NOT NULL")
  136. if dm := defaultExpr.FindStringSubmatch(def); dm != nil {
  137. c.HasDefault = true
  138. }
  139. c.OnUpdate = strings.Contains(strings.ToUpper(def), "ON UPDATE CURRENT_TIMESTAMP")
  140. if cm := commentExpr.FindStringSubmatch(def); cm != nil {
  141. c.Comment = strings.ReplaceAll(cm[1], "''", "'")
  142. }
  143. out = append(out, c)
  144. }
  145. return out, nil
  146. }
  147. func contains(ss []string, s string) bool {
  148. for _, v := range ss {
  149. if v == s {
  150. return true
  151. }
  152. }
  153. return false
  154. }
  155. // loadLiveColumns 从 information_schema 读实库这两列的属性。
  156. func loadLiveColumns(ctx context.Context, db *sql.DB, schema string) (map[string]liveColumn, error) {
  157. // 关掉 information_schema 统计缓存,拿即时值(同 batch2 的 AUTO_INCREMENT 坑)。
  158. if _, err := db.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
  159. return nil, err
  160. }
  161. rows, err := db.QueryContext(ctx, `
  162. SELECT table_name, column_name, column_type, is_nullable, column_default, extra, column_comment
  163. FROM information_schema.columns
  164. WHERE table_schema = ?
  165. AND column_name IN ('created_at','updated_at')`, schema)
  166. if err != nil {
  167. return nil, err
  168. }
  169. defer rows.Close()
  170. out := map[string]liveColumn{}
  171. for rows.Next() {
  172. var c liveColumn
  173. var nullable string
  174. if err := rows.Scan(&c.Table, &c.Column, &c.ColumnType, &nullable, &c.Default, &c.Extra, &c.Comment); err != nil {
  175. return nil, err
  176. }
  177. c.Nullable = strings.EqualFold(nullable, "YES")
  178. out[c.Table+"."+c.Column] = c
  179. }
  180. return out, rows.Err()
  181. }
  182. // buildModifySQL 用**实库现有属性** + DDL 的 DEFAULT 拼出 MODIFY 子句。
  183. //
  184. // 只动两处:DEFAULT CURRENT_TIMESTAMP(3)(两列都有)、ON UPDATE CURRENT_TIMESTAMP(3)(仅 updated_at)。
  185. // type / NOT NULL / COMMENT 全部照抄实库,避免顺手改掉别的属性。
  186. func buildModifySQL(live liveColumn, ddl ddlColumn) string {
  187. var b strings.Builder
  188. b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType))
  189. if !live.Nullable {
  190. b.WriteString(" NOT NULL")
  191. }
  192. b.WriteString(" DEFAULT CURRENT_TIMESTAMP(3)")
  193. if ddl.OnUpdate {
  194. b.WriteString(" ON UPDATE CURRENT_TIMESTAMP(3)")
  195. }
  196. if live.Comment != "" {
  197. b.WriteString(" COMMENT '" + strings.ReplaceAll(live.Comment, "'", "''") + "'")
  198. }
  199. return b.String()
  200. }
  201. // describeLive 生成实库列的可读描述(报告用)。
  202. func describeLive(l liveColumn) string {
  203. def := "NULL(无默认值)"
  204. if l.Default.Valid {
  205. def = l.Default.String
  206. }
  207. null := "NULL"
  208. if !l.Nullable {
  209. null = "NOT NULL"
  210. }
  211. return fmt.Sprintf("%s %s DEFAULT=%s extra=%q", l.ColumnType, null, def, l.Extra)
  212. }
  213. // BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。
  214. func BuildAlignReport(ctx context.Context, cfg Config, modelDir string) (*AlignReport, error) {
  215. if modelDir == "" {
  216. modelDir = os.Getenv("AC15_MODEL_DIR")
  217. }
  218. if modelDir == "" {
  219. modelDir = defaultModelDir
  220. }
  221. files := []string{
  222. filepath.Join(modelDir, "core-model-ddl.sql"),
  223. filepath.Join(modelDir, "academic-model-ddl.sql"),
  224. }
  225. var specs []ddlColumn
  226. for _, f := range files {
  227. cols, err := ParseDDLFile(f)
  228. if err != nil {
  229. return nil, err
  230. }
  231. specs = append(specs, cols...)
  232. }
  233. if len(specs) == 0 {
  234. return nil, fmt.Errorf("DDL 里没有解析到 created_at/updated_at 定义(modelDir=%s)", modelDir)
  235. }
  236. db, err := cfg.OpenTarget()
  237. if err != nil {
  238. return nil, err
  239. }
  240. defer db.Close()
  241. live, err := loadLiveColumns(ctx, db, cfg.TargetDB)
  242. if err != nil {
  243. return nil, err
  244. }
  245. rep := &AlignReport{ModelDir: modelDir, DryRunOK: map[int]bool{}}
  246. for _, sp := range specs {
  247. key := sp.Table + "." + sp.Column
  248. lv, ok := live[key]
  249. if !ok {
  250. rep.Actions = append(rep.Actions, AlignAction{
  251. Table: sp.Table, Column: sp.Column, Kind: "ddl-missing",
  252. LiveDesc: "<实库无此列>", Note: "DDL 有、实库没有 —— 属缺列,本工具不处理(需要 ALTER ADD COLUMN,另有风险)",
  253. })
  254. continue
  255. }
  256. a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv),
  257. DDLDefault: "CURRENT_TIMESTAMP(3)"}
  258. switch {
  259. case lv.Nullable != !sp.NotNull:
  260. // NULL 性不一致 —— 只报告,等人工定夺(团队要求不顺手改)
  261. a.Kind = "drift-nullable"
  262. liveNull := "NOT NULL"
  263. if lv.Nullable {
  264. liveNull = "NULL"
  265. }
  266. ddlNull := "NOT NULL"
  267. if !sp.NotNull {
  268. ddlNull = "NULL"
  269. }
  270. a.Note = fmt.Sprintf("NULL 性不一致:实库 %s,DDL %s —— 只报告不改", liveNull, ddlNull)
  271. case !sp.HasDefault:
  272. a.Kind = "ddl-no-default"
  273. a.Note = "DDL 该列本身就没有 DEFAULT CURRENT_TIMESTAMP(3),无需对齐"
  274. case lv.Default.Valid:
  275. a.Kind = "ok"
  276. a.Note = "已有默认值(或已对齐)"
  277. default:
  278. a.Kind = "alignable"
  279. a.SQL = buildModifySQL(lv, sp)
  280. a.Note = "实库 DEFAULT 为 NULL,补 DEFAULT CURRENT_TIMESTAMP(3)" +
  281. map[bool]string{true: ",并补 ON UPDATE CURRENT_TIMESTAMP(3)", false: ""}[sp.OnUpdate]
  282. }
  283. rep.Actions = append(rep.Actions, a)
  284. }
  285. sort.Slice(rep.Actions, func(i, j int) bool {
  286. if rep.Actions[i].Table != rep.Actions[j].Table {
  287. return rep.Actions[i].Table < rep.Actions[j].Table
  288. }
  289. return rep.Actions[i].Column < rep.Actions[j].Column
  290. })
  291. return rep, nil
  292. }
  293. // DryRunAlign 在**会话临时表**上验证每条 ALTER 是否合法且达到预期效果。
  294. //
  295. // 不动真实表:`CREATE TEMPORARY TABLE ... LIKE 真表` → 跑同一条 MODIFY → 复查默认值。
  296. func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
  297. db, err := cfg.OpenTarget()
  298. if err != nil {
  299. return err
  300. }
  301. defer db.Close()
  302. conn, err := db.Conn(ctx)
  303. if err != nil {
  304. return err
  305. }
  306. defer conn.Close()
  307. for i := range rep.Actions {
  308. a := &rep.Actions[i]
  309. if a.Kind != "alignable" {
  310. continue
  311. }
  312. tmp := "tmp_align_" + a.Table
  313. if _, err := conn.ExecContext(ctx,
  314. fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE `%s`", tmp, a.Table)); err != nil {
  315. return fmt.Errorf("dry-run 建临时表失败(%s):%w", a.Table, err)
  316. }
  317. // 把临时表名替换进 ALTER 语句(其余部分逐字符相同)
  318. stmt := strings.Replace(a.SQL, "ALTER TABLE `"+a.Table+"`", "ALTER TABLE `"+tmp+"`", 1)
  319. if _, err := conn.ExecContext(ctx, stmt); err != nil {
  320. rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 失败 %s.%s:%v", a.Table, a.Column, err))
  321. rep.DryRunOK[i] = false
  322. continue
  323. }
  324. var def sql.NullString
  325. var extra string
  326. if err := conn.QueryRowContext(ctx, `
  327. SELECT column_default, extra FROM information_schema.columns
  328. WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, tmp, a.Column).Scan(&def, &extra); err != nil {
  329. // 临时表不出现在 information_schema 的情况(部分版本),退化为 SHOW COLUMNS
  330. rows, err2 := conn.QueryContext(ctx, fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE '%s'", tmp, a.Column))
  331. if err2 != nil {
  332. rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 复查失败 %s.%s:%v", a.Table, a.Column, err2))
  333. rep.DryRunOK[i] = false
  334. continue
  335. }
  336. for rows.Next() {
  337. var field, typ, null, key, d, ex sql.NullString
  338. if err := rows.Scan(&field, &typ, &null, &key, &d, &ex); err != nil {
  339. rows.Close()
  340. return err
  341. }
  342. def, extra = d, ex.String
  343. }
  344. rows.Close()
  345. }
  346. rep.DryRunOK[i] = def.Valid && strings.Contains(strings.ToLower(def.String), "current_timestamp")
  347. if !rep.DryRunOK[i] {
  348. rep.Errors = append(rep.Errors, fmt.Sprintf(
  349. "dry-run 复查不符 %s.%s:default=%v extra=%q(期望 CURRENT_TIMESTAMP)", a.Table, a.Column, def, extra))
  350. }
  351. }
  352. return nil
  353. }
  354. // ApplyAlign 执行对齐(只在 dry-run 全部通过时执行)。
  355. func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
  356. for i := range rep.Actions {
  357. if rep.Actions[i].Kind == "alignable" && !rep.DryRunOK[i] {
  358. return fmt.Errorf("存在未通过 dry-run 的语句,拒绝执行(%s.%s)",
  359. rep.Actions[i].Table, rep.Actions[i].Column)
  360. }
  361. }
  362. db, err := cfg.OpenTarget()
  363. if err != nil {
  364. return err
  365. }
  366. defer db.Close()
  367. for i := range rep.Actions {
  368. a := &rep.Actions[i]
  369. if a.Kind != "alignable" {
  370. continue
  371. }
  372. if _, err := db.ExecContext(ctx, a.SQL); err != nil {
  373. rep.Failed++
  374. rep.Errors = append(rep.Errors, fmt.Sprintf("执行失败 %s.%s:%v", a.Table, a.Column, err))
  375. continue
  376. }
  377. rep.Applied++
  378. }
  379. return nil
  380. }
  381. // VerifyAlign 复核:这两列的 COLUMN_DEFAULT 必须全部非 NULL(drift-nullable 的除外)。
  382. func VerifyAlign(ctx context.Context, cfg Config) (total int, missing []string, err error) {
  383. db, err := cfg.OpenTarget()
  384. if err != nil {
  385. return 0, nil, err
  386. }
  387. defer db.Close()
  388. live, err := loadLiveColumns(ctx, db, cfg.TargetDB)
  389. if err != nil {
  390. return 0, nil, err
  391. }
  392. for key, c := range live {
  393. total++
  394. if !c.Default.Valid {
  395. missing = append(missing, fmt.Sprintf("%s(%s)", key, c.ColumnType))
  396. }
  397. }
  398. sort.Strings(missing)
  399. return total, missing, nil
  400. }
  401. // PrintAlignReport 打印对齐清单。
  402. func PrintAlignReport(w io.Writer, r *AlignReport) {
  403. fmt.Fprintf(w, "== 结构对齐清单(基准:DDL 文件)==\n")
  404. fmt.Fprintf(w, "DDL 目录:%s\n\n", r.ModelDir)
  405. alignable := r.Alignable()
  406. fmt.Fprintf(w, "【待补 DEFAULT】%d 条(只 MODIFY 这两列,其余属性照抄实库)\n", len(alignable))
  407. for _, i := range alignable {
  408. a := r.Actions[i]
  409. dry := "未验证"
  410. if ok, has := r.DryRunOK[i]; has {
  411. if ok {
  412. dry = "dry-run ✔"
  413. } else {
  414. dry = "dry-run ✘"
  415. }
  416. }
  417. fmt.Fprintf(w, " %-30s 实库:%s\n %s [%s]\n", a.Table+"."+a.Column, a.LiveDesc, a.SQL, dry)
  418. }
  419. okCount := 0
  420. for _, a := range r.Actions {
  421. if a.Kind == "ok" {
  422. okCount++
  423. }
  424. }
  425. fmt.Fprintf(w, "\n【已对齐】%d 条\n", okCount)
  426. for _, a := range r.Actions {
  427. if a.Kind == "ok" {
  428. fmt.Fprintf(w, " %-30s %s\n", a.Table+"."+a.Column, a.LiveDesc)
  429. }
  430. }
  431. probs := r.Problems()
  432. fmt.Fprintf(w, "\n【只报告、不执行】%d 条\n", len(probs))
  433. for _, a := range probs {
  434. fmt.Fprintf(w, " %-30s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc)
  435. }
  436. if len(r.Errors) > 0 {
  437. fmt.Fprintf(w, "\n【错误/告警】%d 条\n", len(r.Errors))
  438. for _, e := range r.Errors {
  439. fmt.Fprintf(w, " - %s\n", e)
  440. }
  441. }
  442. if r.Applied > 0 || r.Failed > 0 {
  443. fmt.Fprintf(w, "\n执行结果:成功 %d 条,失败 %d 条\n", r.Applied, r.Failed)
  444. }
  445. }