|
@@ -12,33 +12,54 @@ import (
|
|
|
"strings"
|
|
"strings"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-// 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的 created_at / updated_at
|
|
|
|
|
-// 对齐到 DDL 文件的口径(补 DEFAULT CURRENT_TIMESTAMP(3),updated_at 另有 ON UPDATE)。
|
|
|
|
|
|
|
+// 本文件是**结构对齐工具**:把 dev 库 `yuansiwei_2026` 的时间列默认值对齐到 DDL 文件的口径。
|
|
|
//
|
|
//
|
|
|
// 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
|
|
// 为什么需要它:目标库是"部分表由 DDL 建、部分表由 GORM AutoMigrate 建"的混合体。
|
|
|
-// GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这两列 **NOT NULL 且无默认值**,
|
|
|
|
|
|
|
+// GORM 不处理 `DEFAULT CURRENT_TIMESTAMP(3)`,所以它建的表这些列 **NOT NULL 且无默认值**,
|
|
|
// 裸 SQL 插入会在随机某几张表上直接 `Error 1364`。
|
|
// 裸 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 建的索引/字符集一起改掉,产生新漂移);
|
|
// 1. **只 ALTER ... MODIFY**,绝不 DROP 重建(重建会把 GORM 建的索引/字符集一起改掉,产生新漂移);
|
|
|
// 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径
|
|
// 2. MODIFY 子句**用实库现有属性**(type / NULL 性 / 注释),只把 DEFAULT 与 ON UPDATE 换成 DDL 口径
|
|
|
-// —— 这样除了这两个属性,其它一个字节都不动;
|
|
|
|
|
-// 3. NULL 性、类型、注释等**其它**差异只报告不执行,等人工定夺;
|
|
|
|
|
-// 4. 每条 ALTER 先在会话临时表上 dry-run(`CREATE TEMPORARY TABLE ... LIKE` + 同一条 MODIFY),
|
|
|
|
|
-// 语法或属性不合法的语句在真正执行前就会暴露。
|
|
|
|
|
|
|
+// —— `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` 才能执行(见报告)。
|
|
|
|
|
|
|
|
-// DDL 目录(与 core DDL 同一目录)。
|
|
|
|
|
|
|
+// defaultModelDir 是 DDL 目录。
|
|
|
const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model`
|
|
const defaultModelDir = `C:\Users\Run\WorkBuddy\项目合并\unified\02-data-model`
|
|
|
|
|
|
|
|
-// 本工具只处理这两列。
|
|
|
|
|
-var alignedColumns = []string{"created_at", "updated_at"}
|
|
|
|
|
|
|
+// 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 文件里解析出来的一列。
|
|
// ddlColumn 是从 DDL 文件里解析出来的一列。
|
|
|
type ddlColumn struct {
|
|
type ddlColumn struct {
|
|
|
Table string
|
|
Table string
|
|
|
Column string
|
|
Column string
|
|
|
- RawDef string // DDL 里的原始定义(不含列名、不含逗号)
|
|
|
|
|
- Type string // 如 DATETIME(3)
|
|
|
|
|
|
|
+ RawDef string
|
|
|
|
|
+ Type string
|
|
|
NotNull bool
|
|
NotNull bool
|
|
|
HasDefault bool
|
|
HasDefault bool
|
|
|
OnUpdate bool
|
|
OnUpdate bool
|
|
@@ -49,7 +70,7 @@ type ddlColumn struct {
|
|
|
type liveColumn struct {
|
|
type liveColumn struct {
|
|
|
Table string
|
|
Table string
|
|
|
Column string
|
|
Column string
|
|
|
- ColumnType string // 如 datetime(3)
|
|
|
|
|
|
|
+ ColumnType string
|
|
|
Nullable bool
|
|
Nullable bool
|
|
|
Default sql.NullString
|
|
Default sql.NullString
|
|
|
Extra string
|
|
Extra string
|
|
@@ -60,28 +81,29 @@ type liveColumn struct {
|
|
|
type AlignAction struct {
|
|
type AlignAction struct {
|
|
|
Table string
|
|
Table string
|
|
|
Column string
|
|
Column string
|
|
|
- Kind string // ok / alignable / drift-nullable / drift-type / ddl-missing
|
|
|
|
|
|
|
+ Kind string // ok / alignable / alignable-nullable / drift-nullable / ddl-no-default / ddl-missing
|
|
|
LiveDesc string
|
|
LiveDesc string
|
|
|
DDLDefault string
|
|
DDLDefault string
|
|
|
- SQL string // Kind=alignable 时的 ALTER 语句
|
|
|
|
|
|
|
+ SQL string
|
|
|
Note string
|
|
Note string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// AlignReport 是整轮比对结果。
|
|
// AlignReport 是整轮比对结果。
|
|
|
type AlignReport struct {
|
|
type AlignReport struct {
|
|
|
ModelDir string
|
|
ModelDir string
|
|
|
|
|
+ Options AlignOptions
|
|
|
Actions []AlignAction
|
|
Actions []AlignAction
|
|
|
- DryRunOK map[int]bool // Actions 下标 → dry-run 是否通过
|
|
|
|
|
|
|
+ DryRunOK map[int]bool
|
|
|
Applied int
|
|
Applied int
|
|
|
Failed int
|
|
Failed int
|
|
|
Errors []string
|
|
Errors []string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Alignable 返回需要补 DEFAULT 的条目下标。
|
|
|
|
|
|
|
+// Alignable 返回需要执行 ALTER 的条目下标。
|
|
|
func (r *AlignReport) Alignable() []int {
|
|
func (r *AlignReport) Alignable() []int {
|
|
|
var out []int
|
|
var out []int
|
|
|
for i, a := range r.Actions {
|
|
for i, a := range r.Actions {
|
|
|
- if a.Kind == "alignable" {
|
|
|
|
|
|
|
+ if a.Kind == "alignable" || a.Kind == "alignable-nullable" {
|
|
|
out = append(out, i)
|
|
out = append(out, i)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -107,8 +129,8 @@ var (
|
|
|
firstToken = regexp.MustCompile(`^[A-Za-z]+(?:\([0-9,]+\))?`)
|
|
firstToken = regexp.MustCompile(`^[A-Za-z]+(?:\([0-9,]+\))?`)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-// ParseDDLFile 解析一个 DDL 文件,返回其中 created_at / updated_at 的列定义。
|
|
|
|
|
-func ParseDDLFile(path string) ([]ddlColumn, error) {
|
|
|
|
|
|
|
+// ParseDDLFile 解析一个 DDL 文件,返回指定列的列定义。
|
|
|
|
|
+func ParseDDLFile(path string, want []string) ([]ddlColumn, error) {
|
|
|
raw, err := os.ReadFile(path)
|
|
raw, err := os.ReadFile(path)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, fmt.Errorf("读 DDL 失败(%s):%w", path, err)
|
|
return nil, fmt.Errorf("读 DDL 失败(%s):%w", path, err)
|
|
@@ -136,7 +158,7 @@ func ParseDDLFile(path string) ([]ddlColumn, error) {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
col := m[1]
|
|
col := m[1]
|
|
|
- if len(alignedColumns) > 0 && !contains(alignedColumns, col) {
|
|
|
|
|
|
|
+ if !containsString(want, col) {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
def := strings.TrimSuffix(strings.TrimSpace(m[2]), ",")
|
|
def := strings.TrimSuffix(strings.TrimSpace(m[2]), ",")
|
|
@@ -157,7 +179,7 @@ func ParseDDLFile(path string) ([]ddlColumn, error) {
|
|
|
return out, nil
|
|
return out, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func contains(ss []string, s string) bool {
|
|
|
|
|
|
|
+func containsString(ss []string, s string) bool {
|
|
|
for _, v := range ss {
|
|
for _, v := range ss {
|
|
|
if v == s {
|
|
if v == s {
|
|
|
return true
|
|
return true
|
|
@@ -166,17 +188,23 @@ func contains(ss []string, s string) bool {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// loadLiveColumns 从 information_schema 读实库这两列的属性。
|
|
|
|
|
-func loadLiveColumns(ctx context.Context, db *sql.DB, schema string) (map[string]liveColumn, error) {
|
|
|
|
|
|
|
+// loadLiveColumns 从 information_schema 读实库指定列的属性。
|
|
|
|
|
+func loadLiveColumns(ctx context.Context, db *sql.DB, schema string, columns []string) (map[string]liveColumn, error) {
|
|
|
// 关掉 information_schema 统计缓存,拿即时值(同 batch2 的 AUTO_INCREMENT 坑)。
|
|
// 关掉 information_schema 统计缓存,拿即时值(同 batch2 的 AUTO_INCREMENT 坑)。
|
|
|
if _, err := db.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
|
|
if _, err := db.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
|
- rows, err := db.QueryContext(ctx, `
|
|
|
|
|
|
|
+ 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
|
|
SELECT table_name, column_name, column_type, is_nullable, column_default, extra, column_comment
|
|
|
FROM information_schema.columns
|
|
FROM information_schema.columns
|
|
|
- WHERE table_schema = ?
|
|
|
|
|
- AND column_name IN ('created_at','updated_at')`, schema)
|
|
|
|
|
|
|
+ WHERE table_schema = ? AND column_name IN (%s)`, strings.Join(ph, ","))
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, q, args...)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
@@ -194,14 +222,29 @@ SELECT table_name, column_name, column_type, is_nullable, column_default, extra,
|
|
|
return out, rows.Err()
|
|
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 {
|
|
|
|
|
|
|
+// 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
|
|
var b strings.Builder
|
|
|
b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType))
|
|
b.WriteString(fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN `%s` %s", live.Table, live.Column, live.ColumnType))
|
|
|
- if !live.Nullable {
|
|
|
|
|
|
|
+ notNull := !live.Nullable
|
|
|
|
|
+ if fixNullable && ddl.NotNull {
|
|
|
|
|
+ notNull = true
|
|
|
|
|
+ }
|
|
|
|
|
+ if notNull {
|
|
|
b.WriteString(" NOT NULL")
|
|
b.WriteString(" NOT NULL")
|
|
|
}
|
|
}
|
|
|
b.WriteString(" DEFAULT CURRENT_TIMESTAMP(3)")
|
|
b.WriteString(" DEFAULT CURRENT_TIMESTAMP(3)")
|
|
@@ -211,44 +254,51 @@ func buildModifySQL(live liveColumn, ddl ddlColumn) string {
|
|
|
if live.Comment != "" {
|
|
if live.Comment != "" {
|
|
|
b.WriteString(" COMMENT '" + strings.ReplaceAll(live.Comment, "'", "''") + "'")
|
|
b.WriteString(" COMMENT '" + strings.ReplaceAll(live.Comment, "'", "''") + "'")
|
|
|
}
|
|
}
|
|
|
|
|
+ b.WriteString(algorithmSuffix(algorithm))
|
|
|
return b.String()
|
|
return b.String()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// describeLive 生成实库列的可读描述(报告用)。
|
|
|
|
|
func describeLive(l liveColumn) string {
|
|
func describeLive(l liveColumn) string {
|
|
|
def := "NULL(无默认值)"
|
|
def := "NULL(无默认值)"
|
|
|
if l.Default.Valid {
|
|
if l.Default.Valid {
|
|
|
def = l.Default.String
|
|
def = l.Default.String
|
|
|
}
|
|
}
|
|
|
- null := "NULL"
|
|
|
|
|
- if !l.Nullable {
|
|
|
|
|
- null = "NOT NULL"
|
|
|
|
|
|
|
+ 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 extra=%q", l.ColumnType, null, def, l.Extra)
|
|
|
|
|
|
|
+ return fmt.Sprintf("%s %s DEFAULT=%s%s", l.ColumnType, null, def, extra)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。
|
|
// BuildAlignReport 逐表比对 DDL 与实库,产出对齐清单(不执行)。
|
|
|
-func BuildAlignReport(ctx context.Context, cfg Config, modelDir string) (*AlignReport, error) {
|
|
|
|
|
- if modelDir == "" {
|
|
|
|
|
- modelDir = os.Getenv("AC15_MODEL_DIR")
|
|
|
|
|
|
|
+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 modelDir == "" {
|
|
|
|
|
- modelDir = defaultModelDir
|
|
|
|
|
|
|
+ if len(opts.Columns) == 0 {
|
|
|
|
|
+ opts.Columns = ColumnsAudit
|
|
|
}
|
|
}
|
|
|
files := []string{
|
|
files := []string{
|
|
|
- filepath.Join(modelDir, "core-model-ddl.sql"),
|
|
|
|
|
- filepath.Join(modelDir, "academic-model-ddl.sql"),
|
|
|
|
|
|
|
+ filepath.Join(opts.ModelDir, "core-model-ddl.sql"),
|
|
|
|
|
+ filepath.Join(opts.ModelDir, "academic-model-ddl.sql"),
|
|
|
}
|
|
}
|
|
|
var specs []ddlColumn
|
|
var specs []ddlColumn
|
|
|
for _, f := range files {
|
|
for _, f := range files {
|
|
|
- cols, err := ParseDDLFile(f)
|
|
|
|
|
|
|
+ cols, err := ParseDDLFile(f, opts.Columns)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
|
specs = append(specs, cols...)
|
|
specs = append(specs, cols...)
|
|
|
}
|
|
}
|
|
|
if len(specs) == 0 {
|
|
if len(specs) == 0 {
|
|
|
- return nil, fmt.Errorf("DDL 里没有解析到 created_at/updated_at 定义(modelDir=%s)", modelDir)
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("DDL 里没有解析到 %v 的定义(modelDir=%s)", opts.Columns, opts.ModelDir)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
db, err := cfg.OpenTarget()
|
|
db, err := cfg.OpenTarget()
|
|
@@ -257,47 +307,51 @@ func BuildAlignReport(ctx context.Context, cfg Config, modelDir string) (*AlignR
|
|
|
}
|
|
}
|
|
|
defer db.Close()
|
|
defer db.Close()
|
|
|
|
|
|
|
|
- live, err := loadLiveColumns(ctx, db, cfg.TargetDB)
|
|
|
|
|
|
|
+ live, err := loadLiveColumns(ctx, db, cfg.TargetDB, opts.Columns)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- rep := &AlignReport{ModelDir: modelDir, DryRunOK: map[int]bool{}}
|
|
|
|
|
|
|
+ rep := &AlignReport{ModelDir: opts.ModelDir, Options: opts, DryRunOK: map[int]bool{}}
|
|
|
for _, sp := range specs {
|
|
for _, sp := range specs {
|
|
|
|
|
+ if len(opts.OnlyTables) > 0 && !containsString(opts.OnlyTables, sp.Table) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
key := sp.Table + "." + sp.Column
|
|
key := sp.Table + "." + sp.Column
|
|
|
lv, ok := live[key]
|
|
lv, ok := live[key]
|
|
|
if !ok {
|
|
if !ok {
|
|
|
rep.Actions = append(rep.Actions, AlignAction{
|
|
rep.Actions = append(rep.Actions, AlignAction{
|
|
|
Table: sp.Table, Column: sp.Column, Kind: "ddl-missing",
|
|
Table: sp.Table, Column: sp.Column, Kind: "ddl-missing",
|
|
|
- LiveDesc: "<实库无此列>", Note: "DDL 有、实库没有 —— 属缺列,本工具不处理(需要 ALTER ADD COLUMN,另有风险)",
|
|
|
|
|
|
|
+ LiveDesc: "<实库无此列>", Note: "DDL 有、实库没有 —— 属缺列,本工具不处理",
|
|
|
})
|
|
})
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv),
|
|
a := AlignAction{Table: sp.Table, Column: sp.Column, LiveDesc: describeLive(lv),
|
|
|
DDLDefault: "CURRENT_TIMESTAMP(3)"}
|
|
DDLDefault: "CURRENT_TIMESTAMP(3)"}
|
|
|
|
|
+ ddlNull := "NOT NULL"
|
|
|
|
|
+ if !sp.NotNull {
|
|
|
|
|
+ ddlNull = "NULL"
|
|
|
|
|
+ }
|
|
|
switch {
|
|
switch {
|
|
|
case lv.Nullable != !sp.NotNull:
|
|
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"
|
|
|
|
|
|
|
+ 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)
|
|
|
}
|
|
}
|
|
|
- a.Note = fmt.Sprintf("NULL 性不一致:实库 %s,DDL %s —— 只报告不改", liveNull, ddlNull)
|
|
|
|
|
case !sp.HasDefault:
|
|
case !sp.HasDefault:
|
|
|
a.Kind = "ddl-no-default"
|
|
a.Kind = "ddl-no-default"
|
|
|
- a.Note = "DDL 该列本身就没有 DEFAULT CURRENT_TIMESTAMP(3),无需对齐"
|
|
|
|
|
|
|
+ a.Note = "DDL 该列本身没有 DEFAULT CURRENT_TIMESTAMP(3),无需对齐"
|
|
|
case lv.Default.Valid:
|
|
case lv.Default.Valid:
|
|
|
a.Kind = "ok"
|
|
a.Kind = "ok"
|
|
|
- a.Note = "已有默认值(或已对齐)"
|
|
|
|
|
|
|
+ a.Note = "已有默认值(已对齐)"
|
|
|
default:
|
|
default:
|
|
|
a.Kind = "alignable"
|
|
a.Kind = "alignable"
|
|
|
- a.SQL = buildModifySQL(lv, sp)
|
|
|
|
|
- a.Note = "实库 DEFAULT 为 NULL,补 DEFAULT CURRENT_TIMESTAMP(3)" +
|
|
|
|
|
|
|
+ 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]
|
|
map[bool]string{true: ",并补 ON UPDATE CURRENT_TIMESTAMP(3)", false: ""}[sp.OnUpdate]
|
|
|
}
|
|
}
|
|
|
rep.Actions = append(rep.Actions, a)
|
|
rep.Actions = append(rep.Actions, a)
|
|
@@ -312,8 +366,6 @@ func BuildAlignReport(ctx context.Context, cfg Config, modelDir string) (*AlignR
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// DryRunAlign 在**会话临时表**上验证每条 ALTER 是否合法且达到预期效果。
|
|
// DryRunAlign 在**会话临时表**上验证每条 ALTER 是否合法且达到预期效果。
|
|
|
-//
|
|
|
|
|
-// 不动真实表:`CREATE TEMPORARY TABLE ... LIKE 真表` → 跑同一条 MODIFY → 复查默认值。
|
|
|
|
|
func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
db, err := cfg.OpenTarget()
|
|
db, err := cfg.OpenTarget()
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -328,7 +380,7 @@ func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
|
|
|
|
|
for i := range rep.Actions {
|
|
for i := range rep.Actions {
|
|
|
a := &rep.Actions[i]
|
|
a := &rep.Actions[i]
|
|
|
- if a.Kind != "alignable" {
|
|
|
|
|
|
|
+ if a.Kind != "alignable" && a.Kind != "alignable-nullable" {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
tmp := "tmp_align_" + a.Table
|
|
tmp := "tmp_align_" + a.Table
|
|
@@ -336,7 +388,6 @@ func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
fmt.Sprintf("CREATE TEMPORARY TABLE IF NOT EXISTS `%s` LIKE `%s`", tmp, a.Table)); err != nil {
|
|
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)
|
|
return fmt.Errorf("dry-run 建临时表失败(%s):%w", a.Table, err)
|
|
|
}
|
|
}
|
|
|
- // 把临时表名替换进 ALTER 语句(其余部分逐字符相同)
|
|
|
|
|
stmt := strings.Replace(a.SQL, "ALTER TABLE `"+a.Table+"`", "ALTER TABLE `"+tmp+"`", 1)
|
|
stmt := strings.Replace(a.SQL, "ALTER TABLE `"+a.Table+"`", "ALTER TABLE `"+tmp+"`", 1)
|
|
|
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
|
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.Errors = append(rep.Errors, fmt.Sprintf("dry-run 失败 %s.%s:%v", a.Table, a.Column, err))
|
|
@@ -348,7 +399,6 @@ func DryRunAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
if err := conn.QueryRowContext(ctx, `
|
|
if err := conn.QueryRowContext(ctx, `
|
|
|
SELECT column_default, extra FROM information_schema.columns
|
|
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 {
|
|
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))
|
|
rows, err2 := conn.QueryContext(ctx, fmt.Sprintf("SHOW COLUMNS FROM `%s` LIKE '%s'", tmp, a.Column))
|
|
|
if err2 != nil {
|
|
if err2 != nil {
|
|
|
rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 复查失败 %s.%s:%v", a.Table, a.Column, err2))
|
|
rep.Errors = append(rep.Errors, fmt.Sprintf("dry-run 复查失败 %s.%s:%v", a.Table, a.Column, err2))
|
|
@@ -377,7 +427,8 @@ SELECT column_default, extra FROM information_schema.columns
|
|
|
// ApplyAlign 执行对齐(只在 dry-run 全部通过时执行)。
|
|
// ApplyAlign 执行对齐(只在 dry-run 全部通过时执行)。
|
|
|
func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
for i := range rep.Actions {
|
|
for i := range rep.Actions {
|
|
|
- if rep.Actions[i].Kind == "alignable" && !rep.DryRunOK[i] {
|
|
|
|
|
|
|
+ k := rep.Actions[i].Kind
|
|
|
|
|
+ if (k == "alignable" || k == "alignable-nullable") && !rep.DryRunOK[i] {
|
|
|
return fmt.Errorf("存在未通过 dry-run 的语句,拒绝执行(%s.%s)",
|
|
return fmt.Errorf("存在未通过 dry-run 的语句,拒绝执行(%s.%s)",
|
|
|
rep.Actions[i].Table, rep.Actions[i].Column)
|
|
rep.Actions[i].Table, rep.Actions[i].Column)
|
|
|
}
|
|
}
|
|
@@ -389,7 +440,7 @@ func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
defer db.Close()
|
|
defer db.Close()
|
|
|
for i := range rep.Actions {
|
|
for i := range rep.Actions {
|
|
|
a := &rep.Actions[i]
|
|
a := &rep.Actions[i]
|
|
|
- if a.Kind != "alignable" {
|
|
|
|
|
|
|
+ if a.Kind != "alignable" && a.Kind != "alignable-nullable" {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
if _, err := db.ExecContext(ctx, a.SQL); err != nil {
|
|
if _, err := db.ExecContext(ctx, a.SQL); err != nil {
|
|
@@ -402,14 +453,17 @@ func ApplyAlign(ctx context.Context, cfg Config, rep *AlignReport) error {
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// VerifyAlign 复核:这两列的 COLUMN_DEFAULT 必须全部非 NULL(drift-nullable 的除外)。
|
|
|
|
|
-func VerifyAlign(ctx context.Context, cfg Config) (total int, missing []string, err error) {
|
|
|
|
|
|
|
+// 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()
|
|
db, err := cfg.OpenTarget()
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return 0, nil, err
|
|
return 0, nil, err
|
|
|
}
|
|
}
|
|
|
defer db.Close()
|
|
defer db.Close()
|
|
|
- live, err := loadLiveColumns(ctx, db, cfg.TargetDB)
|
|
|
|
|
|
|
+ live, err := loadLiveColumns(ctx, db, cfg.TargetDB, columns)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return 0, nil, err
|
|
return 0, nil, err
|
|
|
}
|
|
}
|
|
@@ -426,10 +480,15 @@ func VerifyAlign(ctx context.Context, cfg Config) (total int, missing []string,
|
|
|
// PrintAlignReport 打印对齐清单。
|
|
// PrintAlignReport 打印对齐清单。
|
|
|
func PrintAlignReport(w io.Writer, r *AlignReport) {
|
|
func PrintAlignReport(w io.Writer, r *AlignReport) {
|
|
|
fmt.Fprintf(w, "== 结构对齐清单(基准:DDL 文件)==\n")
|
|
fmt.Fprintf(w, "== 结构对齐清单(基准:DDL 文件)==\n")
|
|
|
- fmt.Fprintf(w, "DDL 目录:%s\n\n", r.ModelDir)
|
|
|
|
|
|
|
+ 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()
|
|
alignable := r.Alignable()
|
|
|
- fmt.Fprintf(w, "【待补 DEFAULT】%d 条(只 MODIFY 这两列,其余属性照抄实库)\n", len(alignable))
|
|
|
|
|
|
|
+ fmt.Fprintf(w, "【待执行】%d 条\n", len(alignable))
|
|
|
for _, i := range alignable {
|
|
for _, i := range alignable {
|
|
|
a := r.Actions[i]
|
|
a := r.Actions[i]
|
|
|
dry := "未验证"
|
|
dry := "未验证"
|
|
@@ -440,7 +499,7 @@ func PrintAlignReport(w io.Writer, r *AlignReport) {
|
|
|
dry = "dry-run ✘"
|
|
dry = "dry-run ✘"
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- fmt.Fprintf(w, " %-30s 实库:%s\n %s [%s]\n", a.Table+"."+a.Column, a.LiveDesc, a.SQL, dry)
|
|
|
|
|
|
|
+ fmt.Fprintf(w, " %-32s [%s] %s\n %s\n", a.Table+"."+a.Column, a.Kind, dry, a.SQL)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
okCount := 0
|
|
okCount := 0
|
|
@@ -452,14 +511,23 @@ func PrintAlignReport(w io.Writer, r *AlignReport) {
|
|
|
fmt.Fprintf(w, "\n【已对齐】%d 条\n", okCount)
|
|
fmt.Fprintf(w, "\n【已对齐】%d 条\n", okCount)
|
|
|
for _, a := range r.Actions {
|
|
for _, a := range r.Actions {
|
|
|
if a.Kind == "ok" {
|
|
if a.Kind == "ok" {
|
|
|
- fmt.Fprintf(w, " %-30s %s\n", a.Table+"."+a.Column, a.LiveDesc)
|
|
|
|
|
|
|
+ fmt.Fprintf(w, " %-32s %s\n", a.Table+"."+a.Column, a.LiveDesc)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
probs := r.Problems()
|
|
probs := r.Problems()
|
|
|
fmt.Fprintf(w, "\n【只报告、不执行】%d 条\n", len(probs))
|
|
fmt.Fprintf(w, "\n【只报告、不执行】%d 条\n", len(probs))
|
|
|
for _, a := range probs {
|
|
for _, a := range probs {
|
|
|
- fmt.Fprintf(w, " %-30s [%s] %s\n 实库:%s\n", a.Table+"."+a.Column, a.Kind, a.Note, a.LiveDesc)
|
|
|
|
|
|
|
+ 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 {
|
|
if len(r.Errors) > 0 {
|