From 8cd99368ba69c11df1409d68644259b7804a25e9 Mon Sep 17 00:00:00 2001 From: 3AceShowHand Date: Wed, 13 May 2026 22:07:27 +0800 Subject: [PATCH 1/4] schemastore: qualify create view column refs --- .../persist_storage_ddl_handlers.go | 117 ++++++++++++++++-- .../schemastore/persist_storage_test.go | 53 ++++++++ 2 files changed, 163 insertions(+), 7 deletions(-) diff --git a/logservice/schemastore/persist_storage_ddl_handlers.go b/logservice/schemastore/persist_storage_ddl_handlers.go index 87b8f67c2d..6b8ae8e8f4 100644 --- a/logservice/schemastore/persist_storage_ddl_handlers.go +++ b/logservice/schemastore/persist_storage_ddl_handlers.go @@ -638,8 +638,7 @@ func normalizeCreateViewQueryWithStoredSelect(event *PersistedDDLEvent) { zap.Error(err)) return } - // Keep the original CREATE VIEW text when the stored SELECT only qualifies tables in the view's own schema. - if createViewSelectUsesCurrentSchemaOnly(selectStmt, event.SchemaName) { + if !normalizeCreateViewSelect(selectStmt, event.SchemaName) { return } @@ -655,13 +654,117 @@ func normalizeCreateViewQueryWithStoredSelect(event *PersistedDDLEvent) { event.Query = normalizedQuery } -func createViewSelectUsesCurrentSchemaOnly(selectStmt ast.StmtNode, currentSchema string) bool { - for _, schema := range extractTableSchemas(selectStmt) { - if schema != "" && !strings.EqualFold(schema, currentSchema) { - return false +type createViewSelectNormalizer struct { + currentSchema string + currentSchemaOnly bool + changed bool + scopes []createViewSelectScope +} + +type createViewSelectScope struct { + aliases map[string]struct{} + tableByName map[string]string + ambiguousTables map[string]struct{} +} + +// normalizeCreateViewSelect returns true when CREATE VIEW should use the stored +// SELECT body. It also turns unaliased table-qualified column references into +// schema-qualified references: `orders`.`id` with FROM `source_db`.`orders` +// becomes `source_db`.`orders`.`id`. Explicit alias references are preserved. +func normalizeCreateViewSelect(selectStmt ast.StmtNode, currentSchema string) bool { + normalizer := &createViewSelectNormalizer{ + currentSchema: currentSchema, + currentSchemaOnly: true, + scopes: make([]createViewSelectScope, 0), + } + selectStmt.Accept(normalizer) + return !normalizer.currentSchemaOnly || normalizer.changed +} + +func (n *createViewSelectNormalizer) Enter(in ast.Node) (ast.Node, bool) { + switch v := in.(type) { + case *ast.SelectStmt: + n.scopes = append(n.scopes, buildCreateViewSelectScope(v)) + case *ast.TableName: + if v.Schema.O != "" && !strings.EqualFold(v.Schema.O, n.currentSchema) { + n.currentSchemaOnly = false + } + case *ast.ColumnName: + n.qualifyColumnName(v) + } + return in, false +} + +func (n *createViewSelectNormalizer) Leave(in ast.Node) (ast.Node, bool) { + if _, ok := in.(*ast.SelectStmt); ok { + n.scopes = n.scopes[:len(n.scopes)-1] + } + return in, true +} + +func (n *createViewSelectNormalizer) qualifyColumnName(c *ast.ColumnName) { + if len(n.scopes) == 0 || c == nil || c.Schema.O != "" || c.Table.O == "" { + return + } + + scope := n.scopes[len(n.scopes)-1] + tableKey := strings.ToLower(c.Table.O) + if _, ok := scope.aliases[tableKey]; ok { + return + } + if _, ok := scope.ambiguousTables[tableKey]; ok { + return + } + schema, ok := scope.tableByName[tableKey] + if !ok { + return + } + c.Schema = ast.NewCIStr(schema) + n.changed = true +} + +func buildCreateViewSelectScope(selectStmt *ast.SelectStmt) createViewSelectScope { + scope := createViewSelectScope{ + aliases: make(map[string]struct{}), + tableByName: make(map[string]string), + ambiguousTables: make(map[string]struct{}), + } + if selectStmt == nil || selectStmt.From == nil || selectStmt.From.TableRefs == nil { + return scope + } + collectCreateViewSelectTables(selectStmt.From.TableRefs, &scope) + return scope +} + +func collectCreateViewSelectTables(node ast.ResultSetNode, scope *createViewSelectScope) { + switch v := node.(type) { + case *ast.Join: + if v.Left != nil { + collectCreateViewSelectTables(v.Left, scope) + } + if v.Right != nil { + collectCreateViewSelectTables(v.Right, scope) + } + case *ast.TableSource: + if v.AsName.O != "" { + scope.aliases[strings.ToLower(v.AsName.O)] = struct{}{} + return + } + tableName, ok := v.Source.(*ast.TableName) + if !ok || tableName.Schema.O == "" || tableName.Name.O == "" { + return + } + tableKey := strings.ToLower(tableName.Name.O) + if _, ambiguous := scope.ambiguousTables[tableKey]; ambiguous { + return + } + if _, exists := scope.tableByName[tableKey]; exists { + delete(scope.tableByName, tableKey) + scope.ambiguousTables[tableKey] = struct{}{} + return } + scope.tableByName[tableKey] = tableName.Schema.O } - return true } func buildPersistedDDLEventForCreateTable(args buildPersistedDDLEventFuncArgs) PersistedDDLEvent { diff --git a/logservice/schemastore/persist_storage_test.go b/logservice/schemastore/persist_storage_test.go index 0835fad7a0..61b53cd9ff 100644 --- a/logservice/schemastore/persist_storage_test.go +++ b/logservice/schemastore/persist_storage_test.go @@ -3698,6 +3698,59 @@ func TestBuildPersistedDDLEventForCreateViewKeepsOriginalQueryForSameSchemaSelec require.Equal(t, "v", ddl.TableName) } +func TestBuildPersistedDDLEventForCreateViewQualifiesTableColumnReferences(t *testing.T) { + cases := []struct { + name string + query string + selectStmt string + expected string + }{ + { + name: "cross schema unaliased table qualifier", + query: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `orders`.`id` FROM `orders`", + selectStmt: "SELECT `orders`.`id` AS `id` FROM `source_db`.`orders`", + expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `source_db`.`orders`.`id` AS `id` FROM `source_db`.`orders`", + }, + { + name: "same schema unaliased table qualifier", + query: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `users`.`id` FROM `users`", + selectStmt: "SELECT `users`.`id` AS `id` FROM `target_db`.`users`", + expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `target_db`.`users`.`id` AS `id` FROM `target_db`.`users`", + }, + { + name: "alias is preserved", + query: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `orders`.`id` FROM `orders` AS `orders`", + selectStmt: "SELECT `orders`.`id` AS `id` FROM `source_db`.`orders` AS `orders`", + expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `orders`.`id` AS `id` FROM `source_db`.`orders` AS `orders`", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + job := buildCreateViewJobForTest(101, 100) + job.TableName = "v" + job.Query = tc.query + job.BinlogInfo.TableInfo = &model.TableInfo{ + Name: ast.NewCIStr("v"), + View: &model.ViewInfo{ + SelectStmt: tc.selectStmt, + }, + } + + ddl := buildPersistedDDLEventForCreateView(buildPersistedDDLEventFuncArgs{ + job: job, + databaseMap: map[int64]*BasicDatabaseInfo{ + 101: {Name: "target_db", Tables: map[int64]bool{}}, + }, + }) + + require.Equal(t, tc.expected, ddl.Query) + require.Equal(t, "target_db", ddl.SchemaName) + require.Equal(t, "v", ddl.TableName) + }) + } +} + func TestBuildDDLEventForNewTableDDL_CreateTableLikeBlockedTableNames(t *testing.T) { cases := []struct { name string From c318bb461213f128530b6d4503eeeee36b113063 Mon Sep 17 00:00:00 2001 From: 3AceShowHand Date: Wed, 13 May 2026 22:55:14 +0800 Subject: [PATCH 2/4] schemastore: cover create view qualifier edge cases --- logservice/schemastore/persist_storage_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/logservice/schemastore/persist_storage_test.go b/logservice/schemastore/persist_storage_test.go index 61b53cd9ff..e016835df8 100644 --- a/logservice/schemastore/persist_storage_test.go +++ b/logservice/schemastore/persist_storage_test.go @@ -3723,6 +3723,24 @@ func TestBuildPersistedDDLEventForCreateViewQualifiesTableColumnReferences(t *te selectStmt: "SELECT `orders`.`id` AS `id` FROM `source_db`.`orders` AS `orders`", expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `orders`.`id` AS `id` FROM `source_db`.`orders` AS `orders`", }, + { + name: "ambiguous table qualifier is preserved", + query: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `t`.`id` FROM `t`", + selectStmt: "SELECT `t`.`id` AS `id` FROM `db1`.`t`, `db2`.`t`", + expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `t`.`id` AS `id` FROM (`db1`.`t`) JOIN `db2`.`t`", + }, + { + name: "subquery scopes are independent", + query: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `q`.`id` FROM (SELECT `t`.`id` FROM `t`) AS `q`", + selectStmt: "SELECT `q`.`id` AS `id` FROM (SELECT `t`.`id` AS `id` FROM `source_db`.`t`) AS `q`", + expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `q`.`id` AS `id` FROM (SELECT `source_db`.`t`.`id` AS `id` FROM `source_db`.`t`) AS `q`", + }, + { + name: "join table qualifier", + query: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `orders`.`id`, `customers`.`name` FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id`", + selectStmt: "SELECT `orders`.`id` AS `id`, `customers`.`name` AS `name` FROM `source_db`.`orders` JOIN `crm_db`.`customers` ON `orders`.`customer_id` = `customers`.`id`", + expected: "CREATE ALGORITHM = UNDEFINED DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `target_db`.`v` AS SELECT `source_db`.`orders`.`id` AS `id`,`crm_db`.`customers`.`name` AS `name` FROM `source_db`.`orders` JOIN `crm_db`.`customers` ON `source_db`.`orders`.`customer_id`=`crm_db`.`customers`.`id`", + }, } for _, tc := range cases { From 4e15e513aec4aff586d9c9ac4897c5d2204c7eab Mon Sep 17 00:00:00 2001 From: 3AceShowHand Date: Thu, 14 May 2026 10:13:57 +0800 Subject: [PATCH 3/4] schemastore: share create view query normalizer --- .../persist_storage_ddl_handlers.go | 150 +-------------- pkg/common/event/ddl_query_normalizer.go | 172 ++++++++++++++++++ 2 files changed, 180 insertions(+), 142 deletions(-) create mode 100644 pkg/common/event/ddl_query_normalizer.go diff --git a/logservice/schemastore/persist_storage_ddl_handlers.go b/logservice/schemastore/persist_storage_ddl_handlers.go index 6b8ae8e8f4..363495cdfa 100644 --- a/logservice/schemastore/persist_storage_ddl_handlers.go +++ b/logservice/schemastore/persist_storage_ddl_handlers.go @@ -614,157 +614,23 @@ func buildPersistedDDLEventForDropView(args buildPersistedDDLEventFuncArgs) Pers // Value assignment in CREATE VIEW: // https://github.com/pingcap/tidb/blob/8f2630e53d5d/pkg/ddl/create_table.go#L1668-L1678 func normalizeCreateViewQueryWithStoredSelect(event *PersistedDDLEvent) { - if event.Query == "" || event.TableInfo == nil || event.TableInfo.View == nil || event.TableInfo.View.SelectStmt == "" { + if event.TableInfo == nil || event.TableInfo.View == nil { return } - stmt, err := parser.New().ParseOneStmt(event.Query, "", "") - if err != nil { - log.Warn("parse create view query failed when normalizing select statement", - zap.String("query", event.Query), - zap.Error(err)) - return - } - createViewStmt, ok := stmt.(*ast.CreateViewStmt) - if !ok { - return - } - - selectStmt, err := parser.New().ParseOneStmt(event.TableInfo.View.SelectStmt, "", "") - if err != nil { - log.Warn("parse stored create view select statement failed", - zap.String("selectStmt", event.TableInfo.View.SelectStmt), - zap.String("query", event.Query), - zap.Error(err)) - return - } - if !normalizeCreateViewSelect(selectStmt, event.SchemaName) { - return - } - - createViewStmt.Select = selectStmt - normalizedQuery, err := commonEvent.Restore(createViewStmt) + query, err := commonEvent.NormalizeCreateViewQueryWithStoredSelect( + event.Query, + event.TableInfo.View.SelectStmt, + event.SchemaName, + ) if err != nil { - log.Warn("restore normalized create view query failed", + log.Warn("normalize create view query with stored select failed", zap.String("query", event.Query), zap.String("selectStmt", event.TableInfo.View.SelectStmt), zap.Error(err)) return } - event.Query = normalizedQuery -} - -type createViewSelectNormalizer struct { - currentSchema string - currentSchemaOnly bool - changed bool - scopes []createViewSelectScope -} - -type createViewSelectScope struct { - aliases map[string]struct{} - tableByName map[string]string - ambiguousTables map[string]struct{} -} - -// normalizeCreateViewSelect returns true when CREATE VIEW should use the stored -// SELECT body. It also turns unaliased table-qualified column references into -// schema-qualified references: `orders`.`id` with FROM `source_db`.`orders` -// becomes `source_db`.`orders`.`id`. Explicit alias references are preserved. -func normalizeCreateViewSelect(selectStmt ast.StmtNode, currentSchema string) bool { - normalizer := &createViewSelectNormalizer{ - currentSchema: currentSchema, - currentSchemaOnly: true, - scopes: make([]createViewSelectScope, 0), - } - selectStmt.Accept(normalizer) - return !normalizer.currentSchemaOnly || normalizer.changed -} - -func (n *createViewSelectNormalizer) Enter(in ast.Node) (ast.Node, bool) { - switch v := in.(type) { - case *ast.SelectStmt: - n.scopes = append(n.scopes, buildCreateViewSelectScope(v)) - case *ast.TableName: - if v.Schema.O != "" && !strings.EqualFold(v.Schema.O, n.currentSchema) { - n.currentSchemaOnly = false - } - case *ast.ColumnName: - n.qualifyColumnName(v) - } - return in, false -} - -func (n *createViewSelectNormalizer) Leave(in ast.Node) (ast.Node, bool) { - if _, ok := in.(*ast.SelectStmt); ok { - n.scopes = n.scopes[:len(n.scopes)-1] - } - return in, true -} - -func (n *createViewSelectNormalizer) qualifyColumnName(c *ast.ColumnName) { - if len(n.scopes) == 0 || c == nil || c.Schema.O != "" || c.Table.O == "" { - return - } - - scope := n.scopes[len(n.scopes)-1] - tableKey := strings.ToLower(c.Table.O) - if _, ok := scope.aliases[tableKey]; ok { - return - } - if _, ok := scope.ambiguousTables[tableKey]; ok { - return - } - schema, ok := scope.tableByName[tableKey] - if !ok { - return - } - c.Schema = ast.NewCIStr(schema) - n.changed = true -} - -func buildCreateViewSelectScope(selectStmt *ast.SelectStmt) createViewSelectScope { - scope := createViewSelectScope{ - aliases: make(map[string]struct{}), - tableByName: make(map[string]string), - ambiguousTables: make(map[string]struct{}), - } - if selectStmt == nil || selectStmt.From == nil || selectStmt.From.TableRefs == nil { - return scope - } - collectCreateViewSelectTables(selectStmt.From.TableRefs, &scope) - return scope -} - -func collectCreateViewSelectTables(node ast.ResultSetNode, scope *createViewSelectScope) { - switch v := node.(type) { - case *ast.Join: - if v.Left != nil { - collectCreateViewSelectTables(v.Left, scope) - } - if v.Right != nil { - collectCreateViewSelectTables(v.Right, scope) - } - case *ast.TableSource: - if v.AsName.O != "" { - scope.aliases[strings.ToLower(v.AsName.O)] = struct{}{} - return - } - tableName, ok := v.Source.(*ast.TableName) - if !ok || tableName.Schema.O == "" || tableName.Name.O == "" { - return - } - tableKey := strings.ToLower(tableName.Name.O) - if _, ambiguous := scope.ambiguousTables[tableKey]; ambiguous { - return - } - if _, exists := scope.tableByName[tableKey]; exists { - delete(scope.tableByName, tableKey) - scope.ambiguousTables[tableKey] = struct{}{} - return - } - scope.tableByName[tableKey] = tableName.Schema.O - } + event.Query = query } func buildPersistedDDLEventForCreateTable(args buildPersistedDDLEventFuncArgs) PersistedDDLEvent { diff --git a/pkg/common/event/ddl_query_normalizer.go b/pkg/common/event/ddl_query_normalizer.go new file mode 100644 index 0000000000..18c8815ffa --- /dev/null +++ b/pkg/common/event/ddl_query_normalizer.go @@ -0,0 +1,172 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package event + +import ( + "strings" + + "github.com/pingcap/ticdc/pkg/errors" + "github.com/pingcap/tidb/pkg/parser" + "github.com/pingcap/tidb/pkg/parser/ast" +) + +// NormalizeCreateViewQueryWithStoredSelect replaces the SELECT body in a +// CREATE VIEW query with TiDB's stored View.SelectStmt when the stored SELECT +// carries information that the original query text does not carry. +// +// TiDB persists the normalized SELECT body of a view in TableInfo.View.SelectStmt +// when executing CREATE VIEW, so this field can carry resolved source-table +// references even if job.Query keeps the original session-level text. +func NormalizeCreateViewQueryWithStoredSelect(query string, storedSelectStmt string, currentSchema string) (string, error) { + if query == "" || storedSelectStmt == "" { + return query, nil + } + + stmt, err := parser.New().ParseOneStmt(query, "", "") + if err != nil { + return query, errors.WrapError(errors.ErrDDLEventError, err) + } + createViewStmt, ok := stmt.(*ast.CreateViewStmt) + if !ok { + return query, nil + } + + selectStmt, err := parser.New().ParseOneStmt(storedSelectStmt, "", "") + if err != nil { + return query, errors.WrapError(errors.ErrDDLEventError, err) + } + if !normalizeCreateViewSelect(selectStmt, currentSchema) { + return query, nil + } + + createViewStmt.Select = selectStmt + normalizedQuery, err := Restore(createViewStmt) + if err != nil { + return query, errors.WrapError(errors.ErrDDLEventError, err) + } + return normalizedQuery, nil +} + +type createViewSelectNormalizer struct { + currentSchema string + currentSchemaOnly bool + changed bool + scopes []createViewSelectScope +} + +type createViewSelectScope struct { + aliases map[string]struct{} + tableByName map[string]string + ambiguousTables map[string]struct{} +} + +// normalizeCreateViewSelect returns true when CREATE VIEW should use the stored +// SELECT body. It also turns unaliased table-qualified column references into +// schema-qualified references: `orders`.`id` with FROM `source_db`.`orders` +// becomes `source_db`.`orders`.`id`. Explicit alias references are preserved. +func normalizeCreateViewSelect(selectStmt ast.StmtNode, currentSchema string) bool { + normalizer := &createViewSelectNormalizer{ + currentSchema: currentSchema, + currentSchemaOnly: true, + scopes: make([]createViewSelectScope, 0), + } + selectStmt.Accept(normalizer) + return !normalizer.currentSchemaOnly || normalizer.changed +} + +func (n *createViewSelectNormalizer) Enter(in ast.Node) (ast.Node, bool) { + switch v := in.(type) { + case *ast.SelectStmt: + n.scopes = append(n.scopes, buildCreateViewSelectScope(v)) + case *ast.TableName: + if v.Schema.O != "" && !strings.EqualFold(v.Schema.O, n.currentSchema) { + n.currentSchemaOnly = false + } + case *ast.ColumnName: + n.qualifyColumnName(v) + } + return in, false +} + +func (n *createViewSelectNormalizer) Leave(in ast.Node) (ast.Node, bool) { + if _, ok := in.(*ast.SelectStmt); ok { + n.scopes = n.scopes[:len(n.scopes)-1] + } + return in, true +} + +func (n *createViewSelectNormalizer) qualifyColumnName(c *ast.ColumnName) { + if len(n.scopes) == 0 || c == nil || c.Schema.O != "" || c.Table.O == "" { + return + } + + scope := n.scopes[len(n.scopes)-1] + tableKey := strings.ToLower(c.Table.O) + if _, ok := scope.aliases[tableKey]; ok { + return + } + if _, ok := scope.ambiguousTables[tableKey]; ok { + return + } + schema, ok := scope.tableByName[tableKey] + if !ok { + return + } + c.Schema = ast.NewCIStr(schema) + n.changed = true +} + +func buildCreateViewSelectScope(selectStmt *ast.SelectStmt) createViewSelectScope { + scope := createViewSelectScope{ + aliases: make(map[string]struct{}), + tableByName: make(map[string]string), + ambiguousTables: make(map[string]struct{}), + } + if selectStmt == nil || selectStmt.From == nil || selectStmt.From.TableRefs == nil { + return scope + } + collectCreateViewSelectTables(selectStmt.From.TableRefs, &scope) + return scope +} + +func collectCreateViewSelectTables(node ast.ResultSetNode, scope *createViewSelectScope) { + switch v := node.(type) { + case *ast.Join: + if v.Left != nil { + collectCreateViewSelectTables(v.Left, scope) + } + if v.Right != nil { + collectCreateViewSelectTables(v.Right, scope) + } + case *ast.TableSource: + if v.AsName.O != "" { + scope.aliases[strings.ToLower(v.AsName.O)] = struct{}{} + return + } + tableName, ok := v.Source.(*ast.TableName) + if !ok || tableName.Schema.O == "" || tableName.Name.O == "" { + return + } + tableKey := strings.ToLower(tableName.Name.O) + if _, ambiguous := scope.ambiguousTables[tableKey]; ambiguous { + return + } + if _, exists := scope.tableByName[tableKey]; exists { + delete(scope.tableByName, tableKey) + scope.ambiguousTables[tableKey] = struct{}{} + return + } + scope.tableByName[tableKey] = tableName.Schema.O + } +} From 77f52024ee1bc8be2e7930e603a1dc9f64da0a55 Mon Sep 17 00:00:00 2001 From: 3AceShowHand Date: Thu, 14 May 2026 10:53:27 +0800 Subject: [PATCH 4/4] common: move table schema extractor to ddl normalizer --- logservice/schemastore/utils.go | 31 ---------- logservice/schemastore/utils_test.go | 36 ------------ pkg/common/event/ddl_query_normalizer.go | 56 +++++++++++++++---- pkg/common/event/ddl_query_normalizer_test.go | 56 +++++++++++++++++++ 4 files changed, 100 insertions(+), 79 deletions(-) create mode 100644 pkg/common/event/ddl_query_normalizer_test.go diff --git a/logservice/schemastore/utils.go b/logservice/schemastore/utils.go index 7d4d3c28be..0b54043f9c 100644 --- a/logservice/schemastore/utils.go +++ b/logservice/schemastore/utils.go @@ -21,7 +21,6 @@ import ( commonEvent "github.com/pingcap/ticdc/pkg/common/event" "github.com/pingcap/tidb/pkg/meta/model" "github.com/pingcap/tidb/pkg/parser" - "github.com/pingcap/tidb/pkg/parser/ast" "go.uber.org/zap" ) @@ -121,33 +120,3 @@ func extractAddIndexIDs(job *model.Job) []int64 { } return res } - -type tableSchemaExtractor struct { - schemas []string -} - -func (e *tableSchemaExtractor) Enter(in ast.Node) (ast.Node, bool) { - if t, ok := in.(*ast.TableName); ok { - e.schemas = append(e.schemas, t.Schema.O) - return in, true - } - return in, false -} - -func (e *tableSchemaExtractor) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -// extractTableSchemas returns schema qualifiers from all *ast.TableName nodes in -// AST visit order. Unqualified tables contribute an empty schema name. -func extractTableSchemas(node ast.Node) []string { - if node == nil { - return nil - } - - extractor := &tableSchemaExtractor{ - schemas: make([]string, 0), - } - node.Accept(extractor) - return extractor.schemas -} diff --git a/logservice/schemastore/utils_test.go b/logservice/schemastore/utils_test.go index 2418641670..08e72202a8 100644 --- a/logservice/schemastore/utils_test.go +++ b/logservice/schemastore/utils_test.go @@ -21,7 +21,6 @@ import ( ticonfig "github.com/pingcap/tidb/pkg/config" "github.com/pingcap/tidb/pkg/disttask/framework/handle" "github.com/pingcap/tidb/pkg/meta/model" - "github.com/pingcap/tidb/pkg/parser" "github.com/stretchr/testify/require" ) @@ -224,38 +223,3 @@ func TestGetIndexIDsIgnoresAddPrimaryKeySubJobsForMultiSchemaChange(t *testing.T require.NotZero(t, anonymousIndexID) require.Equal(t, []int64{anonymousIndexID}, getIndexIDs(job)) } - -func TestExtractTableSchemas(t *testing.T) { - cases := []struct { - name string - query string - expected []string - }{ - { - name: "unqualified table", - query: "SELECT * FROM `t`", - expected: []string{""}, - }, - { - name: "mixed qualified tables", - query: "SELECT * FROM `db1`.`t1` JOIN `t2` ON `db1`.`t1`.`id` = `t2`.`id`", - expected: []string{"db1", ""}, - }, - { - name: "subquery preserves visit order", - query: "SELECT * FROM `db1`.`t1` WHERE EXISTS (SELECT 1 FROM `db2`.`t2`)", - expected: []string{"db1", "db2"}, - }, - } - - p := parser.New() - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - stmt, err := p.ParseOneStmt(tc.query, "", "") - require.NoError(t, err) - require.Equal(t, tc.expected, extractTableSchemas(stmt)) - }) - } - - require.Nil(t, extractTableSchemas(nil)) -} diff --git a/pkg/common/event/ddl_query_normalizer.go b/pkg/common/event/ddl_query_normalizer.go index 18c8815ffa..e04802cfea 100644 --- a/pkg/common/event/ddl_query_normalizer.go +++ b/pkg/common/event/ddl_query_normalizer.go @@ -59,10 +59,8 @@ func NormalizeCreateViewQueryWithStoredSelect(query string, storedSelectStmt str } type createViewSelectNormalizer struct { - currentSchema string - currentSchemaOnly bool - changed bool - scopes []createViewSelectScope + changed bool + scopes []createViewSelectScope } type createViewSelectScope struct { @@ -76,23 +74,27 @@ type createViewSelectScope struct { // schema-qualified references: `orders`.`id` with FROM `source_db`.`orders` // becomes `source_db`.`orders`.`id`. Explicit alias references are preserved. func normalizeCreateViewSelect(selectStmt ast.StmtNode, currentSchema string) bool { + currentSchemaOnly := createViewSelectUsesCurrentSchemaOnly(selectStmt, currentSchema) normalizer := &createViewSelectNormalizer{ - currentSchema: currentSchema, - currentSchemaOnly: true, - scopes: make([]createViewSelectScope, 0), + scopes: make([]createViewSelectScope, 0), } selectStmt.Accept(normalizer) - return !normalizer.currentSchemaOnly || normalizer.changed + return !currentSchemaOnly || normalizer.changed +} + +func createViewSelectUsesCurrentSchemaOnly(selectStmt ast.StmtNode, currentSchema string) bool { + for _, schema := range extractTableSchemas(selectStmt) { + if schema != "" && !strings.EqualFold(schema, currentSchema) { + return false + } + } + return true } func (n *createViewSelectNormalizer) Enter(in ast.Node) (ast.Node, bool) { switch v := in.(type) { case *ast.SelectStmt: n.scopes = append(n.scopes, buildCreateViewSelectScope(v)) - case *ast.TableName: - if v.Schema.O != "" && !strings.EqualFold(v.Schema.O, n.currentSchema) { - n.currentSchemaOnly = false - } case *ast.ColumnName: n.qualifyColumnName(v) } @@ -170,3 +172,33 @@ func collectCreateViewSelectTables(node ast.ResultSetNode, scope *createViewSele scope.tableByName[tableKey] = tableName.Schema.O } } + +type tableSchemaExtractor struct { + schemas []string +} + +func (e *tableSchemaExtractor) Enter(in ast.Node) (ast.Node, bool) { + if t, ok := in.(*ast.TableName); ok { + e.schemas = append(e.schemas, t.Schema.O) + return in, true + } + return in, false +} + +func (e *tableSchemaExtractor) Leave(in ast.Node) (ast.Node, bool) { + return in, true +} + +// extractTableSchemas returns schema qualifiers from all *ast.TableName nodes in +// AST visit order. Unqualified tables contribute an empty schema name. +func extractTableSchemas(node ast.Node) []string { + if node == nil { + return nil + } + + extractor := &tableSchemaExtractor{ + schemas: make([]string, 0), + } + node.Accept(extractor) + return extractor.schemas +} diff --git a/pkg/common/event/ddl_query_normalizer_test.go b/pkg/common/event/ddl_query_normalizer_test.go new file mode 100644 index 0000000000..422bf6479b --- /dev/null +++ b/pkg/common/event/ddl_query_normalizer_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package event + +import ( + "testing" + + "github.com/pingcap/tidb/pkg/parser" + "github.com/stretchr/testify/require" +) + +func TestExtractTableSchemas(t *testing.T) { + cases := []struct { + name string + query string + expected []string + }{ + { + name: "unqualified table", + query: "SELECT * FROM `t`", + expected: []string{""}, + }, + { + name: "mixed qualified tables", + query: "SELECT * FROM `db1`.`t1` JOIN `t2` ON `db1`.`t1`.`id` = `t2`.`id`", + expected: []string{"db1", ""}, + }, + { + name: "subquery preserves visit order", + query: "SELECT * FROM `db1`.`t1` WHERE EXISTS (SELECT 1 FROM `db2`.`t2`)", + expected: []string{"db1", "db2"}, + }, + } + + p := parser.New() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stmt, err := p.ParseOneStmt(tc.query, "", "") + require.NoError(t, err) + require.Equal(t, tc.expected, extractTableSchemas(stmt)) + }) + } + + require.Nil(t, extractTableSchemas(nil)) +}