diff --git a/CLAUDE.md b/CLAUDE.md index 73a6492..a2f66ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,11 @@ architecture of that rewrite and the decisions that survive it. ## Rules -- The `ast` package is frozen, and the public `parser` API is stable. +- The `ast` package may only change in backwards-compatible ways: new + node types and new fields are okay; existing nodes, fields, and their + semantics (`Offset`, `Text()`, flags, `Restore` output) must not be + renamed, removed, or changed — sqlc's MySQL engine consumes them. +- The public `parser` API is stable. - Error messages are part of the contract: `line N column M near "..."` built from the offending token's recorded position; action errors use `r.actionErrorf` (reduce-time lookahead position). Failures during diff --git a/ast/misc.go b/ast/misc.go index 8d338b7..243d5d8 100644 --- a/ast/misc.go +++ b/ast/misc.go @@ -32,6 +32,7 @@ var ( _ StmtNode = &AlterRangeStmt{} _ StmtNode = &BeginStmt{} _ StmtNode = &BinlogStmt{} + _ StmtNode = &ChangeReplicationSourceStmt{} _ StmtNode = &CommitStmt{} _ StmtNode = &CreateUserStmt{} _ StmtNode = &DeallocateStmt{} @@ -474,7 +475,10 @@ const ( TrafficOptionReadOnly ) -var _ SensitiveStmtNode = (*TrafficStmt)(nil) +var ( + _ SensitiveStmtNode = (*ChangeReplicationSourceStmt)(nil) + _ SensitiveStmtNode = (*TrafficStmt)(nil) +) // TrafficStmt is traffic operation statement. type TrafficStmt struct { @@ -852,6 +856,89 @@ func (n *BinlogStmt) Accept(v Visitor) (Node, bool) { return v.Leave(n) } +// ReplicationSourceOption is a single name = value option of +// ChangeReplicationSourceStmt. Names are stored uppercase; the parser +// does not validate them against the server's option list. Values are +// literals: a string, integer, or decimal. +type ReplicationSourceOption struct { + Name string + Value ValueExpr +} + +// Restore implements Node interface. +func (n *ReplicationSourceOption) Restore(ctx *format.RestoreCtx) error { + ctx.WriteKeyWord(n.Name) + ctx.WritePlain(" = ") + if err := n.Value.Restore(ctx); err != nil { + return fmt.Errorf("an error occurred while restore ReplicationSourceOption.Value: %w", err) + } + return nil +} + +// ChangeReplicationSourceStmt is a statement to configure a replication +// channel's connection to its source, "CHANGE REPLICATION SOURCE TO" +// (which replaced CHANGE MASTER TO; the removed spelling is not parsed). +// The MySQL 26.7 Change Stream Applier options APPLIER_VERSION, +// APPLIER_WORKER_COUNT, and APPLIER_EVENT_MEMORY_LIMIT parse through the +// same generic option form as the connection options. +// See https://dev.mysql.com/doc/refman/26.7/en/change-replication-source-to.html +type ChangeReplicationSourceStmt struct { + stmtNode + + Options []*ReplicationSourceOption + Channel string // FOR CHANNEL clause; empty when absent +} + +// Restore implements Node interface. +func (n *ChangeReplicationSourceStmt) Restore(ctx *format.RestoreCtx) error { + ctx.WriteKeyWord("CHANGE REPLICATION SOURCE TO ") + for i, opt := range n.Options { + if i != 0 { + ctx.WritePlain(", ") + } + if err := opt.Restore(ctx); err != nil { + return fmt.Errorf("an error occurred while restore ChangeReplicationSourceStmt.Options[%d]: %w", i, err) + } + } + if n.Channel != "" { + ctx.WriteKeyWord(" FOR CHANNEL ") + ctx.WriteString(n.Channel) + } + return nil +} + +// SecureText implements SensitiveStatement interface. +func (n *ChangeReplicationSourceStmt) SecureText() string { + opts := make([]*ReplicationSourceOption, 0, len(n.Options)) + for _, opt := range n.Options { + if strings.Contains(opt.Name, "PASSWORD") { + opt = &ReplicationSourceOption{Name: opt.Name, Value: NewValueExpr("xxxxxx", "", "")} + } + opts = append(opts, opt) + } + masked := &ChangeReplicationSourceStmt{Options: opts, Channel: n.Channel} + var sb strings.Builder + _ = masked.Restore(format.NewRestoreCtx(format.DefaultRestoreFlags, &sb)) + return sb.String() +} + +// Accept implements Node Accept interface. +func (n *ChangeReplicationSourceStmt) Accept(v Visitor) (Node, bool) { + newNode, skipChildren := v.Enter(n) + if skipChildren { + return v.Leave(newNode) + } + n = newNode.(*ChangeReplicationSourceStmt) + for _, opt := range n.Options { + node, ok := opt.Value.Accept(v) + if !ok { + return n, false + } + opt.Value = node.(ValueExpr) + } + return v.Leave(n) +} + // CompletionType defines completion_type used in COMMIT and ROLLBACK statements type CompletionType int8 diff --git a/ast/sem.go b/ast/sem.go index b6f2f5d..2370978 100644 --- a/ast/sem.go +++ b/ast/sem.go @@ -371,6 +371,8 @@ const ( CalibrateResourceCommand = "CALIBRATE RESOURCE" // CancelDistributionJobCommand represents CANCEL DISTRIBUTION JOB statement CancelDistributionJobCommand = "CANCEL DISTRIBUTION JOB" + // ChangeReplicationSourceCommand represents CHANGE REPLICATION SOURCE TO statement + ChangeReplicationSourceCommand = "CHANGE REPLICATION SOURCE" // CommitCommand represents COMMIT statement CommitCommand = "COMMIT" // AlterTableCompactCommand represents ALTER TABLE COMPACT statement @@ -947,6 +949,11 @@ func (n *BinlogStmt) SEMCommand() string { return BinlogCommand } +// SEMCommand returns the command string for the statement. +func (n *ChangeReplicationSourceStmt) SEMCommand() string { + return ChangeReplicationSourceCommand +} + // SEMCommand returns the command string for the statement. func (n *BRIEStmt) SEMCommand() string { switch n.Kind { diff --git a/parser/keyword_classes.go b/parser/keyword_classes.go index 84fdfaf..b241ea6 100644 --- a/parser/keyword_classes.go +++ b/parser/keyword_classes.go @@ -40,6 +40,7 @@ var unReservedKeywordNames = []string{ "CLEANUP", "CLOSE", "CHAIN", + "CHANNEL", "CHARSET", "COLUMNS", "CONFIG", @@ -94,6 +95,7 @@ var unReservedKeywordNames = []string{ "NVARCHAR", "OFFSET", "PACK_KEYS", + "PARALLEL", "PARSER", "PASSWORD", "PREPARE", @@ -221,6 +223,7 @@ var unReservedKeywordNames = []string{ "MAX_QUERIES_PER_HOUR", "MAX_UPDATES_PER_HOUR", "MAX_USER_CONNECTIONS", + "MANUAL", "MASKING", "REPLICATION", "CLIENT", diff --git a/parser/keywords.go b/parser/keywords.go index 105cfce..3744a8d 100644 --- a/parser/keywords.go +++ b/parser/keywords.go @@ -54,6 +54,7 @@ var Keywords = []KeywordsType{ {"CONVERT", true, "reserved"}, {"CREATE", true, "reserved"}, {"CROSS", true, "reserved"}, + {"CUBE", true, "reserved"}, {"CUME_DIST", true, "reserved"}, {"CURRENT_DATE", true, "reserved"}, {"CURRENT_ROLE", true, "reserved"}, @@ -88,6 +89,7 @@ var Keywords = []KeywordsType{ {"EXISTS", true, "reserved"}, {"EXIT", true, "reserved"}, {"EXPLAIN", true, "reserved"}, + {"EXTERNAL", true, "reserved"}, {"FALSE", true, "reserved"}, {"FETCH", true, "reserved"}, {"FIRST_VALUE", true, "reserved"}, @@ -184,6 +186,7 @@ var Keywords = []KeywordsType{ {"PRECISION", true, "reserved"}, {"PRIMARY", true, "reserved"}, {"PROCEDURE", true, "reserved"}, + {"QUALIFY", true, "reserved"}, {"RANGE", true, "reserved"}, {"RANK", true, "reserved"}, {"READ", true, "reserved"}, @@ -303,6 +306,7 @@ var Keywords = []KeywordsType{ {"CASCADED", false, "unreserved"}, {"CAUSAL", false, "unreserved"}, {"CHAIN", false, "unreserved"}, + {"CHANNEL", false, "unreserved"}, {"CHARSET", false, "unreserved"}, {"CHECKPOINT", false, "unreserved"}, {"CHECKSUM", false, "unreserved"}, @@ -438,6 +442,7 @@ var Keywords = []KeywordsType{ {"LOCATION", false, "unreserved"}, {"LOCKED", false, "unreserved"}, {"LOGS", false, "unreserved"}, + {"MANUAL", false, "unreserved"}, {"MASKING", false, "unreserved"}, {"MASTER", false, "unreserved"}, {"MAX_CONNECTIONS_PER_HOUR", false, "unreserved"}, @@ -490,6 +495,7 @@ var Keywords = []KeywordsType{ {"PAGE_CHECKSUM", false, "unreserved"}, {"PAGE_COMPRESSED", false, "unreserved"}, {"PAGE_COMPRESSION_LEVEL", false, "unreserved"}, + {"PARALLEL", false, "unreserved"}, {"PARSER", false, "unreserved"}, {"PARTIAL", false, "unreserved"}, {"PARTITIONING", false, "unreserved"}, diff --git a/parser/keywords_test.go b/parser/keywords_test.go index 5660835..08a6f0a 100644 --- a/parser/keywords_test.go +++ b/parser/keywords_test.go @@ -43,8 +43,8 @@ func TestKeywords(t *testing.T) { } func TestKeywordsLength(t *testing.T) { - if !reflect.DeepEqual(679, len(parser.Keywords)) { - t.Fatalf("got %v, want %v", len(parser.Keywords), 679) + if !reflect.DeepEqual(685, len(parser.Keywords)) { + t.Fatalf("got %v, want %v", len(parser.Keywords), 685) } reservedNr := 0 @@ -53,8 +53,8 @@ func TestKeywordsLength(t *testing.T) { reservedNr += 1 } } - if !reflect.DeepEqual(233, reservedNr) { - t.Fatalf("got %v, want %v", reservedNr, 233) + if !reflect.DeepEqual(236, reservedNr) { + t.Fatalf("got %v, want %v", reservedNr, 236) } } diff --git a/parser/misc.go b/parser/misc.go index 0f11b4d..0d81164 100644 --- a/parser/misc.go +++ b/parser/misc.go @@ -240,6 +240,7 @@ var tokenMap = map[string]int{ "CAUSAL": causal, "CHAIN": chain, "CHANGE": change, + "CHANNEL": channel, "CHAR": charType, "CHARACTER": character, "CHARSET": charsetKwd, @@ -292,6 +293,7 @@ var tokenMap = map[string]int{ "CSV_NULL": csvNull, "CSV_SEPARATOR": csvSeparator, "CSV_TRIM_LAST_SEPARATORS": csvTrimLastSeparators, + "CUBE": cube, "WAIT_TIFLASH_READY": waitTiflashReady, "WITH_SYS_TABLE": withSysTable, "IGNORE_STATS": ignoreStats, @@ -396,6 +398,7 @@ var tokenMap = map[string]int{ "EXPR_PUSHDOWN_BLACKLIST": exprPushdownBlacklist, "EXTENDED": extended, "EXPLORE": explore, + "EXTERNAL": external, "EXTRACT": extract, "FALSE": falseKwd, "FAULTS": faultsSym, @@ -538,6 +541,7 @@ var tokenMap = map[string]int{ "LONGBLOB": longblobType, "LONGTEXT": longtextType, "LOW_PRIORITY": lowPriority, + "MANUAL": manual, "MASTER": master, "MASKING": masking, "MATCH": match, @@ -625,6 +629,7 @@ var tokenMap = map[string]int{ "PAGE_CHECKSUM": pageChecksum, "PAGE_COMPRESSED": pageCompressed, "PAGE_COMPRESSION_LEVEL": pageCompressionLevel, + "PARALLEL": parallel, "PARSER": parser, "PARTIAL": partial, "PARTITION": partition, @@ -661,6 +666,7 @@ var tokenMap = map[string]int{ "PROFILES": profiles, "PROXY": proxy, "PURGE": purge, + "QUALIFY": qualify, "QUARTER": quarter, "QUERIES": queries, "QUERY": query, diff --git a/parser/parse_replication.go b/parser/parse_replication.go new file mode 100644 index 0000000..d13ea97 --- /dev/null +++ b/parser/parse_replication.go @@ -0,0 +1,72 @@ +// Copyright 2026 The sqlc Authors. +// +// 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 parser + +import ( + "strings" + + "github.com/sqlc-dev/marino/ast" +) + +func init() { + rdRegister(change, (*rdParser).parseChangeReplicationSourceStmt) +} + +// parseChangeReplicationSourceStmt implements ChangeReplicationSourceStmt. +// The statement postdates the goyacc grammar (parser.y has no production +// for it); the shape follows the MySQL 26.7 reference manual: +// +// "CHANGE" "REPLICATION" "SOURCE" "TO" ReplicationSourceOption +// ("," ReplicationSourceOption)* ("FOR" "CHANNEL" stringLit)? +// +// Option names are not validated against the server's option list, so the +// MySQL 26.7 Change Stream Applier options (APPLIER_VERSION, +// APPLIER_WORKER_COUNT, APPLIER_EVENT_MEMORY_LIMIT) parse like any other. +// The removed CHANGE MASTER TO spelling is not parsed, matching MySQL 8.4+. +func (r *rdParser) parseChangeReplicationSourceStmt() ast.StmtNode { + r.expect(change) + r.expect(replication) + r.expect(source) + r.expect(to) + stmt := &ast.ChangeReplicationSourceStmt{ + Options: []*ast.ReplicationSourceOption{r.parseReplicationSourceOption()}, + } + for r.accept(int(',')) { + stmt.Options = append(stmt.Options, r.parseReplicationSourceOption()) + } + if r.accept(forKwd) { + r.expect(channel) + stmt.Channel = r.expect(stringLit).lit + } + return stmt +} + +// parseReplicationSourceOption implements ReplicationSourceOption: +// Identifier eq (stringLit | intLit | decLit | floatLit). +func (r *rdParser) parseReplicationSourceOption() *ast.ReplicationSourceOption { + name := strings.ToUpper(r.parseIdentifier()) + r.expect(eq) + var value ast.ValueExpr + switch r.tok() { + case stringLit: + value = ast.NewValueExpr(r.cur().lit, "", "") + r.advance() + case intLit, decLit, floatLit: + value = ast.NewValueExpr(r.cur().item, "", "") + r.advance() + default: + r.syntaxError() + } + return &ast.ReplicationSourceOption{Name: name, Value: value} +} diff --git a/parser/parser_test.go b/parser/parser_test.go index 44f6cb7..84a9240 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -61,6 +61,7 @@ func TestSimple(t *testing.T) { "cumeDist", "denseRank", "firstValue", "lag", "lastValue", "lead", "nthValue", "ntile", "over", "percentRank", "rank", "row", "rows", "rowNumber", "window", "linear", "match", "until", "placement", "tablesample", "failedLoginAttempts", "passwordLockTime", + "cube", "external", "qualify", // TODO: support the following keywords // "with", } @@ -105,7 +106,7 @@ func TestSimple(t *testing.T) { "following", "preceding", "unbounded", "respect", "nulls", "current", "last", "against", "expansion", "chain", "error", "general", "nvarchar", "pack_keys", "p", "shard_row_id_bits", "pre_split_regions", "constraints", "role", "replicas", "policy", "s3", "strict", "running", "stop", "preserve", "placement", "attributes", "attribute", "resource", - "burstable", "calibrate", "masking", "rollup", + "burstable", "calibrate", "masking", "rollup", "manual", "parallel", "channel", } for _, kw := range unreservedKws { src := fmt.Sprintf("SELECT %s FROM tbl;", kw) @@ -7619,6 +7620,78 @@ func TestAnalyze(t *testing.T) { RunTest(t, table, false, false) } +func TestChangeReplicationSource(t *testing.T) { + table := []testCase{ + // MySQL 26.7 Change Stream Applier options + {"change replication source to applier_version = 2", true, "CHANGE REPLICATION SOURCE TO APPLIER_VERSION = 2"}, + {"CHANGE REPLICATION SOURCE TO APPLIER_VERSION = 1, APPLIER_WORKER_COUNT = 64, APPLIER_EVENT_MEMORY_LIMIT = 1073741824 FOR CHANNEL 'channel_1'", true, "CHANGE REPLICATION SOURCE TO APPLIER_VERSION = 1, APPLIER_WORKER_COUNT = 64, APPLIER_EVENT_MEMORY_LIMIT = 1073741824 FOR CHANNEL 'channel_1'"}, + + // generic connection options + {"change replication source to source_host = 'replica.example.com', source_port = 3306", true, "CHANGE REPLICATION SOURCE TO SOURCE_HOST = 'replica.example.com', SOURCE_PORT = 3306"}, + {"change replication source to source_auto_position = 1 for channel 'group_replication_recovery'", true, "CHANGE REPLICATION SOURCE TO SOURCE_AUTO_POSITION = 1 FOR CHANNEL 'group_replication_recovery'"}, + {"change replication source to source_heartbeat_period = 60.5", true, "CHANGE REPLICATION SOURCE TO SOURCE_HEARTBEAT_PERIOD = 60.5"}, + + // negative test cases + {"change replication source to", false, ""}, + {"change replication source applier_version = 2", false, ""}, + {"change master to master_host = 'h'", false, ""}, + {"change replication source to applier_version", false, ""}, + {"change replication source to applier_version = 2,", false, ""}, + {"change replication source to applier_version = 2 for channel", false, ""}, + {"change replication source to for channel 'ch'", false, ""}, + } + RunTest(t, table, false, false) + + // SOURCE_PASSWORD is masked in the sensitive-statement text. + p := parser.New() + stmt, err := p.ParseOneStmt("change replication source to source_user = 'repl', source_password = 'hunter2'", "", "") + if err != nil { + t.Fatal(err) + } + sensitive, ok := stmt.(ast.SensitiveStmtNode) + if !ok { + t.Fatalf("expected ChangeReplicationSourceStmt to implement SensitiveStmtNode, got %T", stmt) + } + secure := sensitive.SecureText() + if strings.Contains(secure, "hunter2") { + t.Fatalf("SecureText leaked the password: %q", secure) + } + want := "CHANGE REPLICATION SOURCE TO SOURCE_USER = 'repl', SOURCE_PASSWORD = 'xxxxxx'" + if secure != want { + t.Fatalf("got %q, want %q", secure, want) + } +} + +func TestMySQLReservedWordCompat(t *testing.T) { + // MySQL reserves CUBE, EXTERNAL, QUALIFY, and TABLESAMPLE (documented + // since 8.4; information_schema.KEYWORDS corrected in 26.7, MySQL Bug + // #114874), while MANUAL and PARALLEL are non-reserved keywords. + table := []testCase{ + // reserved: rejected as unquoted identifiers + {"create table cube (a int)", false, ""}, + {"create table external (a int)", false, ""}, + {"create table qualify (a int)", false, ""}, + {"select cube from t", false, ""}, + {"select external from t", false, ""}, + {"select qualify from t", false, ""}, + + // reserved: usable when quoted + {"create table `cube` (a int)", true, "CREATE TABLE `cube` (`a` INT)"}, + {"create table `external` (a int)", true, "CREATE TABLE `external` (`a` INT)"}, + {"create table `qualify` (a int)", true, "CREATE TABLE `qualify` (`a` INT)"}, + + // reserved: usable unquoted after a qualifying dot, like MySQL + {"select t.cube from t", true, "SELECT `t`.`cube` FROM `t`"}, + {"select t.external from t", true, "SELECT `t`.`external` FROM `t`"}, + {"select t.qualify from t", true, "SELECT `t`.`qualify` FROM `t`"}, + + // non-reserved: valid identifiers anywhere + {"create table manual (parallel int)", true, "CREATE TABLE `manual` (`parallel` INT)"}, + {"select manual, parallel from t", true, "SELECT `manual`,`parallel` FROM `t`"}, + } + RunTest(t, table, false, false) +} + func TestTableSample(t *testing.T) { table := []testCase{ // positive test cases diff --git a/parser/token_kinds.go b/parser/token_kinds.go index 3c56a89..5aa181b 100644 --- a/parser/token_kinds.go +++ b/parser/token_kinds.go @@ -165,6 +165,7 @@ const ( causal = 57638 chain = 57639 change = 57380 + channel = 58252 charType = 57381 character = 57382 charsetKwd = 57640 @@ -221,6 +222,7 @@ const ( csvNull = 57675 csvSeparator = 57676 csvTrimLastSeparators = 57677 + cube = 58247 cumeDist = 57391 curDate = 58011 curTime = 58012 @@ -321,6 +323,7 @@ const ( explore = 57719 exprPushdownBlacklist = 58022 extended = 57720 + external = 58248 extract = 58023 failedLoginAttempts = 57721 falseKwd = 57425 @@ -504,6 +507,7 @@ const ( lowerThanWith = 58223 lowerThenOrder = 58235 lsh = 58213 + manual = 58249 masking = 57775 master = 57776 match = 57489 @@ -606,6 +610,7 @@ const ( pageCompressed = 57826 pageCompressionLevel = 57827 pageSym = 57824 + parallel = 58250 paramMarker = 58217 parser = 57828 partial = 57829 @@ -648,6 +653,7 @@ const ( profiles = 57850 proxy = 57851 purge = 57852 + qualify = 58251 quarter = 57853 queries = 57854 query = 57855