package fixture import ( "context" "database/sql" "fmt" "os" "regexp" "strings" ) // 本文件把 02-data-model/core-model-ddl.sql 安全地套到目标库上,规则只有三条: // // 1. **只执行 CREATE TABLE**(外加 DDL 自带的 INSERT IGNORE 种子与两个 SET)。 // `DROP TABLE IF EXISTS` 一律**丢弃**——DDL 文件是「干净起步」口径写的, // 而目标库已经有 28 张表(27 academic + t_campus),照抄会删库。 // 2. `CREATE TABLE x` 一律改写成 `CREATE TABLE IF NOT EXISTS x`, // 已存在的表(如 t_campus)跳过而不报错中断。 // 3. 绝不 ALTER / TRUNCATE / DROP 任何已有表;不碰 e2e_test。 // defaultCoreDDLPath 是 core DDL 的默认位置(可用 AC15_CORE_DDL 覆盖)。 const defaultCoreDDLPath = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model\core-model-ddl.sql` // DDLAction 描述一条语句的实际处置结果。 type DDLAction struct { Kind string // create / seed / set / skipped-drop Target string // 表名或说明 Executed bool ExistedBefore bool Err string } // DDLReport 是一次补齐操作的结果。 type DDLReport struct { Path string Actions []DDLAction TablesBefore int TablesAfter int Created []string Skipped []string } var ( createTableRe = regexp.MustCompile("(?i)^CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?`?([A-Za-z0-9_]+)`?") insertIntoRe = regexp.MustCompile("(?i)^INSERT\\s+(?:IGNORE\\s+)?INTO\\s+`?([A-Za-z0-9_]+)`?") backtickAfterCreate = regexp.MustCompile("(?i)^CREATE\\s+TABLE\\s+`") ) // SplitStatements 去掉纯注释行后按 `;` 切分成语句列表。 // // DDL 文件里的注释全部是**整行注释**(行首 `--`),没有行尾注释, // 因此「先剔行再切分」是安全的;一旦将来出现行尾注释,这里会立刻在解析阶段暴露(语句变非法)而不是悄悄漏建表。 func SplitStatements(src string) []string { var body strings.Builder for _, line := range strings.Split(src, "\n") { t := strings.TrimSpace(line) if t == "" || strings.HasPrefix(t, "--") { continue } body.WriteString(line) body.WriteString("\n") } var out []string for _, stmt := range strings.Split(body.String(), ";") { if s := strings.TrimSpace(stmt); s != "" { out = append(out, s) } } return out } // countTables 统计某个 schema 的表数。 func countTables(ctx context.Context, db *sql.DB, schema string) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ?`, schema).Scan(&n) return n, err } // tableExists 判断表是否已存在。 func tableExists(ctx context.Context, db *sql.DB, schema, table string) (bool, error) { var n int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ? AND table_name = ?`, schema, table).Scan(&n) return n > 0, err } // ApplyCoreDDL 把 core DDL 补齐到目标库。 // // ddlPath 为空时用 defaultCoreDDLPath(环境变量 AC15_CORE_DDL 优先)。 func ApplyCoreDDL(ctx context.Context, cfg Config, ddlPath string) (*DDLReport, error) { if ddlPath == "" { ddlPath = os.Getenv("AC15_CORE_DDL") } if ddlPath == "" { ddlPath = defaultCoreDDLPath } raw, err := os.ReadFile(ddlPath) if err != nil { return nil, fmt.Errorf("读取 core DDL 失败(%s):%w", ddlPath, err) } if !strings.Contains(strings.ToUpper(string(raw)), "CREATE TABLE") { return nil, fmt.Errorf("core DDL 文件内容异常(无 CREATE TABLE):%s", ddlPath) } db, err := cfg.OpenTarget() if err != nil { return nil, err } defer db.Close() before, err := countTables(ctx, db, cfg.TargetDB) if err != nil { return nil, err } rep := &DDLReport{Path: ddlPath, TablesBefore: before} for _, stmt := range SplitStatements(string(raw)) { upper := strings.ToUpper(stmt) // 1) DROP TABLE —— 一律跳过(目标是「补齐」,不是「重建」)。 if strings.HasPrefix(upper, "DROP TABLE") { rep.Actions = append(rep.Actions, DDLAction{Kind: "skipped-drop", Target: firstLine(stmt), Executed: false}) continue } // 2) SET ... —— 会话级开关,照执行(不改变任何数据)。 if strings.HasPrefix(upper, "SET ") { if _, err := db.ExecContext(ctx, stmt); err != nil { rep.Actions = append(rep.Actions, DDLAction{Kind: "set", Target: firstLine(stmt), Err: err.Error()}) return rep, fmt.Errorf("执行 SET 失败:%w", err) } rep.Actions = append(rep.Actions, DDLAction{Kind: "set", Target: firstLine(stmt), Executed: true}) continue } // 3) CREATE TABLE —— 改写成 IF NOT EXISTS,已存在则跳过。 if m := createTableRe.FindStringSubmatch(stmt); m != nil { table := m[1] exists, err := tableExists(ctx, db, cfg.TargetDB, table) if err != nil { return rep, err } if exists { rep.Skipped = append(rep.Skipped, table) rep.Actions = append(rep.Actions, DDLAction{Kind: "create", Target: table, Executed: false, ExistedBefore: true}) continue } fixed := stmt if backtickAfterCreate.MatchString(fixed) { idx := strings.Index(fixed, "`") fixed = fixed[:idx] + "IF NOT EXISTS " + fixed[idx:] } if _, err := db.ExecContext(ctx, fixed); err != nil { rep.Actions = append(rep.Actions, DDLAction{Kind: "create", Target: table, Err: err.Error()}) return rep, fmt.Errorf("建表 %s 失败:%w", table, err) } rep.Created = append(rep.Created, table) rep.Actions = append(rep.Actions, DDLAction{Kind: "create", Target: table, Executed: true}) continue } // 4) INSERT IGNORE 种子 —— 幂等(IGNORE + 主键冲突不动),照执行。 if m := insertIntoRe.FindStringSubmatch(stmt); m != nil { if _, err := db.ExecContext(ctx, stmt); err != nil { rep.Actions = append(rep.Actions, DDLAction{Kind: "seed", Target: m[1], Err: err.Error()}) return rep, fmt.Errorf("插入种子 %s 失败:%w", m[1], err) } rep.Actions = append(rep.Actions, DDLAction{Kind: "seed", Target: m[1], Executed: true}) continue } // 5) 其它语句一律拒绝执行(白名单策略:没预期到的语句不跑)。 rep.Actions = append(rep.Actions, DDLAction{Kind: "rejected", Target: firstLine(stmt), Executed: false, Err: "不在允许的语句类型(CREATE TABLE / INSERT IGNORE / SET)内"}) return rep, fmt.Errorf("DDL 中出现未预期的语句,拒绝执行:%s", firstLine(stmt)) } after, err := countTables(ctx, db, cfg.TargetDB) if err != nil { return rep, err } rep.TablesAfter = after return rep, nil } // firstLine 取语句首行(多为 60~80 字符),仅用于报告展示。 func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return strings.TrimSpace(s[:i]) } return strings.TrimSpace(s) }