|
@@ -0,0 +1,474 @@
|
|
|
|
|
+package fixture
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "database/sql"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "regexp"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的 created_at / updated_at
|
|
|
|
|
+// 对齐到 DDL 文件的口径(补 DEFAULT CURRENT_TIMESTAMP(3),updated_at 另有 ON UPDATE)。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
|
|
|
|
|
+// GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这两列 **NOT NULL 且无默认值**,
|
|
|
|
|
+// 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。
|
|
|
|
|
+//
|
|
|
|
|
+// 纪律(不可妥协):
|
|
|
|
|
+// 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移);
|
|
|
|
|
+// 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径
|
|
|
|
|
+// —— 这样除了这两个属性,其它一个字节都不动;
|
|
|
|
|
+// 3. NULL 性、类型、注释等**其它**差异只报告不执行,等人工定夺;
|
|
|
|
|
+// 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY),
|
|
|
|
|
+// 语法或属性不合法的语句在真正执行前就会暴露。
|
|
|
|
|
+
|
|
|
|
|
+// DDL 目录(与 core DDL 同一目录)。
|
|
|
|
|
+const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model`
|
|
|
|
|
+
|
|
|
|
|
+// 本工具只处理这两列。
|
|
|
|
|
+var alignedColumns = []string{"created_at", "updated_at"}
|
|
|
|
|
+
|
|
|
|
|
+// ddlColumn 是从 DDL 文件里解析出来的一列。
|
|
|
|
|
+type ddlColumn struct {
|
|
|
|
|
+ Table string
|
|
|
|
|
+ Column string
|
|
|
|
|
+ RawDef string // DDL 里的原始定义(不含列名、不含逗号)
|
|
|
|
|
+ Type string // 如 DATETIME(3)
|
|
|
|
|
+ NotNull bool
|
|
|
|
|
+ HasDefault bool
|
|
|
|
|
+ OnUpdate bool
|
|
|
|
|
+ Comment string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// liveColumn 是实库的一列(取自 information_schema)。
|
|
|
|
|
+type liveColumn struct {
|
|
|
|
|
+ Table string
|
|
|
|
|
+ Column string
|
|
|
|
|
+ ColumnType string // 如 datetime(3)
|
|
|
|
|
+ Nullable bool
|
|
|
|
|
+ Default sql.NullString
|
|
|
|
|
+ Extra string
|
|
|
|
|
+ Comment string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// AlignAction 是一张表一列的对齐判定。
|
|
|
|
|
+type AlignAction struct {
|
|
|
|
|
+ Table string
|
|
|
|
|
+ Column string
|
|
|
|
|
+ Kind string // ok / alignable / drift-nullable / drift-type / ddl-missing
|
|
|
|
|
+ LiveDesc string
|
|
|
|
|
+ DDLDefault string
|
|
|
|
|
+ SQL string // Kind=alignable 时的 ALTER 语句
|
|
|
|
|
+ Note string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// AlignReport 是整轮比对结果。
|
|
|
|
|
+type AlignReport struct {
|
|
|
|
|
+ ModelDir string
|
|
|
|
|
+ Actions []AlignAction
|
|
|
|
|
+ DryRunOK map[int]bool // Actions 下标 → dry-run 是否通过
|
|
|
|
|
+ Applied int
|
|
|
|
|
+ Failed int
|
|
|
|
|
+ Errors []string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Alignable 返回需要补 DEFAULT 的条目下标。
|
|
|
|
|
+func (r *AlignReport) Alignable() []int {
|
|
|
|
|
+ var out []int
|
|
|
|
|
+ for i, a := range r.Actions {
|
|
|
|
|
+ if a.Kind == "alignable" {
|
|
|
|
|
+ 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 文件,返回其中 created_at / updated_at 的列定义。
|
|
|
|
|
+func ParseDDLFile(path 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 len(alignedColumns) > 0 && !contains(alignedColumns, 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 contains(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) (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
|
|
|
|
|
+ }
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, `
|
|
|
|
|
+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 ('created_at','updated_at')`, schema)
|
|
|
|
|
+ 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()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// buildModifySQL 用**实库现有属性** + DDL 的 DEFAULT 拼出 MODIFY 子句。
|
|
|
|
|
+//
|
|
|
|
|
+// 只动两处:DEFAULT CURRENT_TIMESTAMP(3)(两列都有)、ON UPDATE CURRENT_TIMESTAMP(3)(仅 updated_at)。
|
|
|
|
|
+// type / NOT NULL / COMMENT 全部照抄实库,避免顺手改掉别的属性。
|
|
|
|
|
+func buildModifySQL(live liveColumn, ddl ddlColumn) string {
|
|
|
|
|
+ var b strings.Builder
|
|
|
|
|
+ b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType))
|
|
|
|
|
+ if !live.Nullable {
|
|
|
|
|
+ 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, "'", "''") + "'")
|
|
|
|
|
+ }
|
|
|
|
|
+ return b.String()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// describeLive 生成实库列的可读描述(报告用)。
|
|
|
|
|
+func describeLive(l liveColumn) string {
|
|
|
|
|
+ def := "NULL(无默认值)"
|
|
|
|
|
+ if l.Default.Valid {
|
|
|
|
|
+ def = l.Default.String
|
|
|
|
|
+ }
|
|
|
|
|
+ null := "NULL"
|
|
|
|
|
+ if !l.Nullable {
|
|
|
|
|
+ null = "NOT NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ return fmt.Sprintf("%s %s DEFAULT=%s extra=%q", l.ColumnType, null, def, l.Extra)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。
|
|
|
|
|
+func BuildAlignReport(ctx context.Context, cfg Config, modelDir string) (*AlignReport, error) {
|
|
|
|
|
+ if modelDir == "" {
|
|
|
|
|
+ modelDir = os.Getenv("AC15_MODEL_DIR")
|
|
|
|
|
+ }
|
|
|
|
|
+ if modelDir == "" {
|
|
|
|
|
+ modelDir = defaultModelDir
|
|
|
|
|
+ }
|
|
|
|
|
+ files := []string{
|
|
|
|
|
+ filepath.Join(modelDir, "core-model-ddl.sql"),
|
|
|
|
|
+ filepath.Join(modelDir, "academic-model-ddl.sql"),
|
|
|
|
|
+ }
|
|
|
|
|
+ var specs []ddlColumn
|
|
|
|
|
+ for _, f := range files {
|
|
|
|
|
+ cols, err := ParseDDLFile(f)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ specs = append(specs, cols...)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(specs) == 0 {
|
|
|
|
|
+ return nil, fmt.Errorf("DDL 里没有解析到 created_at/updated_at 定义(modelDir=%s)", modelDir)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ db, err := cfg.OpenTarget()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer db.Close()
|
|
|
|
|
+
|
|
|
|
|
+ live, err := loadLiveColumns(ctx, db, cfg.TargetDB)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rep := &AlignReport{ModelDir: modelDir, DryRunOK: map[int]bool{}}
|
|
|
|
|
+ for _, sp := range specs {
|
|
|
|
|
+ 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 有、实库没有 —— 属缺列,本工具不处理(需要 ALTER ADD COLUMN,另有风险)",
|
|
|
|
|
+ })
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv),
|
|
|
|
|
+ DDLDefault: "CURRENT_TIMESTAMP(3)"}
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case lv.Nullable != !sp.NotNull:
|
|
|
|
|
+ // NULL 性不一致 —— 只报告,等人工定夺(团队要求不顺手改)
|
|
|
|
|
+ a.Kind = "drift-nullable"
|
|
|
|
|
+ liveNull := "NOT NULL"
|
|
|
|
|
+ if lv.Nullable {
|
|
|
|
|
+ liveNull = "NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ ddlNull := "NOT NULL"
|
|
|
|
|
+ if !sp.NotNull {
|
|
|
|
|
+ ddlNull = "NULL"
|
|
|
|
|
+ }
|
|
|
|
|
+ a.Note = fmt.Sprintf("NULL 性不一致:实库 %s,DDL %s —— 只报告不改", liveNull, 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)
|
|
|
|
|
+ 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 是否合法且达到预期效果。
|
|
|
|
|
+//
|
|
|
|
|
+// 不动真实表:`CREATE TEMPORARY TABLE ... LIKE 真表` → 跑同一条 MODIFY → 复查默认值。
|
|
|
|
|
+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" {
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 把临时表名替换进 ALTER 语句(其余部分逐字符相同)
|
|
|
|
|
+ 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 {
|
|
|
|
|
+ // 临时表不出现在 information_schema 的情况(部分版本),退化为 SHOW COLUMNS
|
|
|
|
|
+ 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 {
|
|
|
|
|
+ if rep.Actions[i].Kind == "alignable" && !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" {
|
|
|
|
|
+ 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(drift-nullable 的除外)。
|
|
|
|
|
+func VerifyAlign(ctx context.Context, cfg Config) (total int, missing []string, err error) {
|
|
|
|
|
+ db, err := cfg.OpenTarget()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer db.Close()
|
|
|
|
|
+ live, err := loadLiveColumns(ctx, db, cfg.TargetDB)
|
|
|
|
|
+ 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\n", r.ModelDir)
|
|
|
|
|
+
|
|
|
|
|
+ alignable := r.Alignable()
|
|
|
|
|
+ fmt.Fprintf(w, "【待补 DEFAULT】%d 条(只 MODIFY 这两列,其余属性照抄实库)\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, " %-30s 实库:%s\n %s [%s]\n", a.Table+"."+a.Column, a.LiveDesc, a.SQL, dry)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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, " %-30s %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, " %-30s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|