| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- package fixture
- import (
- "context"
- "database/sql"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
- )
- // 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的时间列默认值对齐到 DDL 文件的口径。
- //
- // 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
- // GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这些列 **NOT NULL 且无默认值**,
- // 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。
- //
- // ⚠ 三类时间列的语义**完全不同**,不能一视同仁:
- // - `created_at`:**审计**——这行什么时候入库的。取当前时间合理。
- // - `create_time`:**业务**——这笔钱/这节课什么时候发生的。取当前时间 = **静默写错**。
- // - `update_time`:**业务**——最后修改时间(仅 `t_courses`,DDL 本身就是 DEFAULT NULL)。
- // 补默认只解决"插入漏传则报错",**不解决"取到当前时间就错了"**,所以必须配 create_time 断言
- // (见 run_marker.go 的 countRowsAfter)。
- //
- // 纪律(不可妥协):
- // 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移);
- // 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径
- // —— `FixNullable` 打开时才额外按 DDL 改 NULL 性;
- // 3. NULL 性、类型、注释等差异**默认只报告不执行**,必须显式开 `FixNullable` 才动;
- // 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY)。
- //
- // 关于 ALGORITHM:默认在语句末尾带 `, ALGORITHM=INSTANT`(显式声明"只改元数据")。
- // 但 **MySQL 8.0.23 不支持对 "MODIFY 改默认值" 用 INSTANT/INPLACE**(实测 Error 1845),
- // 要 8.0.29+ 才支持;在旧版本上必须显式降级为 `Algorithm=none` 才能执行(见报告)。
- // defaultModelDir 是 DDL 目录。
- const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model`
- // ColumnsAudit 是审计类时间列(取默认值合理)。
- var ColumnsAudit = []string{"created_at", "updated_at"}
- // ColumnsBusiness 是业务类时间列(取默认值 = 静默写错,必须配断言)。
- var ColumnsBusiness = []string{"create_time"}
- // AlignOptions 是对齐参数。
- type AlignOptions struct {
- ModelDir string // DDL 目录
- Columns []string // 参与对齐的列名(默认 created_at/updated_at)
- Algorithm string // instant / inplace / copy / none
- FixNullable bool // 是否按 DDL 修正 NULL 性(默认只报告)
- OnlyTables []string // 只处理这些表(空=全部)
- }
- // ddlColumn 是从 DDL 文件里解析出来的一列。
- type ddlColumn struct {
- Table string
- Column string
- RawDef string
- Type string
- NotNull bool
- HasDefault bool
- OnUpdate bool
- Comment string
- }
- // liveColumn 是实库的一列(取自 information_schema)。
- type liveColumn struct {
- Table string
- Column string
- ColumnType string
- Nullable bool
- Default sql.NullString
- Extra string
- Comment string
- }
- // AlignAction 是一张表一列的对齐判定。
- type AlignAction struct {
- Table string
- Column string
- Kind string // ok / alignable / alignable-nullable / drift-nullable / ddl-no-default / ddl-missing
- LiveDesc string
- DDLDefault string
- SQL string
- Note string
- }
- // AlignReport 是整轮比对结果。
- type AlignReport struct {
- ModelDir string
- Options AlignOptions
- Actions []AlignAction
- DryRunOK map[int]bool
- Applied int
- Failed int
- Errors []string
- }
- // Alignable 返回需要执行 ALTER 的条目下标。
- func (r *AlignReport) Alignable() []int {
- var out []int
- for i, a := range r.Actions {
- if a.Kind == "alignable" || a.Kind == "alignable-nullable" {
- out = append(out, i)
- }
- }
- return out
- }
- // Problems 返回「只报告不执行」的条目。
- func (r *AlignReport) Problems() []AlignAction {
- var out []AlignAction
- for _, a := range r.Actions {
- if strings.HasPrefix(a.Kind, "drift") || a.Kind == "ddl-missing" {
- out = append(out, a)
- }
- }
- return out
- }
- var (
- createTableLine = regexp.MustCompile("^\\s*CREATE\\s+TABLE\\s+`([A-Za-z0-9_]+)`")
- columnLine = regexp.MustCompile("^\\s*`([A-Za-z0-9_]+)`\\s+(.+?),?\\s*$")
- defaultExpr = regexp.MustCompile(`(?i)\bDEFAULT\s+(CURRENT_TIMESTAMP(?:\(\d*\))?|'[^']*'|[^\s]+)`)
- commentExpr = regexp.MustCompile(`(?i)\bCOMMENT\s+'((?:[^']|'')*)'`)
- firstToken = regexp.MustCompile(`^[A-Za-z]+(?:\([0-9,]+\))?`)
- )
- // ParseDDLFile 解析一个 DDL 文件,返回指定列的列定义。
- func ParseDDLFile(path string, want []string) ([]ddlColumn, error) {
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, fmt.Errorf("读 DDL 失败(%s):%w", path, err)
- }
- var out []ddlColumn
- curTable := ""
- for _, line := range strings.Split(string(raw), "\n") {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" || strings.HasPrefix(trimmed, "--") {
- continue
- }
- if m := createTableLine.FindStringSubmatch(trimmed); m != nil {
- curTable = m[1]
- continue
- }
- if curTable == "" {
- continue
- }
- if strings.HasPrefix(trimmed, ")") {
- curTable = ""
- continue
- }
- m := columnLine.FindStringSubmatch(line)
- if m == nil {
- continue
- }
- col := m[1]
- if !containsString(want, col) {
- continue
- }
- def := strings.TrimSuffix(strings.TrimSpace(m[2]), ",")
- c := ddlColumn{Table: curTable, Column: col, RawDef: def}
- if t := firstToken.FindString(def); t != "" {
- c.Type = t
- }
- c.NotNull = strings.Contains(strings.ToUpper(def), "NOT NULL")
- if dm := defaultExpr.FindStringSubmatch(def); dm != nil {
- c.HasDefault = true
- }
- c.OnUpdate = strings.Contains(strings.ToUpper(def), "ON UPDATE CURRENT_TIMESTAMP")
- if cm := commentExpr.FindStringSubmatch(def); cm != nil {
- c.Comment = strings.ReplaceAll(cm[1], "''", "'")
- }
- out = append(out, c)
- }
- return out, nil
- }
- func containsString(ss []string, s string) bool {
- for _, v := range ss {
- if v == s {
- return true
- }
- }
- return false
- }
- // loadLiveColumns 从 information_schema 读实库指定列的属性。
- func loadLiveColumns(ctx context.Context, db *sql.DB, schema string, columns []string) (map[string]liveColumn, error) {
- // 关掉 information_schema 统计缓存,拿即时值(同 batch2 的 AUTO_INCREMENT 坑)。
- if _, err := db.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
- return nil, err
- }
- ph := make([]string, len(columns))
- args := []any{schema}
- for i, c := range columns {
- ph[i] = "?"
- args = append(args, c)
- }
- q := fmt.Sprintf(`
- SELECT table_name, column_name, column_type, is_nullable, column_default, extra, column_comment
- FROM information_schema.columns
- WHERE table_schema = ? AND column_name IN (%s)`, strings.Join(ph, ","))
- rows, err := db.QueryContext(ctx, q, args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- out := map[string]liveColumn{}
- for rows.Next() {
- var c liveColumn
- var nullable string
- if err := rows.Scan(&c.Table, &c.Column, &c.ColumnType, &nullable, &c.Default, &c.Extra, &c.Comment); err != nil {
- return nil, err
- }
- c.Nullable = strings.EqualFold(nullable, "YES")
- out[c.Table+"."+c.Column] = c
- }
- return out, rows.Err()
- }
- // algorithmSuffix 生成 ALGORITHM 子句(none 时返回空串)。
- func algorithmSuffix(algorithm string) string {
- switch strings.ToLower(strings.TrimSpace(algorithm)) {
- case "instant":
- return ", ALGORITHM=INSTANT"
- case "inplace":
- return ", ALGORITHM=INPLACE, LOCK=NONE"
- case "copy":
- return ", ALGORITHM=COPY"
- default: // "", "none"
- return ""
- }
- }
- // buildModifySQL 用**实库现有属性** + DDL 的 DEFAULT 拼出 MODIFY 语句。
- func buildModifySQL(live liveColumn, ddl ddlColumn, fixNullable bool, algorithm string) string {
- var b strings.Builder
- b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType))
- notNull := !live.Nullable
- if fixNullable && ddl.NotNull {
- notNull = true
- }
- if notNull {
- b.WriteString(" NOT NULL")
- }
- b.WriteString(" DEFAULT CURRENT_TIMESTAMP(3)")
- if ddl.OnUpdate {
- b.WriteString(" ON UPDATE CURRENT_TIMESTAMP(3)")
- }
- if live.Comment != "" {
- b.WriteString(" COMMENT '" + strings.ReplaceAll(live.Comment, "'", "''") + "'")
- }
- b.WriteString(algorithmSuffix(algorithm))
- return b.String()
- }
- func describeLive(l liveColumn) string {
- def := "NULL(无默认值)"
- if l.Default.Valid {
- def = l.Default.String
- }
- null := "NOT NULL"
- if l.Nullable {
- null = "NULL"
- }
- extra := ""
- if l.Extra != "" {
- extra = fmt.Sprintf(" extra=%q", l.Extra)
- }
- return fmt.Sprintf("%s %s DEFAULT=%s%s", l.ColumnType, null, def, extra)
- }
- // BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。
- func BuildAlignReport(ctx context.Context, cfg Config, opts AlignOptions) (*AlignReport, error) {
- if opts.ModelDir == "" {
- opts.ModelDir = os.Getenv("AC15_MODEL_DIR")
- }
- if opts.ModelDir == "" {
- opts.ModelDir = defaultModelDir
- }
- if len(opts.Columns) == 0 {
- opts.Columns = ColumnsAudit
- }
- files := []string{
- filepath.Join(opts.ModelDir, "core-model-ddl.sql"),
- filepath.Join(opts.ModelDir, "academic-model-ddl.sql"),
- }
- var specs []ddlColumn
- for _, f := range files {
- cols, err := ParseDDLFile(f, opts.Columns)
- if err != nil {
- return nil, err
- }
- specs = append(specs, cols...)
- }
- if len(specs) == 0 {
- return nil, fmt.Errorf("DDL 里没有解析到 %v 的定义(modelDir=%s)", opts.Columns, opts.ModelDir)
- }
- db, err := cfg.OpenTarget()
- if err != nil {
- return nil, err
- }
- defer db.Close()
- live, err := loadLiveColumns(ctx, db, cfg.TargetDB, opts.Columns)
- if err != nil {
- return nil, err
- }
- rep := &AlignReport{ModelDir: opts.ModelDir, Options: opts, DryRunOK: map[int]bool{}}
- for _, sp := range specs {
- if len(opts.OnlyTables) > 0 && !containsString(opts.OnlyTables, sp.Table) {
- continue
- }
- key := sp.Table + "." + sp.Column
- lv, ok := live[key]
- if !ok {
- rep.Actions = append(rep.Actions, AlignAction{
- Table: sp.Table, Column: sp.Column, Kind: "ddl-missing",
- LiveDesc: "<实库无此列>", Note: "DDL 有、实库没有 —— 属缺列,本工具不处理",
- })
- continue
- }
- a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv),
- DDLDefault: "CURRENT_TIMESTAMP(3)"}
- ddlNull := "NOT NULL"
- if !sp.NotNull {
- ddlNull = "NULL"
- }
- switch {
- case lv.Nullable != !sp.NotNull:
- if opts.FixNullable && sp.NotNull && sp.HasDefault {
- a.Kind = "alignable-nullable"
- a.SQL = buildModifySQL(lv, sp, true, opts.Algorithm)
- a.Note = fmt.Sprintf("NULL 性不一致(实库可空、DDL %s)→ 按 DDL 一并修正 + 补 DEFAULT", ddlNull)
- } else {
- a.Kind = "drift-nullable"
- a.Note = fmt.Sprintf("NULL 性不一致:实库可空,DDL %s —— 未开 FixNullable,只报告不改", ddlNull)
- }
- case !sp.HasDefault:
- a.Kind = "ddl-no-default"
- a.Note = "DDL 该列本身没有 DEFAULT CURRENT_TIMESTAMP(3),无需对齐"
- case lv.Default.Valid:
- a.Kind = "ok"
- a.Note = "已有默认值(已对齐)"
- default:
- a.Kind = "alignable"
- a.SQL = buildModifySQL(lv, sp, false, opts.Algorithm)
- a.Note = "实库 DEFAULT 为 NULL → 补 DEFAULT CURRENT_TIMESTAMP(3)" +
- map[bool]string{true: ",并补 ON UPDATE CURRENT_TIMESTAMP(3)", false: ""}[sp.OnUpdate]
- }
- rep.Actions = append(rep.Actions, a)
- }
- sort.Slice(rep.Actions, func(i, j int) bool {
- if rep.Actions[i].Table != rep.Actions[j].Table {
- return rep.Actions[i].Table < rep.Actions[j].Table
- }
- return rep.Actions[i].Column < rep.Actions[j].Column
- })
- return rep, nil
- }
- // DryRunAlign 在**会话临时表**上验证每条 ALTER 是否合法且达到预期效果。
- func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
- db, err := cfg.OpenTarget()
- if err != nil {
- return err
- }
- defer db.Close()
- conn, err := db.Conn(ctx)
- if err != nil {
- return err
- }
- defer conn.Close()
- for i := range rep.Actions {
- a := &rep.Actions[i]
- if a.Kind != "alignable" && a.Kind != "alignable-nullable" {
- continue
- }
- tmp := "tmp_align_" + a.Table
- if _, err := conn.ExecContext(ctx,
- fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE `%s`", tmp, a.Table)); err != nil {
- return fmt.Errorf("dry-run 建临时表失败(%s):%w", a.Table, err)
- }
- stmt := strings.Replace(a.SQL, "ALTER TABLE `"+a.Table+"`", "ALTER TABLE `"+tmp+"`", 1)
- if _, err := conn.ExecContext(ctx, stmt); err != nil {
- rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 失败 %s.%s:%v", a.Table, a.Column, err))
- rep.DryRunOK[i] = false
- continue
- }
- var def sql.NullString
- var extra string
- if err := conn.QueryRowContext(ctx, `
- SELECT column_default, extra FROM information_schema.columns
- WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, tmp, a.Column).Scan(&def, &extra); err != nil {
- rows, err2 := conn.QueryContext(ctx, fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE '%s'", tmp, a.Column))
- if err2 != nil {
- rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 复查失败 %s.%s:%v", a.Table, a.Column, err2))
- rep.DryRunOK[i] = false
- continue
- }
- for rows.Next() {
- var field, typ, null, key, d, ex sql.NullString
- if err := rows.Scan(&field, &typ, &null, &key, &d, &ex); err != nil {
- rows.Close()
- return err
- }
- def, extra = d, ex.String
- }
- rows.Close()
- }
- rep.DryRunOK[i] = def.Valid && strings.Contains(strings.ToLower(def.String), "current_timestamp")
- if !rep.DryRunOK[i] {
- rep.Errors = append(rep.Errors, fmt.Sprintf(
- "dry-run 复查不符 %s.%s:default=%v extra=%q(期望 CURRENT_TIMESTAMP)", a.Table, a.Column, def, extra))
- }
- }
- return nil
- }
- // ApplyAlign 执行对齐(只在 dry-run 全部通过时执行)。
- func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
- for i := range rep.Actions {
- k := rep.Actions[i].Kind
- if (k == "alignable" || k == "alignable-nullable") && !rep.DryRunOK[i] {
- return fmt.Errorf("存在未通过 dry-run 的语句,拒绝执行(%s.%s)",
- rep.Actions[i].Table, rep.Actions[i].Column)
- }
- }
- db, err := cfg.OpenTarget()
- if err != nil {
- return err
- }
- defer db.Close()
- for i := range rep.Actions {
- a := &rep.Actions[i]
- if a.Kind != "alignable" && a.Kind != "alignable-nullable" {
- continue
- }
- if _, err := db.ExecContext(ctx, a.SQL); err != nil {
- rep.Failed++
- rep.Errors = append(rep.Errors, fmt.Sprintf("执行失败 %s.%s:%v", a.Table, a.Column, err))
- continue
- }
- rep.Applied++
- }
- return nil
- }
- // VerifyAlign 复核:指定列的 COLUMN_DEFAULT 必须全部非 NULL。
- func VerifyAlign(ctx context.Context, cfg Config, columns []string) (total int, missing []string, err error) {
- if len(columns) == 0 {
- columns = ColumnsAudit
- }
- db, err := cfg.OpenTarget()
- if err != nil {
- return 0, nil, err
- }
- defer db.Close()
- live, err := loadLiveColumns(ctx, db, cfg.TargetDB, columns)
- if err != nil {
- return 0, nil, err
- }
- for key, c := range live {
- total++
- if !c.Default.Valid {
- missing = append(missing, fmt.Sprintf("%s(%s)", key, c.ColumnType))
- }
- }
- sort.Strings(missing)
- return total, missing, nil
- }
- // PrintAlignReport 打印对齐清单。
- func PrintAlignReport(w io.Writer, r *AlignReport) {
- fmt.Fprintf(w, "== 结构对齐清单(基准:DDL 文件)==\n")
- fmt.Fprintf(w, "DDL 目录:%s\n", r.ModelDir)
- algo := r.Options.Algorithm
- if algo == "" {
- algo = "none"
- }
- fmt.Fprintf(w, "列范围:%v 算法子句:%s FixNullable=%v\n\n", r.Options.Columns, algo, r.Options.FixNullable)
- alignable := r.Alignable()
- fmt.Fprintf(w, "【待执行】%d 条\n", len(alignable))
- for _, i := range alignable {
- a := r.Actions[i]
- dry := "未验证"
- if ok, has := r.DryRunOK[i]; has {
- if ok {
- dry = "dry-run ✔"
- } else {
- dry = "dry-run ✘"
- }
- }
- fmt.Fprintf(w, " %-32s [%s] %s\n %s\n", a.Table+"."+a.Column, a.Kind, dry, a.SQL)
- }
- okCount := 0
- for _, a := range r.Actions {
- if a.Kind == "ok" {
- okCount++
- }
- }
- fmt.Fprintf(w, "\n【已对齐】%d 条\n", okCount)
- for _, a := range r.Actions {
- if a.Kind == "ok" {
- fmt.Fprintf(w, " %-32s %s\n", a.Table+"."+a.Column, a.LiveDesc)
- }
- }
- probs := r.Problems()
- fmt.Fprintf(w, "\n【只报告、不执行】%d 条\n", len(probs))
- for _, a := range probs {
- fmt.Fprintf(w, " %-32s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc)
- }
- skipped := 0
- for _, a := range r.Actions {
- if a.Kind == "ddl-no-default" {
- skipped++
- }
- }
- if skipped > 0 {
- fmt.Fprintf(w, "\n【DDL 无默认值、无需动】%d 条\n", skipped)
- }
- if len(r.Errors) > 0 {
- fmt.Fprintf(w, "\n【错误/告警】%d 条\n", len(r.Errors))
- for _, e := range r.Errors {
- fmt.Fprintf(w, " - %s\n", e)
- }
- }
- if r.Applied > 0 || r.Failed > 0 {
- fmt.Fprintf(w, "\n执行结果:成功 %d 条,失败 %d 条\n", r.Applied, r.Failed)
- }
- }
|