| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- 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)
- }
|