|
4 | 4 | "context" |
5 | 5 | "fmt" |
6 | 6 | "slices" |
| 7 | + "strings" |
7 | 8 |
|
8 | 9 | "github.com/bytebase/omni/tidb/ast" |
9 | 10 |
|
@@ -109,10 +110,19 @@ func (*StatementPriorBackupCheckAdvisor) Check(ctx context.Context, checkCtx adv |
109 | 110 | } |
110 | 111 | switch n := node.(type) { |
111 | 112 | case *ast.UpdateStmt: |
112 | | - for _, t := range omniExtractDMLTables(n.Tables) { |
| 113 | + // Cumulative #30 Codex-fix-1: derive UPDATE mutation |
| 114 | + // targets from SET-clause LHS qualifiers, NOT from the |
| 115 | + // full Tables list (which includes JOIN-only read-only |
| 116 | + // tables). For `UPDATE t1 JOIN t2 ON ... SET t1.col = ...` |
| 117 | + // the mutation target is t1; t2 must NOT be tagged. |
| 118 | + aliasMap := omniBuildTableAliasMap(n.Tables) |
| 119 | + for _, t := range omniExtractUpdateTargets(n.SetList, aliasMap) { |
113 | 120 | dmlRefs = append(dmlRefs, dmlRef{table: t, stmtType: "UPDATE"}) |
114 | 121 | } |
115 | 122 | case *ast.DeleteStmt: |
| 123 | + // DELETE's Tables field IS the mutation target set (per |
| 124 | + // omni parsenodes.go:123); Using[] is the filter-only |
| 125 | + // joins. Use Tables directly. |
116 | 126 | for _, t := range omniExtractDMLTables(n.Tables) { |
117 | 127 | dmlRefs = append(dmlRefs, dmlRef{table: t, stmtType: "DELETE"}) |
118 | 128 | } |
@@ -141,7 +151,19 @@ func (*StatementPriorBackupCheckAdvisor) Check(ctx context.Context, checkCtx adv |
141 | 151 | } |
142 | 152 | groups := make(map[string]*tableTypes) |
143 | 153 | for _, ref := range dmlRefs { |
144 | | - key := fmt.Sprintf("%s.%s", ref.table.database, ref.table.table) |
| 154 | + // Cumulative #30 Codex-fix-2: canonicalize key so equivalent |
| 155 | + // references (qualified vs unqualified; mixed casing) group |
| 156 | + // into the same bucket. Empty database → CurrentDatabase |
| 157 | + // fallback; both segments lowercased for case-insensitivity. |
| 158 | + // Without this, `UPDATE tech_book ...; DELETE FROM |
| 159 | + // db.tech_book ...` splits into `.tech_book` vs |
| 160 | + // `db.tech_book` buckets and misses the mixing — false- |
| 161 | + // negative on a safety gate. |
| 162 | + db := ref.table.database |
| 163 | + if db == "" { |
| 164 | + db = checkCtx.CurrentDatabase |
| 165 | + } |
| 166 | + key := fmt.Sprintf("%s.%s", strings.ToLower(db), strings.ToLower(ref.table.table)) |
145 | 167 | g := groups[key] |
146 | 168 | if g == nil { |
147 | 169 | g = &tableTypes{seen: make(map[string]bool)} |
@@ -222,6 +244,95 @@ func omniExtractTableExprRefs(t ast.TableExpr) []priorBackupTable { |
222 | 244 | } |
223 | 245 | } |
224 | 246 |
|
| 247 | +// omniBuildTableAliasMap walks an UpdateStmt's Tables slice and |
| 248 | +// builds an alias→base-table lookup map. Each TableRef contributes |
| 249 | +// two entries: one keyed by its alias (if present) and one keyed by |
| 250 | +// its bare name (so unaliased references also resolve). JoinClause |
| 251 | +// recurses into Left+Right. SubqueryExpr contributes nothing — |
| 252 | +// derived tables aren't base-table candidates (cumulative #26). |
| 253 | +// |
| 254 | +// Used by omniExtractUpdateTargets to resolve SET-clause column |
| 255 | +// qualifiers back to base tables — the cumulative #30 Codex-fix-1 |
| 256 | +// SET-clause-target-extraction discipline. Pre-fix, `n.Tables` was |
| 257 | +// used directly, false-positiving on JOIN-only read-only tables in |
| 258 | +// statements like `UPDATE t1 JOIN t2 SET t1.col = ...`. |
| 259 | +func omniBuildTableAliasMap(tables []ast.TableExpr) map[string]priorBackupTable { |
| 260 | + m := make(map[string]priorBackupTable) |
| 261 | + for _, t := range tables { |
| 262 | + omniCollectTableAliases(t, m) |
| 263 | + } |
| 264 | + return m |
| 265 | +} |
| 266 | + |
| 267 | +func omniCollectTableAliases(t ast.TableExpr, m map[string]priorBackupTable) { |
| 268 | + if t == nil { |
| 269 | + return |
| 270 | + } |
| 271 | + switch n := t.(type) { |
| 272 | + case *ast.TableRef: |
| 273 | + base := priorBackupTable{database: n.Schema, table: n.Name} |
| 274 | + // Alias takes precedence in lookup; also register the bare |
| 275 | + // name for unqualified references in single-table updates. |
| 276 | + if n.Alias != "" { |
| 277 | + m[strings.ToLower(n.Alias)] = base |
| 278 | + } |
| 279 | + m[strings.ToLower(n.Name)] = base |
| 280 | + case *ast.JoinClause: |
| 281 | + omniCollectTableAliases(n.Left, m) |
| 282 | + omniCollectTableAliases(n.Right, m) |
| 283 | + case *ast.SubqueryExpr: |
| 284 | + // Derived table — not a base-table candidate for backup |
| 285 | + // type-mixing detection. |
| 286 | + default: |
| 287 | + } |
| 288 | +} |
| 289 | + |
| 290 | +// omniExtractUpdateTargets walks an UpdateStmt's SetList and returns |
| 291 | +// the distinct base-table references that are actual mutation |
| 292 | +// targets. Resolves column qualifiers against the alias map: |
| 293 | +// - Qualified `t.col = ...` or `alias.col = ...`: look up the |
| 294 | +// qualifier in aliasMap → base table. |
| 295 | +// - Unqualified `col = ...`: if aliasMap has exactly one entry |
| 296 | +// (single-target UPDATE), use it. Otherwise the assignment is |
| 297 | +// ambiguous without schema info — skip (per cumulative #30 |
| 298 | +// fix-1 rationale: false-negative is preferable to false- |
| 299 | +// positive at this boundary; multi-target UPDATE with unqualified |
| 300 | +// SET is rare in practice and would be ambiguous-by-design). |
| 301 | +// |
| 302 | +// Returns deduplicated targets in input order; duplicates from |
| 303 | +// multiple assignments on the same table are collapsed. |
| 304 | +func omniExtractUpdateTargets(setList []*ast.Assignment, aliasMap map[string]priorBackupTable) []priorBackupTable { |
| 305 | + var result []priorBackupTable |
| 306 | + seen := make(map[string]bool) |
| 307 | + add := func(t priorBackupTable) { |
| 308 | + key := strings.ToLower(t.database) + "." + strings.ToLower(t.table) |
| 309 | + if seen[key] { |
| 310 | + return |
| 311 | + } |
| 312 | + seen[key] = true |
| 313 | + result = append(result, t) |
| 314 | + } |
| 315 | + for _, a := range setList { |
| 316 | + if a == nil || a.Column == nil { |
| 317 | + continue |
| 318 | + } |
| 319 | + qualifier := a.Column.Table |
| 320 | + if qualifier == "" { |
| 321 | + // Unqualified: only safe to attribute when single-target. |
| 322 | + if len(aliasMap) == 1 { |
| 323 | + for _, t := range aliasMap { |
| 324 | + add(t) |
| 325 | + } |
| 326 | + } |
| 327 | + continue |
| 328 | + } |
| 329 | + if base, ok := aliasMap[strings.ToLower(qualifier)]; ok { |
| 330 | + add(base) |
| 331 | + } |
| 332 | + } |
| 333 | + return result |
| 334 | +} |
| 335 | + |
225 | 336 | // omniIsDDLStmt reports whether the given statement node is a DDL |
226 | 337 | // (schema-changing) statement, mirroring pingcap-tidb's `ast.DDLNode` |
227 | 338 | // interface set. Pingcap-tidb's DDLNode has 22 implementers (verified |
|
0 commit comments