From 07a248b81214b0f2a646b3d3c93ca7ad7f0570c9 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 17:46:08 +0530 Subject: [PATCH 01/95] fix(parser): model EXPLAIN as ExplainStatement EXPLAIN SELECT ... discarded the query and formatted as DESCRIBE "SELECT"; EXPLAIN ANALYZE|VERBOSE split into DESCRIBE ANALYZE; , so formatting turned a non-executing EXPLAIN into the executable statement. Add ast.ExplainStatement {Keyword, Options, Statement} covering EXPLAIN, DESCRIBE and DESC with options (ANALYZE, (FORMAT JSON), QUERY PLAN, FORMAT=JSON, PIPELINE, PLAN FOR), render it in the formatter, and let the CLI format it instead of refusing it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_check_summary_test.go | 4 +- cmd/gosqlx/cmd/format_explain_test.go | 51 ++++++++---- cmd/gosqlx/cmd/formatter.go | 8 +- pkg/formatter/render.go | 2 + pkg/formatter/render_ddl.go | 17 ++++ pkg/sql/ast/ast_statements.go | 19 +++++ pkg/sql/ast/sql.go | 15 ++++ pkg/sql/parser/explain_test.go | 86 +++++++++++++++++++++ pkg/sql/parser/mysql.go | 80 +++++++++++++++---- pkg/sql/parser/parser.go | 3 +- 10 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 pkg/sql/parser/explain_test.go diff --git a/cmd/gosqlx/cmd/format_check_summary_test.go b/cmd/gosqlx/cmd/format_check_summary_test.go index 20335d36..daceca04 100644 --- a/cmd/gosqlx/cmd/format_check_summary_test.go +++ b/cmd/gosqlx/cmd/format_check_summary_test.go @@ -28,11 +28,11 @@ func TestFormat_CheckSummaryOmitsFailedFiles(t *testing.T) { dir := t.TempDir() good := filepath.Join(dir, "good.sql") bad := filepath.Join(dir, "bad.sql") - // EXPLAIN is refused by the formatter, so bad.sql always fails. + // DESCRIBE is refused by the formatter, so bad.sql always fails. if err := os.WriteFile(good, []byte("SELECT a FROM t"), 0600); err != nil { t.Fatal(err) } - if err := os.WriteFile(bad, []byte("EXPLAIN VERBOSE DELETE FROM users"), 0600); err != nil { + if err := os.WriteFile(bad, []byte("DESCRIBE users"), 0600); err != nil { t.Fatal(err) } diff --git a/cmd/gosqlx/cmd/format_explain_test.go b/cmd/gosqlx/cmd/format_explain_test.go index e0e6dfc7..864c1ddf 100644 --- a/cmd/gosqlx/cmd/format_explain_test.go +++ b/cmd/gosqlx/cmd/format_explain_test.go @@ -22,26 +22,45 @@ import ( "testing" ) -// TestFormatSQL_RefusesExplainAndDescribe guards against gosqlx format turning -// a non-executing EXPLAIN into an executable statement. The parser splits -// "EXPLAIN VERBOSE|ANALYZE " into a DescribeStatement plus , and -// DescribeStatement drops EXPLAIN options and object kinds, so the CLI must -// refuse it instead of writing "DESCRIBE VERBOSE; DELETE FROM users". -func TestFormatSQL_RefusesExplainAndDescribe(t *testing.T) { +// TestFormatSQL_PreservesExplain checks that EXPLAIN formats as one +// statement that keeps EXPLAIN and its options, so a non-executing EXPLAIN is +// never rewritten into the executable statement. +func TestFormatSQL_PreservesExplain(t *testing.T) { + tests := []struct { + dialect string + in string + want string // compact, upper-cased output + }{ + {"", "EXPLAIN VERBOSE DELETE FROM users", "EXPLAIN VERBOSE DELETE FROM users"}, + {"postgresql", "EXPLAIN VERBOSE DELETE FROM users;\n", "EXPLAIN VERBOSE DELETE FROM users"}, + {"", "explain verbose update accounts set balance = 0", "EXPLAIN VERBOSE UPDATE accounts SET balance = 0"}, + {"", "EXPLAIN VERBOSE INSERT INTO t (a) VALUES (1)", "EXPLAIN VERBOSE INSERT INTO t (a) VALUES (1)"}, + {"", "EXPLAIN ANALYZE DELETE FROM users", "EXPLAIN ANALYZE DELETE FROM users"}, + {"mysql", "EXPLAIN ANALYZE SELECT * FROM users", "EXPLAIN ANALYZE SELECT * FROM users"}, + {"", "EXPLAIN SELECT 1", "EXPLAIN SELECT 1"}, + } + for _, tc := range tests { + out, err := formatWith(tc.in, CLIFormatterOptions{Compact: true, Uppercase: true, Dialect: tc.dialect}) + if err != nil { + t.Fatalf("formatSQL(%q): %v", tc.in, err) + } + if got := strings.TrimSuffix(strings.TrimSpace(out), ";"); got != tc.want { + t.Errorf("formatSQL(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestFormatSQL_RefusesDescribe guards statements the formatter cannot +// rewrite faithfully: DESCRIBE drops object kinds, and EXPLAIN of a statement +// the parser does not wrap would otherwise be split in two. +func TestFormatSQL_RefusesDescribe(t *testing.T) { tests := []struct { name string dialect string in string }{ - {name: "explain verbose delete", in: "EXPLAIN VERBOSE DELETE FROM users"}, - {name: "explain verbose delete postgresql", dialect: "postgresql", in: "EXPLAIN VERBOSE DELETE FROM users;\n"}, - {name: "explain verbose update", in: "EXPLAIN VERBOSE UPDATE accounts SET balance = 0"}, {name: "explain verbose drop", in: "EXPLAIN VERBOSE DROP TABLE users"}, {name: "explain verbose truncate", in: "EXPLAIN VERBOSE TRUNCATE TABLE users"}, - {name: "explain verbose insert", in: "EXPLAIN VERBOSE INSERT INTO t (a) VALUES (1)"}, - {name: "explain analyze delete", in: "EXPLAIN ANALYZE DELETE FROM users"}, - {name: "mysql explain analyze select", dialect: "mysql", in: "EXPLAIN ANALYZE SELECT * FROM users"}, - {name: "explain select", in: "EXPLAIN SELECT 1"}, {name: "describe table", in: "DESCRIBE users"}, {name: "snowflake describe table kind", dialect: "snowflake", in: "DESCRIBE TABLE users"}, } @@ -64,10 +83,10 @@ func TestFormatSQL_RefusesExplainAndDescribe(t *testing.T) { } } -// TestFormat_InPlaceLeavesExplainUnchanged checks the end-to-end format -i +// TestFormat_InPlaceLeavesDescribeUnchanged checks the end-to-end format -i // path: the file must be left untouched and counted as failed. -func TestFormat_InPlaceLeavesExplainUnchanged(t *testing.T) { - const original = "EXPLAIN VERBOSE DELETE FROM users;\n" +func TestFormat_InPlaceLeavesDescribeUnchanged(t *testing.T) { + const original = "DESCRIBE users;\n" path := filepath.Join(t.TempDir(), "r.sql") if err := os.WriteFile(path, []byte(original), 0600); err != nil { t.Fatal(err) diff --git a/cmd/gosqlx/cmd/formatter.go b/cmd/gosqlx/cmd/formatter.go index 9bbe77de..788f3fc6 100644 --- a/cmd/gosqlx/cmd/formatter.go +++ b/cmd/gosqlx/cmd/formatter.go @@ -621,11 +621,9 @@ func FormatterOptionsFromConfig(cfg *config.Config, flagsChanged map[string]bool // checkFormattable refuses parse results the CLI cannot rewrite faithfully, so // format, format -i and format -o leave such input unchanged. // -// DescribeStatement keeps only a name: it drops EXPLAIN options, the explained -// query and DESCRIBE object kinds, and "EXPLAIN VERBOSE|ANALYZE " parses -// as a DescribeStatement followed by , which would rewrite a -// non-executing EXPLAIN into the executable statement. UnsupportedStatement is -// rendered as a comment. Both are refused by type. As a general guard, a parse +// DescribeStatement keeps only a name and drops DESCRIBE object kinds +// (EXPLAIN is an ExplainStatement, which round-trips). +// UnsupportedStatement is rendered as a comment. Both are refused by type. As a general guard, a parse // with more statements than semicolon-separated segments in the input means // the parser split a statement, which re-parsing the output cannot detect. func checkFormattable(tree *ast.AST, tokens []models.TokenWithSpan) error { diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 2b95047b..27e8c32f 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -166,6 +166,8 @@ func FormatStatement(s ast.Statement, opts ast.FormatOptions) string { return renderShow(v, opts) case *ast.DescribeStatement: return renderDescribe(v, opts) + case *ast.ExplainStatement: + return renderExplain(v, opts) case *ast.UnsupportedStatement: return renderUnsupported(v, opts) default: diff --git a/pkg/formatter/render_ddl.go b/pkg/formatter/render_ddl.go index 76ac2f99..4f21a8f1 100644 --- a/pkg/formatter/render_ddl.go +++ b/pkg/formatter/render_ddl.go @@ -129,6 +129,23 @@ func renderDescribe(s *ast.DescribeStatement, opts ast.FormatOptions) string { return sb.String() } +// renderExplain renders EXPLAIN / DESCRIBE . +func renderExplain(s *ast.ExplainStatement, opts ast.FormatOptions) string { + f := newNodeFormatter(opts) + sb := f.sb + + sb.WriteString(f.kw(s.Keyword)) + if s.Options != "" { + sb.WriteString(" ") + sb.WriteString(s.Options) + } + if s.Statement != nil { + sb.WriteString(" ") + sb.WriteString(FormatStatement(s.Statement, opts)) + } + return sb.String() +} + // renderUnsupported renders an UnsupportedStatement as a SQL comment // preserving the original SQL fragment. This prevents silently producing // corrupt SQL for statement types the formatter cannot handle. The fragment diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index b90a3e79..8eddae16 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -755,6 +755,25 @@ func (d *DescribeStatement) statementNode() {} func (d DescribeStatement) TokenLiteral() string { return "DESCRIBE" } func (d DescribeStatement) Children() []Node { return nil } +// ExplainStatement represents EXPLAIN / DESCRIBE / DESC applied to a +// statement, e.g. "EXPLAIN ANALYZE SELECT ...", "EXPLAIN (FORMAT JSON) ...", +// "EXPLAIN QUERY PLAN ..." (SQLite) or "DESCRIBE PIPELINE ..." (ClickHouse). +// DESCRIBE of a table name is a DescribeStatement instead. +type ExplainStatement struct { + Keyword string // EXPLAIN, DESCRIBE or DESC, upper-cased + Options string // text between the keyword and the statement, e.g. "ANALYZE VERBOSE" + Statement Statement // the explained statement +} + +func (e *ExplainStatement) statementNode() {} +func (e ExplainStatement) TokenLiteral() string { return e.Keyword } +func (e ExplainStatement) Children() []Node { + if e.Statement == nil { + return nil + } + return []Node{e.Statement} +} + // UnsupportedStatement represents a SQL statement that was parsed but not // fully modeled in the AST. The parser consumed and validated the tokens // but no dedicated AST node exists yet for this statement kind. diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index a1a24cca..7372fb1b 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -2771,6 +2771,21 @@ func (d *DescribeStatement) SQL() string { return "DESCRIBE " + QuoteQualifiedName(d.TableName) } +// SQL returns the SQL string for an EXPLAIN / DESCRIBE . +func (e *ExplainStatement) SQL() string { + if e == nil { + return "" + } + out := e.Keyword + if e.Options != "" { + out += " " + e.Options + } + if e.Statement != nil { + out += " " + stmtSQL(e.Statement) + } + return out +} + // SQL returns the SQL string for a REPLACE statement (MySQL). func (r *ReplaceStatement) SQL() string { if r == nil { diff --git a/pkg/sql/parser/explain_test.go b/pkg/sql/parser/explain_test.go new file mode 100644 index 00000000..b219788a --- /dev/null +++ b/pkg/sql/parser/explain_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "fmt" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// EXPLAIN must keep the explained statement and its options instead of +// collapsing to DESCRIBE "SELECT" or splitting into two statements. +func TestParser_ExplainStatement(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + keyword string + options string + inner string // %T of the explained statement + }{ + {keywords.DialectPostgreSQL, "EXPLAIN SELECT * FROM users WHERE id = 1", "EXPLAIN", "", "*ast.SelectStatement"}, + {keywords.DialectPostgreSQL, "EXPLAIN ANALYZE UPDATE t SET x = 1", "EXPLAIN", "ANALYZE", "*ast.UpdateStatement"}, + {keywords.DialectPostgreSQL, "explain (analyze, format json) select 1", "EXPLAIN", "(ANALYZE, FORMAT JSON)", "*ast.SelectStatement"}, + {keywords.DialectPostgreSQL, "EXPLAIN WITH c AS (SELECT 1) SELECT * FROM c", "EXPLAIN", "", "*ast.SelectStatement"}, + {keywords.DialectSQLite, "EXPLAIN QUERY PLAN SELECT * FROM t WHERE a = 2", "EXPLAIN", "QUERY PLAN", "*ast.SelectStatement"}, + {keywords.DialectMySQL, "EXPLAIN FORMAT=JSON DELETE FROM t WHERE id = 1", "EXPLAIN", "FORMAT = JSON", "*ast.DeleteStatement"}, + {keywords.DialectMySQL, "DESCRIBE ANALYZE SELECT * FROM users", "DESCRIBE", "ANALYZE", "*ast.SelectStatement"}, + {keywords.DialectClickHouse, "DESCRIBE PIPELINE SELECT count() FROM events", "DESCRIBE", "PIPELINE", "*ast.SelectStatement"}, + {keywords.DialectOracle, "EXPLAIN PLAN FOR SELECT * FROM emp", "EXPLAIN", "PLAN FOR", "*ast.SelectStatement"}, + } + for _, tt := range tests { + t.Run(tt.sql, func(t *testing.T) { + tree, err := ParseWithDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(tree.Statements) != 1 { + t.Fatalf("got %d statements, want 1", len(tree.Statements)) + } + ex, ok := tree.Statements[0].(*ast.ExplainStatement) + if !ok { + t.Fatalf("got %T, want *ast.ExplainStatement", tree.Statements[0]) + } + if ex.Keyword != tt.keyword || ex.Options != tt.options { + t.Errorf("keyword/options = %q/%q, want %q/%q", ex.Keyword, ex.Options, tt.keyword, tt.options) + } + if got := typeName(ex.Statement); got != tt.inner { + t.Errorf("inner statement %s, want %s", got, tt.inner) + } + }) + } +} + +// DESCRIBE of a table name stays a DescribeStatement. +func TestParser_DescribeTableStillDescribe(t *testing.T) { + for _, sql := range []string{"DESCRIBE users", "DESC orders", "EXPLAIN users", "DESCRIBE TABLE db.users"} { + tree, err := ParseWithDialect(sql, keywords.DialectMySQL) + if err != nil { + t.Fatalf("%s: %v", sql, err) + } + if _, ok := tree.Statements[0].(*ast.DescribeStatement); !ok { + t.Errorf("%s: got %T, want *ast.DescribeStatement", sql, tree.Statements[0]) + } + } +} + +func typeName(v any) string { + if v == nil { + return "" + } + return fmt.Sprintf("%T", v) +} diff --git a/pkg/sql/parser/mysql.go b/pkg/sql/parser/mysql.go index 7158bfdd..77cff7df 100644 --- a/pkg/sql/parser/mysql.go +++ b/pkg/sql/parser/mysql.go @@ -150,23 +150,25 @@ func (p *Parser) parseShowStatement() (ast.Statement, error) { return show, nil } -// parseDescribeStatement parses DESCRIBE/DESC/EXPLAIN table_name -func (p *Parser) parseDescribeStatement() (ast.Statement, error) { - // For EXPLAIN SELECT ..., defer to parseStatement for the SELECT - // For DESCRIBE table_name, just parse the table name - if p.isType(models.TokenTypeSelect) { - // EXPLAIN SELECT ... - treat as describe with the query text - // For now, just skip to parse the select - p.advance() - stmt, err := p.parseSelectWithSetOperations() +// parseDescribeStatement parses DESCRIBE/DESC/EXPLAIN. When a statement +// follows (optionally after options such as ANALYZE, "(FORMAT JSON)", +// "QUERY PLAN" or "PLAN FOR") it returns an ExplainStatement wrapping it; +// otherwise it parses DESCRIBE [object kind] table_name. +func (p *Parser) parseDescribeStatement(keyword string) (ast.Statement, error) { + if n := p.explainOptionTokens(); n >= 0 { + explain := &ast.ExplainStatement{Keyword: keyword} + parts := make([]string, 0, n) + for i := 0; i < n; i++ { + parts = append(parts, explainTokenText(p.currentToken.Token)) + p.advance() + } + explain.Options = joinExplainOptions(parts) + stmt, err := p.parseStatement() if err != nil { return nil, err } - // Wrap in a describe - _ = stmt - desc := ast.GetDescribeStatement() - desc.TableName = "SELECT" - return desc, nil + explain.Statement = stmt + return explain, nil } // Snowflake: DESCRIBE TABLE , DESCRIBE VIEW , DESCRIBE STAGE @@ -195,6 +197,56 @@ func (p *Parser) parseDescribeStatement() (ast.Statement, error) { return desc, nil } +// maxExplainOptionTokens bounds the lookahead for an explained statement. +const maxExplainOptionTokens = 64 + +// explainOptionTokens reports how many option tokens sit between EXPLAIN / +// DESCRIBE and the statement being explained, or -1 if no statement follows +// (DESCRIBE table_name). Parenthesised option lists are skipped as a unit. +func (p *Parser) explainOptionTokens() int { + depth := 0 + for i := 0; i < maxExplainOptionTokens && p.currentPos+i < len(p.tokens); i++ { + switch p.tokens[p.currentPos+i].Token.Type { + case models.TokenTypeLParen: + depth++ + case models.TokenTypeRParen: + depth-- + case models.TokenTypeSemicolon, models.TokenTypeEOF: + return -1 + case models.TokenTypeSelect, models.TokenTypeWith, models.TokenTypeInsert, + models.TokenTypeUpdate, models.TokenTypeDelete, models.TokenTypeMerge, + models.TokenTypeReplace: + if depth == 0 { + return i + } + } + } + return -1 +} + +// explainTokenText renders an EXPLAIN option token back to SQL text; option +// words are keywords, so they are upper-cased. +func explainTokenText(tok models.Token) string { + switch tok.Type { + case models.TokenTypeSingleQuotedString, models.TokenTypeString: + return "'" + strings.ReplaceAll(tok.Value, "'", "''") + "'" + } + return strings.ToUpper(tok.Value) +} + +// joinExplainOptions joins option tokens with single spaces, except inside +// parentheses and before commas: "(FORMAT JSON, ANALYZE)". +func joinExplainOptions(parts []string) string { + var sb strings.Builder + for i, part := range parts { + if i > 0 && part != ")" && part != "," && parts[i-1] != "(" { + sb.WriteByte(' ') + } + sb.WriteString(part) + } + return sb.String() +} + // parseReplaceStatement parses MySQL REPLACE INTO statement func (p *Parser) parseReplaceStatement() (ast.Statement, error) { // Expect INTO diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 0ad0a48a..d3c6a4af 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -853,8 +853,9 @@ func (p *Parser) parseStatement() (ast.Statement, error) { case models.TokenTypeDescribe, models.TokenTypeExplain, models.TokenTypeDesc: // DESC is the ORDER-BY sort-direction token but also a synonym for // DESCRIBE at statement position (Oracle, Snowflake, MySQL). + keyword := strings.ToUpper(p.currentToken.Token.Value) p.advance() - return p.parseDescribeStatement() + return p.parseDescribeStatement(keyword) case models.TokenTypeReplace: p.advance() return p.parseReplaceStatement() From a109ed954a72a1f42402434f4ec31c099b8476ae Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 17:46:29 +0530 Subject: [PATCH 02/95] test(realworld): add 2.7k-statement multi-dialect real-world corpus with ratchet Adds testdata/realworld// corpora (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, SQLite, Snowflake, ClickHouse) of production-style app, reporting, ETL, DDL and dialect-specific SQL, plus: - TestRealWorldCorpus: split -> parse -> format -> reparse -> idempotency -> dropped-word check for every statement, ratcheted against testdata/realworld/known_failures.txt (-update-known-failures regenerates) - scripts/dialect-audit: CLI harness for the same pipeline Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/gosqlx/realworld_corpus_test.go | 271 ++++ scripts/dialect-audit/main.go | 216 +++ testdata/realworld/clickhouse/01_app_crud.sql | 26 + .../realworld/clickhouse/02_reporting.sql | 32 + .../clickhouse/03_ddl_migrations.sql | 48 + .../clickhouse/04_window_analytics.sql | 32 + .../clickhouse/05_dialect_specific.sql | 73 + .../clickhouse/06_materialized_views.sql | 37 + .../realworld/clickhouse/07_observability.sql | 47 + .../realworld/clickhouse/08_etl_pipelines.sql | 41 + .../realworld/clickhouse/09_deep_nesting.sql | 38 + .../realworld/clickhouse/10_misc_real.sql | 29 + testdata/realworld/known_failures.txt | 1255 +++++++++++++++++ testdata/realworld/mariadb/01_app_crud.sql | 36 + testdata/realworld/mariadb/02_reporting.sql | 46 + .../realworld/mariadb/03_ddl_migrations.sql | 55 + .../realworld/mariadb/04_window_analytics.sql | 24 + .../realworld/mariadb/05_mariadb_specific.sql | 71 + testdata/realworld/mariadb/06_etl_admin.sql | 42 + .../realworld/mariadb/07_stored_programs.sql | 20 + .../realworld/mariadb/08_complex_queries.sql | 32 + .../mariadb/09_mysql_compat_misc.sql | 57 + testdata/realworld/mysql/01_app_crud.sql | 32 + testdata/realworld/mysql/02_reporting.sql | 36 + .../realworld/mysql/03_ddl_migrations.sql | 45 + .../realworld/mysql/04_window_analytics.sql | 23 + .../realworld/mysql/05_dialect_specific.sql | 74 + testdata/realworld/mysql/06_wordpress.sql | 22 + .../realworld/mysql/07_laravel_magento.sql | 17 + testdata/realworld/mysql/08_etl_admin.sql | 38 + testdata/realworld/mysql/09_deep_nesting.sql | 35 + .../realworld/mysql/10_misc_functions.sql | 31 + .../realworld/mysql/11_stored_programs.sql | 37 + testdata/realworld/mysql/12_orm_misc.sql | 27 + testdata/realworld/oracle/01_app_crud.sql | 39 + testdata/realworld/oracle/02_reporting.sql | 38 + .../realworld/oracle/03_ddl_migrations.sql | 62 + .../realworld/oracle/04_window_analytics.sql | 29 + .../realworld/oracle/05_dialect_specific.sql | 73 + testdata/realworld/oracle/06_etl_merge.sql | 39 + testdata/realworld/oracle/07_admin_ops.sql | 53 + .../realworld/oracle/08_complex_nested.sql | 27 + testdata/realworld/postgresql/01_app_crud.sql | 27 + .../realworld/postgresql/02_reporting.sql | 29 + .../postgresql/03_ddl_migrations.sql | 67 + .../postgresql/04_window_analytics.sql | 23 + .../postgresql/05_dialect_specific.sql | 58 + .../realworld/postgresql/06_cte_dml_etl.sql | 28 + .../realworld/postgresql/07_admin_ops.sql | 46 + .../postgresql/08_supabase_rls_misc.sql | 30 + testdata/realworld/snowflake/01_app_crud.sql | 23 + testdata/realworld/snowflake/02_reporting.sql | 28 + .../realworld/snowflake/03_ddl_migrations.sql | 38 + .../snowflake/04_window_analytics.sql | 19 + .../snowflake/05_dialect_specific.sql | 53 + testdata/realworld/snowflake/06_etl_merge.sql | 37 + testdata/realworld/snowflake/07_admin.sql | 54 + .../realworld/snowflake/08_pivot_match.sql | 24 + .../realworld/snowflake/09_dbt_models.sql | 19 + .../realworld/snowflake/10_complex_nested.sql | 41 + testdata/realworld/sqlite/01_app_crud.sql | 37 + .../realworld/sqlite/02_upsert_replace.sql | 32 + .../realworld/sqlite/03_ddl_migrations.sql | 74 + .../realworld/sqlite/04_window_analytics.sql | 26 + .../realworld/sqlite/05_dialect_specific.sql | 70 + testdata/realworld/sqlite/06_json.sql | 29 + .../realworld/sqlite/07_ctes_recursive.sql | 36 + testdata/realworld/sqlite/08_reporting.sql | 32 + testdata/realworld/sqlite/09_etl_admin.sql | 42 + .../sqlite/10_mobile_offline_sync.sql | 46 + testdata/realworld/sqlite/11_deep_nesting.sql | 35 + .../realworld/sqlite/12_orm_generated.sql | 21 + testdata/realworld/sqlserver/01_app_crud.sql | 27 + testdata/realworld/sqlserver/02_reporting.sql | 27 + .../realworld/sqlserver/03_ddl_migrations.sql | 33 + .../sqlserver/04_window_analytics.sql | 18 + .../sqlserver/05_dialect_specific.sql | 36 + testdata/realworld/sqlserver/06_merge_etl.sql | 20 + testdata/realworld/sqlserver/07_ctes_deep.sql | 22 + .../realworld/sqlserver/08_procs_batches.sql | 33 + testdata/realworld/sqlserver/09_admin_dmv.sql | 21 + testdata/realworld/sqlserver/10_etl_mixed.sql | 32 + 82 files changed, 4698 insertions(+) create mode 100644 pkg/gosqlx/realworld_corpus_test.go create mode 100644 scripts/dialect-audit/main.go create mode 100644 testdata/realworld/clickhouse/01_app_crud.sql create mode 100644 testdata/realworld/clickhouse/02_reporting.sql create mode 100644 testdata/realworld/clickhouse/03_ddl_migrations.sql create mode 100644 testdata/realworld/clickhouse/04_window_analytics.sql create mode 100644 testdata/realworld/clickhouse/05_dialect_specific.sql create mode 100644 testdata/realworld/clickhouse/06_materialized_views.sql create mode 100644 testdata/realworld/clickhouse/07_observability.sql create mode 100644 testdata/realworld/clickhouse/08_etl_pipelines.sql create mode 100644 testdata/realworld/clickhouse/09_deep_nesting.sql create mode 100644 testdata/realworld/clickhouse/10_misc_real.sql create mode 100644 testdata/realworld/known_failures.txt create mode 100644 testdata/realworld/mariadb/01_app_crud.sql create mode 100644 testdata/realworld/mariadb/02_reporting.sql create mode 100644 testdata/realworld/mariadb/03_ddl_migrations.sql create mode 100644 testdata/realworld/mariadb/04_window_analytics.sql create mode 100644 testdata/realworld/mariadb/05_mariadb_specific.sql create mode 100644 testdata/realworld/mariadb/06_etl_admin.sql create mode 100644 testdata/realworld/mariadb/07_stored_programs.sql create mode 100644 testdata/realworld/mariadb/08_complex_queries.sql create mode 100644 testdata/realworld/mariadb/09_mysql_compat_misc.sql create mode 100644 testdata/realworld/mysql/01_app_crud.sql create mode 100644 testdata/realworld/mysql/02_reporting.sql create mode 100644 testdata/realworld/mysql/03_ddl_migrations.sql create mode 100644 testdata/realworld/mysql/04_window_analytics.sql create mode 100644 testdata/realworld/mysql/05_dialect_specific.sql create mode 100644 testdata/realworld/mysql/06_wordpress.sql create mode 100644 testdata/realworld/mysql/07_laravel_magento.sql create mode 100644 testdata/realworld/mysql/08_etl_admin.sql create mode 100644 testdata/realworld/mysql/09_deep_nesting.sql create mode 100644 testdata/realworld/mysql/10_misc_functions.sql create mode 100644 testdata/realworld/mysql/11_stored_programs.sql create mode 100644 testdata/realworld/mysql/12_orm_misc.sql create mode 100644 testdata/realworld/oracle/01_app_crud.sql create mode 100644 testdata/realworld/oracle/02_reporting.sql create mode 100644 testdata/realworld/oracle/03_ddl_migrations.sql create mode 100644 testdata/realworld/oracle/04_window_analytics.sql create mode 100644 testdata/realworld/oracle/05_dialect_specific.sql create mode 100644 testdata/realworld/oracle/06_etl_merge.sql create mode 100644 testdata/realworld/oracle/07_admin_ops.sql create mode 100644 testdata/realworld/oracle/08_complex_nested.sql create mode 100644 testdata/realworld/postgresql/01_app_crud.sql create mode 100644 testdata/realworld/postgresql/02_reporting.sql create mode 100644 testdata/realworld/postgresql/03_ddl_migrations.sql create mode 100644 testdata/realworld/postgresql/04_window_analytics.sql create mode 100644 testdata/realworld/postgresql/05_dialect_specific.sql create mode 100644 testdata/realworld/postgresql/06_cte_dml_etl.sql create mode 100644 testdata/realworld/postgresql/07_admin_ops.sql create mode 100644 testdata/realworld/postgresql/08_supabase_rls_misc.sql create mode 100644 testdata/realworld/snowflake/01_app_crud.sql create mode 100644 testdata/realworld/snowflake/02_reporting.sql create mode 100644 testdata/realworld/snowflake/03_ddl_migrations.sql create mode 100644 testdata/realworld/snowflake/04_window_analytics.sql create mode 100644 testdata/realworld/snowflake/05_dialect_specific.sql create mode 100644 testdata/realworld/snowflake/06_etl_merge.sql create mode 100644 testdata/realworld/snowflake/07_admin.sql create mode 100644 testdata/realworld/snowflake/08_pivot_match.sql create mode 100644 testdata/realworld/snowflake/09_dbt_models.sql create mode 100644 testdata/realworld/snowflake/10_complex_nested.sql create mode 100644 testdata/realworld/sqlite/01_app_crud.sql create mode 100644 testdata/realworld/sqlite/02_upsert_replace.sql create mode 100644 testdata/realworld/sqlite/03_ddl_migrations.sql create mode 100644 testdata/realworld/sqlite/04_window_analytics.sql create mode 100644 testdata/realworld/sqlite/05_dialect_specific.sql create mode 100644 testdata/realworld/sqlite/06_json.sql create mode 100644 testdata/realworld/sqlite/07_ctes_recursive.sql create mode 100644 testdata/realworld/sqlite/08_reporting.sql create mode 100644 testdata/realworld/sqlite/09_etl_admin.sql create mode 100644 testdata/realworld/sqlite/10_mobile_offline_sync.sql create mode 100644 testdata/realworld/sqlite/11_deep_nesting.sql create mode 100644 testdata/realworld/sqlite/12_orm_generated.sql create mode 100644 testdata/realworld/sqlserver/01_app_crud.sql create mode 100644 testdata/realworld/sqlserver/02_reporting.sql create mode 100644 testdata/realworld/sqlserver/03_ddl_migrations.sql create mode 100644 testdata/realworld/sqlserver/04_window_analytics.sql create mode 100644 testdata/realworld/sqlserver/05_dialect_specific.sql create mode 100644 testdata/realworld/sqlserver/06_merge_etl.sql create mode 100644 testdata/realworld/sqlserver/07_ctes_deep.sql create mode 100644 testdata/realworld/sqlserver/08_procs_batches.sql create mode 100644 testdata/realworld/sqlserver/09_admin_dmv.sql create mode 100644 testdata/realworld/sqlserver/10_etl_mixed.sql diff --git a/pkg/gosqlx/realworld_corpus_test.go b/pkg/gosqlx/realworld_corpus_test.go new file mode 100644 index 00000000..fd5c937b --- /dev/null +++ b/pkg/gosqlx/realworld_corpus_test.go @@ -0,0 +1,271 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx + +import ( + "bufio" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// realworldRoot holds one directory per dialect of real-world SQL; every +// statement is run through split → parse → format → reparse → reformat. +var realworldRoot = filepath.Join("..", "..", "testdata", "realworld") + +var updateKnownFailures = flag.Bool("update-known-failures", false, + "rewrite testdata/realworld/known_failures.txt from the current results") + +// TestRealWorldCorpus is a ratchet: a statement must pass every stage unless +// it is listed in testdata/realworld/known_failures.txt, and a listed +// statement that now passes fails the test so the list only ever shrinks. +func TestRealWorldCorpus(t *testing.T) { + dirs, err := os.ReadDir(realworldRoot) + if os.IsNotExist(err) { + t.Skip("testdata/realworld not present") + } + if err != nil { + t.Fatalf("read %s: %v", realworldRoot, err) + } + known := loadKnownFailures(t) + seen := map[string]bool{} + var current []string // "key stage" for every failing statement + + for _, d := range dirs { + if !d.IsDir() { + continue + } + dialect := d.Name() + if !keywords.IsValidDialect(dialect) { + t.Errorf("testdata/realworld/%s: not a valid dialect name", dialect) + continue + } + files, _ := filepath.Glob(filepath.Join(realworldRoot, dialect, "*.sql")) + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + base := dialect + "/" + filepath.Base(file) + for i, stmt := range SplitStatements(string(data), dialect) { + if onlyComments(stmt) { + continue + } + key := fmt.Sprintf("%s#%d", base, i) + seen[key] = true + stage, detail := realworldCheck(stmt, keywords.SQLDialect(dialect)) + if stage != "ok" { + current = append(current, key+" "+stage) + } + if *updateKnownFailures { + continue + } + want, isKnown := known[key] + switch { + case stage == "ok" && isKnown: + t.Errorf("%s now passes; remove it from known_failures.txt (was %q)", key, want) + case stage != "ok" && !isKnown: + t.Errorf("%s failed at %s: %s\n sql: %s", key, stage, detail, compact(stmt)) + case stage != "ok" && stage != want: + t.Errorf("%s failure stage changed %s → %s: %s", key, want, stage, detail) + } + } + } + } + + if *updateKnownFailures { + writeKnownFailures(t, current) + return + } + + var stale []string + for key := range known { + if !seen[key] { + stale = append(stale, key) + } + } + sort.Strings(stale) + for _, key := range stale { + t.Errorf("known_failures.txt lists %s, which no longer exists", key) + } +} + +func realworldCheck(stmt string, d keywords.SQLDialect) (stage, detail string) { + defer func() { + if p := recover(); p != nil { + stage, detail = "panic", fmt.Sprint(p) + } + }() + tree, err := ParseWithDialect(stmt, d) + if err != nil { + return "parse", err.Error() + } + f1 := FormatAST(tree) + tree2, err := ParseWithDialect(f1, d) + if err != nil { + return "reparse", err.Error() + "\n formatted: " + compact(f1) + } + if f2 := FormatAST(tree2); f2 != f1 { + return "idempotent", compact(f1) + "\n != " + compact(f2) + } + if lost := realworldDroppedWords(stmt, f1); len(lost) > 0 { + return "drop", "formatter lost " + strings.Join(lost, " ") + "\n formatted: " + compact(f1) + } + return "ok", "" +} + +// realworldDroppedWords reports words of the input (outside comments and string +// literals) that are missing from the formatted output — a cheap detector for +// clauses the formatter silently drops even though its output reparses. +func realworldDroppedWords(in, out string) []string { + have := map[string]bool{} + for _, w := range realworldWords(out) { + have[w] = true + } + var lost []string + seen := map[string]bool{} + for _, w := range realworldWords(in) { + if !have[w] && !realworldIgnorable[w] && !seen[w] { + seen[w] = true + lost = append(lost, w) + } + } + return lost +} + +// realworldIgnorable are noise words the formatter may legitimately add or drop. +var realworldIgnorable = map[string]bool{"AS": true, "ASC": true, "INNER": true, "OUTER": true} + +// realworldWords returns the upper-cased identifier-like words of s, skipping +// comments and quoted string literals but keeping quoted identifiers' text. +func realworldWords(s string) []string { + var words []string + for i := 0; i < len(s); { + c := s[i] + switch { + case c == '-' && i+1 < len(s) && s[i+1] == '-': + for i < len(s) && s[i] != '\n' { + i++ + } + case c == '/' && i+1 < len(s) && s[i+1] == '*': + end := strings.Index(s[i+2:], "*/") + if end < 0 { + return words + } + i += end + 4 + case c == '\'': + for i++; i < len(s); i++ { + if s[i] == '\\' { + i++ + continue + } + if s[i] == '\'' { + if i+1 < len(s) && s[i+1] == '\'' { + i++ + continue + } + break + } + } + i++ + case c == '_' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= 0x80: + j := i + for j < len(s) && (s[j] == '_' || s[j] == '$' || s[j] >= '0' && s[j] <= '9' || + s[j] >= 'A' && s[j] <= 'Z' || s[j] >= 'a' && s[j] <= 'z' || s[j] >= 0x80) { + j++ + } + words = append(words, strings.ToUpper(s[i:j])) + i = j + case c >= '0' && c <= '9': + for i < len(s) && (s[i] == '.' || s[i] == '_' || s[i] >= '0' && s[i] <= '9' || + s[i] >= 'A' && s[i] <= 'Z' || s[i] >= 'a' && s[i] <= 'z') { + i++ + } + default: + i++ + } + } + return words +} + +func writeKnownFailures(t *testing.T, lines []string) { + t.Helper() + sort.Strings(lines) + header := "# Real-world corpus statements that GoSQLX does not yet handle.\n" + + "# Format: /# \n" + + "# Stages: parse, reparse, idempotent, drop (formatter lost words), panic.\n" + + "# Regenerate: go test ./pkg/gosqlx -run TestRealWorldCorpus -update-known-failures\n" + data := header + strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(filepath.Join(realworldRoot, "known_failures.txt"), []byte(data), 0o644); err != nil { + t.Fatalf("write known_failures.txt: %v", err) + } + t.Logf("wrote %d known failures", len(lines)) +} + +// loadKnownFailures reads "dialect/file.sql#N stage # reason" lines. +func loadKnownFailures(t *testing.T) map[string]string { + t.Helper() + known := map[string]string{} + f, err := os.Open(filepath.Join(realworldRoot, "known_failures.txt")) + if os.IsNotExist(err) { + return known + } + if err != nil { + t.Fatalf("open known_failures.txt: %v", err) + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if i := strings.IndexByte(line, '#'); i == 0 { + continue + } + if i := strings.Index(line, " #"); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + known[fields[0]] = fields[1] + } + if err := sc.Err(); err != nil { + t.Fatalf("scan known_failures.txt: %v", err) + } + return known +} + +func onlyComments(s string) bool { + for _, line := range strings.Split(s, "\n") { + if t := strings.TrimSpace(line); t != "" && !strings.HasPrefix(t, "--") { + return false + } + } + return true +} + +func compact(s string) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > 240 { + s = s[:240] + "…" + } + return s +} diff --git a/scripts/dialect-audit/main.go b/scripts/dialect-audit/main.go new file mode 100644 index 00000000..5f1cb81c --- /dev/null +++ b/scripts/dialect-audit/main.go @@ -0,0 +1,216 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command dialect-audit runs real-world SQL files through the user-facing +// pipeline (split → parse with dialect → format → reparse → reformat) and +// reports every statement that fails a stage. +// +// Usage: +// +// go run ./scripts/dialect-audit -dialect postgresql testdata/realworld/postgresql/*.sql +// go run ./scripts/dialect-audit -dialect mysql -json file.sql +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +type result struct { + File string `json:"file"` + Index int `json:"index"` + Stage string `json:"stage"` // parse | reparse | idempotent | drop | panic | ok + SQL string `json:"sql"` + Error string `json:"error,omitempty"` + Format string `json:"formatted,omitempty"` +} + +func main() { + dialect := flag.String("dialect", "generic", "SQL dialect") + asJSON := flag.Bool("json", false, "emit JSON lines for every failure") + verbose := flag.Bool("v", false, "print passing statements too") + flag.Parse() + + d := keywords.SQLDialect(*dialect) + total, failed := 0, 0 + stages := map[string]int{} + enc := json.NewEncoder(os.Stdout) + + for _, path := range flag.Args() { + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "read %s: %v\n", path, err) + os.Exit(2) + } + for i, stmt := range gosqlx.SplitStatements(string(data), *dialect) { + if strings.TrimSpace(stripComments(stmt)) == "" { + continue + } + total++ + r := check(path, i, stmt, d) + stages[r.Stage]++ + if r.Stage == "ok" { + if *verbose { + fmt.Printf("ok %s#%d\n", path, i) + } + continue + } + failed++ + if *asJSON { + _ = enc.Encode(r) + continue + } + fmt.Printf("FAIL %s#%d [%s] %s\n sql: %s\n", path, i, r.Stage, r.Error, oneLine(r.SQL)) + if r.Format != "" { + fmt.Printf(" fmt: %s\n", oneLine(r.Format)) + } + } + } + fmt.Fprintf(os.Stderr, "dialect=%s total=%d passed=%d failed=%d stages=%v\n", + *dialect, total, total-failed, failed, stages) + if failed > 0 { + os.Exit(1) + } +} + +func check(path string, i int, stmt string, d keywords.SQLDialect) (r result) { + r = result{File: path, Index: i, SQL: stmt} + defer func() { + if p := recover(); p != nil { + r.Stage, r.Error = "panic", fmt.Sprint(p) + } + }() + tree, err := gosqlx.ParseWithDialect(stmt, d) + if err != nil { + r.Stage, r.Error = "parse", err.Error() + return r + } + f1 := gosqlx.FormatAST(tree) + r.Format = f1 + tree2, err := gosqlx.ParseWithDialect(f1, d) + if err != nil { + r.Stage, r.Error = "reparse", err.Error() + return r + } + if f2 := gosqlx.FormatAST(tree2); f2 != f1 { + r.Stage, r.Error = "idempotent", "format(parse(format(x))) != format(x): "+oneLine(f2) + return r + } + if lost := droppedWords(stmt, f1); len(lost) > 0 { + r.Stage, r.Error = "drop", "formatter output lost words: "+strings.Join(lost, " ") + return r + } + r.Stage, r.Format = "ok", "" + return r +} + +// droppedWords reports words of the input (outside comments and string +// literals) that are missing from the formatted output — a cheap detector for +// clauses the formatter silently drops even though its output reparses. +func droppedWords(in, out string) []string { + have := map[string]bool{} + for _, w := range sqlWords(out) { + have[w] = true + } + var lost []string + seen := map[string]bool{} + for _, w := range sqlWords(in) { + if !have[w] && !ignorableWords[w] && !seen[w] { + seen[w] = true + lost = append(lost, w) + } + } + return lost +} + +// ignorableWords are noise words the formatter may legitimately add or drop. +var ignorableWords = map[string]bool{"AS": true, "ASC": true, "INNER": true, "OUTER": true} + +// sqlWords returns the upper-cased identifier-like words of s, skipping +// comments and quoted string literals but keeping quoted identifiers' text. +func sqlWords(s string) []string { + var words []string + for i := 0; i < len(s); { + c := s[i] + switch { + case c == '-' && i+1 < len(s) && s[i+1] == '-': + for i < len(s) && s[i] != '\n' { + i++ + } + case c == '/' && i+1 < len(s) && s[i+1] == '*': + end := strings.Index(s[i+2:], "*/") + if end < 0 { + return words + } + i += end + 4 + case c == '\'': + for i++; i < len(s); i++ { + if s[i] == '\\' { + i++ + continue + } + if s[i] == '\'' { + if i+1 < len(s) && s[i+1] == '\'' { + i++ + continue + } + break + } + } + i++ + case c == '_' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= 0x80: + j := i + for j < len(s) && (s[j] == '_' || s[j] == '$' || s[j] >= '0' && s[j] <= '9' || + s[j] >= 'A' && s[j] <= 'Z' || s[j] >= 'a' && s[j] <= 'z' || s[j] >= 0x80) { + j++ + } + words = append(words, strings.ToUpper(s[i:j])) + i = j + case c >= '0' && c <= '9': + for i < len(s) && (s[i] == '.' || s[i] == '_' || s[i] >= '0' && s[i] <= '9' || + s[i] >= 'A' && s[i] <= 'Z' || s[i] >= 'a' && s[i] <= 'z') { + i++ + } + default: + i++ + } + } + return words +} + +func stripComments(s string) string { + var b strings.Builder + for _, line := range strings.Split(s, "\n") { + if t := strings.TrimSpace(line); strings.HasPrefix(t, "--") { + continue + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} + +func oneLine(s string) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > 300 { + s = s[:300] + "…" + } + return s +} diff --git a/testdata/realworld/clickhouse/01_app_crud.sql b/testdata/realworld/clickhouse/01_app_crud.sql new file mode 100644 index 00000000..aa41bb47 --- /dev/null +++ b/testdata/realworld/clickhouse/01_app_crud.sql @@ -0,0 +1,26 @@ +-- ORM / application-style CRUD against ClickHouse tables (users, orders, sessions) +SELECT id, email, created_at FROM users WHERE id = 42; +SELECT id, email FROM users WHERE email = 'a@example.com' LIMIT 1; +SELECT * FROM users WHERE id IN (1, 2, 3) ORDER BY id; +SELECT id, name FROM users WHERE name LIKE 'Jo%' AND is_active = 1 ORDER BY created_at DESC LIMIT 20 OFFSET 40; +SELECT count() FROM users; +SELECT count(*) AS total, countIf(is_active = 1) AS active FROM users; +INSERT INTO users (id, email, name, created_at) VALUES (1, 'a@example.com', 'Alice', now()); +INSERT INTO users (id, email, name) VALUES (2, 'b@example.com', 'Bob'), (3, 'c@example.com', 'Carol'); +INSERT INTO users_archive SELECT * FROM users WHERE created_at < '2020-01-01'; +INSERT INTO events (ts, user_id, name) SELECT now(), id, 'migrated' FROM users WHERE is_active = 0; +ALTER TABLE users UPDATE name = 'Alicia' WHERE id = 1; +ALTER TABLE users UPDATE is_active = 0, updated_at = now() WHERE last_login < now() - INTERVAL 1 YEAR; +ALTER TABLE users DELETE WHERE id = 1; +ALTER TABLE events DELETE WHERE ts < now() - INTERVAL 90 DAY; +DELETE FROM users WHERE id = 3; +TRUNCATE TABLE tmp_staging; +SELECT o.id, o.total, u.email FROM orders AS o INNER JOIN users AS u ON o.user_id = u.id WHERE o.status = 'paid' LIMIT 100; +SELECT u.id, count() AS cnt FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id HAVING cnt > 5 ORDER BY cnt DESC; +SELECT id FROM orders WHERE user_id = {user_id:UInt64} AND created_at >= {since:DateTime}; +SELECT * FROM orders WHERE status NOT IN ('cancelled', 'refunded') AND total BETWEEN 10 AND 500; +SELECT DISTINCT country FROM users WHERE country != '' ORDER BY country; +SELECT id, if(is_active = 1, 'active', 'inactive') AS state FROM users; +SELECT id, multiIf(total < 10, 'small', total < 100, 'medium', 'large') AS bucket FROM orders; +SELECT id, CASE WHEN total > 100 THEN 'big' ELSE 'small' END AS size FROM orders; +SELECT id, coalesce(nickname, name, 'unknown') AS display FROM users; diff --git a/testdata/realworld/clickhouse/02_reporting.sql b/testdata/realworld/clickhouse/02_reporting.sql new file mode 100644 index 00000000..0e7c8e07 --- /dev/null +++ b/testdata/realworld/clickhouse/02_reporting.sql @@ -0,0 +1,32 @@ +-- Dashboard and BI reporting queries: daily revenue, funnels, top-N +SELECT toDate(created_at) AS day, sum(total) AS revenue, count() AS orders FROM orders GROUP BY day ORDER BY day; +SELECT toStartOfMonth(created_at) AS month, sum(total) AS revenue FROM orders WHERE created_at >= now() - INTERVAL 12 MONTH GROUP BY month ORDER BY month; +SELECT toStartOfHour(ts) AS hour, count() AS hits FROM web_events WHERE ts >= now() - INTERVAL 1 DAY GROUP BY hour ORDER BY hour; +SELECT toStartOfInterval(ts, INTERVAL 5 MINUTE) AS bucket, count() FROM web_events GROUP BY bucket ORDER BY bucket; +SELECT country, uniq(user_id) AS users, uniqExact(session_id) AS sessions FROM web_events GROUP BY country ORDER BY users DESC LIMIT 10; +SELECT quantile(0.9)(latency_ms) AS p90, quantile(0.99)(latency_ms) AS p99 FROM requests; +SELECT quantiles(0.5, 0.9, 0.99)(latency_ms) FROM requests WHERE ts > now() - INTERVAL 1 HOUR; +SELECT endpoint, quantileTDigest(0.95)(latency_ms) AS p95, avg(latency_ms), max(latency_ms) FROM requests GROUP BY endpoint ORDER BY p95 DESC; +SELECT sumIf(total, status = 'paid') AS paid, sumIf(total, status = 'refunded') AS refunded FROM orders; +SELECT countIf(status = 'error') / count() AS error_rate FROM requests; +SELECT avgIf(latency_ms, status < 500) FROM requests; +SELECT uniqIf(user_id, event = 'purchase') AS buyers, uniqIf(user_id, event = 'view') AS viewers FROM web_events; +SELECT category, argMax(name, price) AS priciest, argMin(name, price) AS cheapest FROM products GROUP BY category; +SELECT groupArray(name) AS names, groupUniqArray(category) FROM products; +SELECT user_id, topK(3)(page) AS top_pages FROM web_events GROUP BY user_id; +SELECT windowFunnel(3600)(ts, event = 'view', event = 'cart', event = 'purchase') AS level, count() FROM web_events GROUP BY user_id; +SELECT product_id, sum(qty) AS units FROM order_items GROUP BY product_id ORDER BY units DESC LIMIT 10; +SELECT date, sum(amount) FROM sales GROUP BY date WITH TOTALS ORDER BY date; +SELECT region, product, sum(amount) FROM sales GROUP BY region, product WITH ROLLUP; +SELECT region, product, sum(amount) FROM sales GROUP BY region, product WITH CUBE; +SELECT a, b, count() FROM t GROUP BY ROLLUP(a, b); +SELECT formatReadableSize(sum(bytes)) AS size, formatReadableQuantity(count()) FROM files; +SELECT round(avg(total), 2), round(stddevPop(total), 2), median(total) FROM orders; +SELECT toYYYYMM(created_at) AS ym, count() FROM orders GROUP BY ym ORDER BY ym DESC LIMIT 12; +SELECT dateDiff('day', min(created_at), max(created_at)) FROM orders; +SELECT toDate(ts) AS d, count() FROM web_events WHERE ts >= toStartOfDay(now()) - INTERVAL 7 DAY AND ts < toStartOfDay(now()) GROUP BY d; +SELECT d, c, sum(c) OVER (ORDER BY d) AS running FROM (SELECT toDate(ts) AS d, count() AS c FROM web_events GROUP BY d) ORDER BY d; +SELECT number FROM numbers(10); +SELECT toDate('2024-01-01') + number AS d FROM numbers(30); +SELECT 1 + 2 * 3 AS x, (1 + 2) * 3 AS y, 10 % 3 AS z, -5 AS neg; +SELECT arrayJoin([1, 2, 3]) AS n; diff --git a/testdata/realworld/clickhouse/03_ddl_migrations.sql b/testdata/realworld/clickhouse/03_ddl_migrations.sql new file mode 100644 index 00000000..eef952ad --- /dev/null +++ b/testdata/realworld/clickhouse/03_ddl_migrations.sql @@ -0,0 +1,48 @@ +-- Schema migrations: MergeTree family DDL, TTL, codecs, projections, indices +CREATE TABLE events (ts DateTime, user_id UInt64, name String) ENGINE = MergeTree ORDER BY (user_id, ts); +CREATE TABLE IF NOT EXISTS default.events2 (ts DateTime, user_id UInt64, name LowCardinality(String), props Map(String, String)) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (name, user_id, ts) SETTINGS index_granularity = 8192; +CREATE TABLE web_events (ts DateTime64(3) CODEC(Delta, ZSTD(1)), user_id UInt64, session_id UUID, url String, country LowCardinality(String), tags Array(String), amount Nullable(Decimal(18, 4)), ua Tuple(browser String, os String)) ENGINE = MergeTree PARTITION BY toDate(ts) ORDER BY (country, user_id, ts) TTL toDateTime(ts) + INTERVAL 90 DAY DELETE SETTINGS index_granularity = 8192; +CREATE TABLE metrics (ts DateTime, host LowCardinality(String), value Float64) ENGINE = MergeTree ORDER BY (host, ts) TTL ts + INTERVAL 1 MONTH TO VOLUME 'cold', ts + INTERVAL 1 YEAR DELETE; +CREATE TABLE users_rmt (id UInt64, name String, version UInt32) ENGINE = ReplacingMergeTree(version) ORDER BY id; +CREATE TABLE daily_sum (day Date, key String, total UInt64) ENGINE = SummingMergeTree ORDER BY (day, key); +CREATE TABLE daily_agg (day Date, key String, uniq_state AggregateFunction(uniq, UInt64), q_state AggregateFunction(quantile(0.9), Float64)) ENGINE = AggregatingMergeTree ORDER BY (day, key); +CREATE TABLE collapsing (id UInt64, sign Int8, v UInt32) ENGINE = CollapsingMergeTree(sign) ORDER BY id; +CREATE TABLE repl_events (ts DateTime, id UInt64) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/repl_events', '{replica}') ORDER BY (id, ts); +CREATE TABLE dist_events AS local_events ENGINE = Distributed('my_cluster', 'default', 'local_events', rand()); +CREATE TABLE IF NOT EXISTS lc_test (id UInt32, s LowCardinality(Nullable(String))) ENGINE = MergeTree ORDER BY id; +CREATE TABLE t_enum (id UInt32, status Enum8('new' = 1, 'paid' = 2, 'shipped' = 3)) ENGINE = MergeTree ORDER BY id; +CREATE TABLE t_defaults (id UInt64, created DateTime DEFAULT now(), day Date MATERIALIZED toDate(created), note String DEFAULT '' COMMENT 'free text', hash UInt64 ALIAS cityHash64(id)) ENGINE = MergeTree ORDER BY id; +CREATE TABLE t_idx (id UInt64, s String, INDEX idx_s s TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, INDEX idx_id id TYPE minmax GRANULARITY 1) ENGINE = MergeTree ORDER BY id; +CREATE TABLE t_nested (id UInt64, n Nested(k String, v UInt32)) ENGINE = MergeTree ORDER BY id; +CREATE TABLE t_log (msg String) ENGINE = Log; +CREATE TABLE t_mem (a UInt8) ENGINE = Memory; +CREATE TABLE t_sample (id UInt64, h UInt64) ENGINE = MergeTree ORDER BY (id, intHash32(h)) SAMPLE BY intHash32(h); +CREATE TABLE copy_of_events AS events; +CREATE TABLE t_from_select ENGINE = MergeTree ORDER BY id AS SELECT id, name FROM users; +CREATE DATABASE IF NOT EXISTS analytics; +CREATE DATABASE analytics ENGINE = Atomic; +DROP TABLE IF EXISTS tmp_staging; +DROP DATABASE IF EXISTS scratch; +RENAME TABLE old_events TO events_old, new_events TO old_events; +EXCHANGE TABLES a AND b; +ALTER TABLE events ADD COLUMN IF NOT EXISTS country LowCardinality(String) DEFAULT '' AFTER user_id; +ALTER TABLE events DROP COLUMN IF EXISTS legacy; +ALTER TABLE events MODIFY COLUMN name LowCardinality(String); +ALTER TABLE events RENAME COLUMN name TO event_name; +ALTER TABLE events COMMENT COLUMN event_name 'event type'; +ALTER TABLE events MODIFY TTL ts + INTERVAL 6 MONTH; +ALTER TABLE events ADD INDEX idx_name event_name TYPE bloom_filter GRANULARITY 3; +ALTER TABLE events DROP INDEX idx_name; +ALTER TABLE events MATERIALIZE INDEX idx_name; +ALTER TABLE events ADD PROJECTION p_by_user (SELECT user_id, count() GROUP BY user_id); +ALTER TABLE events MODIFY SETTING ttl_only_drop_parts = 1; +ALTER TABLE events DROP PARTITION '202401'; +ALTER TABLE events DROP PARTITION ID '20240101'; +ALTER TABLE events DETACH PARTITION 202301; +ALTER TABLE events ATTACH PARTITION 202301; +ALTER TABLE events FREEZE PARTITION '2024-01-01'; +ALTER TABLE events ON CLUSTER my_cluster ADD COLUMN x UInt8; +CREATE TABLE ddl_cluster ON CLUSTER my_cluster (id UInt64) ENGINE = MergeTree ORDER BY id; +OPTIMIZE TABLE events FINAL; +OPTIMIZE TABLE events PARTITION 202401 FINAL; +OPTIMIZE TABLE events DEDUPLICATE; diff --git a/testdata/realworld/clickhouse/04_window_analytics.sql b/testdata/realworld/clickhouse/04_window_analytics.sql new file mode 100644 index 00000000..d408a4e0 --- /dev/null +++ b/testdata/realworld/clickhouse/04_window_analytics.sql @@ -0,0 +1,32 @@ +-- Window functions, CTE chains, retention and cohort analytics +SELECT user_id, ts, row_number() OVER (PARTITION BY user_id ORDER BY ts) AS rn FROM web_events; +SELECT user_id, ts, lagInFrame(ts) OVER (PARTITION BY user_id ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS prev_ts FROM web_events; +SELECT user_id, ts, lag(ts, 1) OVER (PARTITION BY user_id ORDER BY ts) AS prev FROM web_events; +SELECT day, revenue, avg(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM daily_rev; +SELECT day, revenue, sum(revenue) OVER (ORDER BY day RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum FROM daily_rev; +SELECT user_id, amount, rank() OVER w AS r, dense_rank() OVER w AS dr FROM orders WINDOW w AS (PARTITION BY user_id ORDER BY amount DESC); +SELECT id, ntile(4) OVER (ORDER BY score) AS quartile, first_value(score) OVER (ORDER BY score) AS mn, last_value(score) OVER (ORDER BY score ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS mx FROM scores; +SELECT id, percent_rank() OVER (ORDER BY score), cume_dist() OVER (ORDER BY score) FROM scores; +WITH daily AS (SELECT toDate(ts) AS d, count() AS c FROM web_events GROUP BY d) SELECT d, c FROM daily ORDER BY d; +WITH 7 AS days SELECT count() FROM web_events WHERE ts >= now() - INTERVAL days DAY; +WITH (SELECT max(ts) FROM web_events) AS latest SELECT count() FROM web_events WHERE ts = latest; +WITH first_seen AS (SELECT user_id, min(toDate(ts)) AS cohort FROM web_events GROUP BY user_id), activity AS (SELECT DISTINCT user_id, toDate(ts) AS d FROM web_events) SELECT f.cohort, dateDiff('day', f.cohort, a.d) AS age, uniq(a.user_id) AS users FROM first_seen AS f INNER JOIN activity AS a ON a.user_id = f.user_id GROUP BY f.cohort, age ORDER BY f.cohort, age; +WITH a AS (SELECT 1 AS x), b AS (SELECT x + 1 AS y FROM a), c AS (SELECT y * 2 AS z FROM b), d AS (SELECT z + 10 AS w FROM c) SELECT * FROM d; +WITH RECURSIVE t AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM t WHERE n < 5) SELECT * FROM t; +SELECT retention(ts = today(), ts = today() - 1, ts = today() - 7) FROM web_events GROUP BY user_id; +SELECT sequenceMatch('(?1)(?2)')(ts, event = 'a', event = 'b') FROM web_events GROUP BY user_id; +SELECT user_id, sum(amount) OVER (PARTITION BY user_id ORDER BY ts) / sum(amount) OVER (PARTITION BY user_id) AS frac FROM orders; +SELECT * FROM (SELECT user_id, row_number() OVER (PARTITION BY user_id ORDER BY ts DESC) AS rn FROM web_events) WHERE rn = 1; +SELECT x FROM (SELECT * FROM (SELECT * FROM (SELECT number AS x FROM numbers(100)) WHERE x > 10) WHERE x < 90) WHERE x % 2 = 0; +SELECT a.x, b.y, c.z, d.w, e.v FROM a INNER JOIN b ON a.id = b.id LEFT JOIN c ON b.id = c.id LEFT JOIN d ON c.id = d.id ANY LEFT JOIN e ON d.id = e.id; +SELECT * FROM a ASOF JOIN b ON a.id = b.id AND a.ts >= b.ts; +SELECT * FROM a GLOBAL ANY LEFT JOIN b USING (id); +SELECT * FROM a INNER JOIN b USING (id, ts); +SELECT * FROM a CROSS JOIN b; +SELECT * FROM a FULL OUTER JOIN b ON a.id = b.id; +SELECT * FROM a LEFT SEMI JOIN b ON a.id = b.id; +SELECT * FROM a LEFT ANTI JOIN b ON a.id = b.id; +SELECT id FROM a UNION ALL SELECT id FROM b UNION ALL SELECT id FROM c; +SELECT id FROM a UNION DISTINCT SELECT id FROM b; +SELECT id FROM a INTERSECT SELECT id FROM b; +SELECT id FROM a EXCEPT SELECT id FROM b; diff --git a/testdata/realworld/clickhouse/05_dialect_specific.sql b/testdata/realworld/clickhouse/05_dialect_specific.sql new file mode 100644 index 00000000..1403dd5a --- /dev/null +++ b/testdata/realworld/clickhouse/05_dialect_specific.sql @@ -0,0 +1,73 @@ +-- ClickHouse-specific syntax: FINAL, SAMPLE, PREWHERE, ARRAY JOIN, LIMIT BY, WITH FILL, lambdas, FORMAT, SETTINGS +SELECT * FROM users FINAL WHERE id = 1; +SELECT count() FROM users FINAL; +SELECT * FROM events SAMPLE 0.1; +SELECT count() * 10 FROM events SAMPLE 1 / 10; +SELECT * FROM events SAMPLE 10000 OFFSET 0.5; +SELECT * FROM events PREWHERE user_id = 5 WHERE ts > now() - INTERVAL 1 DAY; +SELECT * FROM events FINAL PREWHERE user_id = 5 WHERE name = 'click'; +SELECT id, tag FROM t ARRAY JOIN tags AS tag; +SELECT id, tag FROM t LEFT ARRAY JOIN tags AS tag; +SELECT id, tag, idx FROM t ARRAY JOIN tags AS tag, arrayEnumerate(tags) AS idx; +SELECT n.k, n.v FROM t ARRAY JOIN n; +SELECT user_id, page FROM web_events ORDER BY ts DESC LIMIT 3 BY user_id; +SELECT user_id, page FROM web_events ORDER BY ts DESC LIMIT 1, 2 BY user_id; +SELECT user_id, page FROM web_events ORDER BY ts DESC LIMIT 2 BY user_id, page LIMIT 100; +SELECT n FROM numbers(10) ORDER BY n WITH FILL FROM 0 TO 20; +SELECT d, c FROM daily ORDER BY d WITH FILL FROM toDate('2024-01-01') TO toDate('2024-02-01') STEP 1; +SELECT d, c FROM daily ORDER BY d WITH FILL STEP INTERVAL 1 DAY; +SELECT d, c FROM daily ORDER BY d WITH FILL STEP toIntervalDay(1) INTERPOLATE (c AS c + 1); +SELECT arrayMap(x -> x * 2, [1, 2, 3]) AS doubled; +SELECT arrayFilter(x -> x > 1, arr) FROM t; +SELECT arrayMap((x, y) -> x + y, a, b) FROM t; +SELECT arrayExists(x -> x LIKE 'err%', tags) FROM t; +SELECT arrayFold((acc, x) -> acc + x, [1, 2, 3], toUInt64(0)); +SELECT arraySort(x -> -x, arr), arrayReverseSort(arr), arrayDistinct(arr) FROM t; +SELECT arrayStringConcat(arrayMap(x -> toString(x), arr), ',') FROM t; +SELECT has(tags, 'a'), hasAll(tags, ['a', 'b']), length(tags), empty(tags), arrayElement(tags, 1), tags[1] FROM t; +SELECT [1, 2, 3][2], (1, 'a').1, tupleElement((1, 'a'), 2); +SELECT m['key'], mapKeys(m), mapValues(m), map('a', 1, 'b', 2) FROM t; +SELECT sumState(x), uniqState(user_id), quantileState(0.9)(x) FROM t; +SELECT sumMerge(s), uniqMerge(u), quantileMerge(0.9)(q) FROM daily_agg GROUP BY day; +SELECT sumIfState(x, x > 0) FROM t; +SELECT sumArray(arr), avgArray(arr), minMap(m) FROM t; +SELECT sumForEach(arr) FROM t; +SELECT count() FROM t SETTINGS max_threads = 4; +SELECT * FROM t LIMIT 10 SETTINGS max_execution_time = 10, max_memory_usage = 10000000000; +SELECT * FROM t FORMAT JSON; +SELECT * FROM t FORMAT JSONEachRow; +SELECT * FROM t LIMIT 5 FORMAT TabSeparated; +SELECT * FROM t SETTINGS max_threads = 2 FORMAT CSVWithNames; +SELECT id FROM t WHERE id GLOBAL IN (SELECT id FROM dist_t); +SELECT id FROM t WHERE id GLOBAL NOT IN (SELECT id FROM dist_t); +SELECT * FROM t WHERE (a, b) IN ((1, 2), (3, 4)); +SELECT * FROM t WHERE id IN (SELECT id FROM s); +SELECT * FROM remote('host:9000', db.tbl) LIMIT 1; +SELECT * FROM cluster('my_cluster', default.events) LIMIT 1; +SELECT * FROM clusterAllReplicas('my_cluster', system.metrics) LIMIT 1; +SELECT * FROM url('https://example.com/data.csv', CSV, 'a UInt32, b String'); +SELECT * FROM s3('https://bucket.s3.amazonaws.com/data/*.parquet', 'Parquet') LIMIT 10; +SELECT * FROM file('data.csv', 'CSVWithNames'); +SELECT * FROM generateRandom('a UInt8, b String', 1, 10, 2) LIMIT 3; +SELECT cast(x AS String), CAST(x, 'UInt32'), x::UInt64, toInt32OrNull('12'), toFloat64OrZero('x'), accurateCastOrNull(x, 'UInt8') FROM t; +SELECT toDateTime('2024-01-01 00:00:00', 'UTC'), toTimeZone(now(), 'Asia/Kolkata'), now64(3), today(), yesterday(); +SELECT parseDateTimeBestEffort('2024-01-01T10:00:00Z'), formatDateTime(now(), '%Y-%m-%d'); +SELECT JSONExtractString(j, 'a'), JSONExtractInt(j, 'b', 'c'), JSONExtract(j, 'x', 'Array(UInt8)'), JSONHas(j, 'k'), simpleJSONExtractString(j, 'z') FROM t; +SELECT visitParamExtractString(j, 'a'), 1 FROM t; +SELECT extract(s, '(\\d+)'), match(s, '^a.*b$'), replaceRegexpAll(s, '[aeiou]', '_'), splitByChar(',', s), position(s, 'x'), lower(s), upper(s), trim(s), concat(a, b), substring(s, 1, 3) FROM t; +SELECT domain(url), path(url), protocol(url), extractURLParameter(url, 'q'), cutQueryString(url) FROM t; +SELECT cityHash64(s), sipHash64(s), MD5(s), SHA256(s), halfMD4(s), xxHash64(s) FROM t; +SELECT IPv4StringToNum('1.2.3.4'), IPv4NumToString(x), toIPv4('1.2.3.4'), toIPv6('::1'), isIPAddressInRange('1.2.3.4', '1.2.3.0/24'); +SELECT bitAnd(a, b), bitShiftLeft(a, 2), intDiv(a, b), modulo(a, b), a <> b; +SELECT x IS NULL, x IS NOT NULL, ifNull(x, 0), nullIf(x, 0), isNull(x), assumeNotNull(x) FROM t; +SELECT s LIKE '%a%', s NOT LIKE 'b%', s ILIKE '%A%', s NOT ILIKE 'x%' FROM t; +SELECT * FROM t WHERE ts > now() - INTERVAL 1 HOUR AND s != '' OR x BETWEEN 1 AND 2; +SELECT x AS "quoted col", `back-tick` FROM `my-table`; +SELECT * EXCEPT (secret) FROM t; +SELECT * EXCEPT (a, b), COLUMNS('^x_') FROM t; +SELECT COLUMNS('a|b') APPLY (sum) FROM t; +SELECT * REPLACE (x + 1 AS x) FROM t; +SELECT 'it''s', 'back\\slash', 'tab\t', 0x1F, 1e3, 1.5e-3; +SELECT toDecimal64(1.5, 2), 100500::Decimal(10, 2), toUInt256(1), 123456789012345678901234567890::UInt128; +SELECT 1 FROM system.one; +SELECT name, engine FROM system.tables WHERE database = currentDatabase(); diff --git a/testdata/realworld/clickhouse/06_materialized_views.sql b/testdata/realworld/clickhouse/06_materialized_views.sql new file mode 100644 index 00000000..932ebf51 --- /dev/null +++ b/testdata/realworld/clickhouse/06_materialized_views.sql @@ -0,0 +1,37 @@ +-- Materialized views, TO tables, aggregating pipelines, dictionaries, views +CREATE MATERIALIZED VIEW mv_daily TO daily_agg AS SELECT toDate(ts) AS day, key, uniqState(user_id) AS uniq_state FROM events GROUP BY day, key; +CREATE MATERIALIZED VIEW IF NOT EXISTS mv_hourly TO hourly_sum AS SELECT toStartOfHour(ts) AS hour, sum(value) AS total FROM metrics GROUP BY hour; +CREATE MATERIALIZED VIEW mv_engine ENGINE = SummingMergeTree ORDER BY (day, key) POPULATE AS SELECT toDate(ts) AS day, key, count() AS c FROM events GROUP BY day, key; +CREATE MATERIALIZED VIEW mv_engine2 ENGINE = AggregatingMergeTree PARTITION BY toYYYYMM(day) ORDER BY (day, key) AS SELECT toDate(ts) AS day, key, sumState(v) AS s FROM events GROUP BY day, key; +CREATE MATERIALIZED VIEW mv_cols TO dst (day Date, c UInt64) AS SELECT toDate(ts) AS day, count() AS c FROM src GROUP BY day; +CREATE MATERIALIZED VIEW mv_cluster ON CLUSTER my_cluster TO dst AS SELECT * FROM src; +CREATE MATERIALIZED VIEW kafka_mv TO events AS SELECT JSONExtractString(raw, 'id') AS id, now() AS ts FROM kafka_queue; +CREATE VIEW v_active_users AS SELECT id, email FROM users WHERE is_active = 1; +CREATE OR REPLACE VIEW v_recent AS SELECT * FROM events WHERE ts > now() - INTERVAL 1 DAY; +CREATE VIEW IF NOT EXISTS v_param AS SELECT * FROM events WHERE user_id = {uid:UInt64}; +CREATE TABLE kafka_queue (raw String) ENGINE = Kafka SETTINGS kafka_broker_list = 'kafka:9092', kafka_topic_list = 'events', kafka_group_name = 'ch', kafka_format = 'JSONAsString', kafka_num_consumers = 1; +CREATE TABLE s3_tbl (a UInt32, b String) ENGINE = S3('https://bucket.s3.amazonaws.com/data.csv', 'CSV'); +CREATE TABLE pg_tbl (id Int32, name String) ENGINE = PostgreSQL('pg:5432', 'db', 'tbl', 'user', 'pass'); +CREATE TABLE mysql_tbl (id Int32) ENGINE = MySQL('mysql:3306', 'db', 'tbl', 'user', 'pass'); +CREATE TABLE join_tbl (k UInt64, v String) ENGINE = Join(ANY, LEFT, k); +CREATE TABLE buf (a UInt8) ENGINE = Buffer(default, target, 16, 10, 100, 10000, 1000000, 10000000, 100000000); +CREATE TABLE merge_all (a UInt8) ENGINE = Merge(currentDatabase(), '^events_'); +CREATE DICTIONARY country_dict (id UInt64, name String) PRIMARY KEY id SOURCE(CLICKHOUSE(TABLE 'countries')) LIFETIME(MIN 300 MAX 360) LAYOUT(HASHED()); +CREATE DICTIONARY ip_trie (prefix String, asn UInt32) PRIMARY KEY prefix SOURCE(FILE(PATH '/data/ip.csv' FORMAT CSV)) LIFETIME(0) LAYOUT(IP_TRIE()); +SELECT dictGet('country_dict', 'name', toUInt64(country_id)) AS country FROM events; +SELECT dictGetOrDefault('country_dict', 'name', toUInt64(id), 'n/a') FROM events; +SELECT dictHas('country_dict', toUInt64(5)); +SYSTEM RELOAD DICTIONARY country_dict; +CREATE FUNCTION linear_equation AS (x, k, b) -> k * x + b; +CREATE FUNCTION IF NOT EXISTS plus_one AS x -> x + 1; +DROP FUNCTION linear_equation; +DROP VIEW IF EXISTS mv_daily; +DROP DICTIONARY IF EXISTS country_dict; +CREATE ROW POLICY pol ON events USING user_id = 1 TO ALL; +CREATE USER IF NOT EXISTS analyst IDENTIFIED WITH sha256_password BY 'secret'; +CREATE ROLE reader; +GRANT SELECT ON default.* TO reader; +GRANT reader TO analyst; +REVOKE SELECT ON default.events FROM analyst; +CREATE QUOTA q FOR INTERVAL 1 HOUR MAX queries = 100 TO analyst; +CREATE SETTINGS PROFILE p SETTINGS max_threads = 4 TO analyst; diff --git a/testdata/realworld/clickhouse/07_observability.sql b/testdata/realworld/clickhouse/07_observability.sql new file mode 100644 index 00000000..547896e7 --- /dev/null +++ b/testdata/realworld/clickhouse/07_observability.sql @@ -0,0 +1,47 @@ +-- Observability and admin: system tables, query logs, parts, merges +SELECT query_id, query, query_duration_ms, read_rows, memory_usage FROM system.query_log WHERE type = 'QueryFinish' AND event_time > now() - INTERVAL 1 HOUR ORDER BY query_duration_ms DESC LIMIT 20; +SELECT normalizedQueryHash(query) AS h, count() AS c, avg(query_duration_ms) AS avg_ms, quantile(0.95)(query_duration_ms) AS p95 FROM system.query_log WHERE event_date = today() AND type = 'QueryFinish' GROUP BY h ORDER BY c DESC LIMIT 20; +SELECT database, table, sum(rows) AS total_rows, formatReadableSize(sum(bytes_on_disk)) AS size, count() AS parts FROM system.parts WHERE active GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC; +SELECT table, partition, name, rows, modification_time FROM system.parts WHERE active AND database = 'default' AND table = 'events' ORDER BY modification_time DESC LIMIT 10; +SELECT * FROM system.merges; +SELECT * FROM system.mutations WHERE NOT is_done; +SELECT * FROM system.replicas WHERE is_readonly OR absolute_delay > 60; +SELECT * FROM system.processes ORDER BY elapsed DESC; +SELECT metric, value FROM system.metrics WHERE metric LIKE 'Memory%'; +SELECT event, value FROM system.events WHERE event IN ('Query', 'SelectQuery', 'InsertQuery'); +SELECT name, value, changed FROM system.settings WHERE changed; +SELECT database, name, engine, total_rows, total_bytes FROM system.tables WHERE database NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema'); +SELECT database, table, name, type, compression_codec FROM system.columns WHERE database = 'default'; +SELECT hostName(), uptime(), version(), currentUser(), currentDatabase(); +SELECT toStartOfMinute(event_time) AS m, count() FROM system.query_log GROUP BY m ORDER BY m; +SELECT arrayJoin(stack_trace) FROM system.stack_trace LIMIT 5; +SELECT exception_code, any(exception) AS ex, count() FROM system.query_log WHERE exception_code != 0 GROUP BY exception_code ORDER BY count() DESC; +SELECT user, count() FROM system.query_log WHERE query_kind = 'Select' GROUP BY user; +SELECT query, ProfileEvents['SelectedRows'] AS rows_read, ProfileEvents['RealTimeMicroseconds'] AS rt FROM system.query_log WHERE type = 'QueryFinish' LIMIT 5; +SELECT tables, Settings['max_threads'] FROM system.query_log LIMIT 1; +SELECT disk_name, formatReadableSize(free_space), formatReadableSize(total_space) FROM system.disks; +SELECT * FROM system.asynchronous_metrics WHERE metric LIKE '%Memory%'; +SELECT * FROM system.clusters WHERE cluster = 'my_cluster'; +SELECT * FROM system.zookeeper WHERE path = '/clickhouse'; +SELECT * FROM system.part_log WHERE event_type = 'MergeParts' AND event_date = today(); +SHOW TABLES; +SHOW DATABASES; +SHOW TABLES FROM system LIKE 'query%'; +SHOW CREATE TABLE events; +DESCRIBE TABLE events; +DESC events; +EXISTS TABLE events; +USE analytics; +SET max_threads = 8; +SYSTEM FLUSH LOGS; +SYSTEM DROP DNS CACHE; +SYSTEM STOP MERGES events; +SYSTEM START MERGES events; +SYSTEM SYNC REPLICA events; +KILL QUERY WHERE query_id = 'abc' SYNC; +KILL MUTATION WHERE database = 'default' AND table = 'events'; +EXPLAIN SELECT * FROM events WHERE user_id = 1; +EXPLAIN PIPELINE SELECT count() FROM events; +EXPLAIN AST SELECT 1; +EXPLAIN SYNTAX SELECT 1 + 1; +EXPLAIN indexes = 1 SELECT * FROM events WHERE user_id = 1; diff --git a/testdata/realworld/clickhouse/08_etl_pipelines.sql b/testdata/realworld/clickhouse/08_etl_pipelines.sql new file mode 100644 index 00000000..3372333f --- /dev/null +++ b/testdata/realworld/clickhouse/08_etl_pipelines.sql @@ -0,0 +1,41 @@ +-- ETL / backfill / data-quality patterns +INSERT INTO events SELECT * FROM events_old WHERE ts >= '2024-01-01' AND ts < '2024-02-01'; +INSERT INTO daily_agg SELECT toDate(ts) AS day, key, uniqState(user_id), quantileState(0.9)(v) FROM events GROUP BY day, key; +INSERT INTO t (a, b) SETTINGS async_insert = 1, wait_for_async_insert = 0 VALUES (1, 'x'); +INSERT INTO FUNCTION s3('https://bucket.s3.amazonaws.com/out.parquet', 'Parquet') SELECT * FROM events; +INSERT INTO t SELECT * FROM file('in.csv', 'CSVWithNames'); +INSERT INTO t SELECT * FROM url('https://example.com/x.json', 'JSONEachRow'); +INSERT INTO t SELECT number, toString(number) FROM numbers(1000000); +SELECT count(), uniqExact(id), count() - uniqExact(id) AS dupes FROM events; +SELECT id, count() AS c FROM events GROUP BY id HAVING c > 1 ORDER BY c DESC LIMIT 100; +SELECT countIf(x IS NULL) AS null_cnt, countIf(x = '') AS empties, count() AS total FROM t; +SELECT min(ts), max(ts), dateDiff('hour', min(ts), max(ts)) FROM events; +SELECT partition, count() FROM system.parts WHERE table = 'events' AND active GROUP BY partition ORDER BY partition; +SELECT toDate(ts) AS d, count() FROM events GROUP BY d HAVING count() < 100 ORDER BY d; +SELECT * FROM events WHERE ts >= '2024-01-01 00:00:00' AND ts < '2024-01-02 00:00:00' AND NOT (user_id = 0 OR name = ''); +SELECT a.id FROM src AS a LEFT ANTI JOIN dst AS b ON a.id = b.id; +SELECT id FROM src WHERE id NOT IN (SELECT id FROM dst); +SELECT s.id, s.v FROM src AS s ANY LEFT JOIN dst AS d ON s.id = d.id WHERE d.id = 0; +SELECT arrayJoin(range(0, 10)) AS i; +SELECT ts, value, runningDifference(value) AS delta FROM (SELECT ts, value FROM metrics ORDER BY ts); +SELECT neighbor(value, -1) AS prev, value FROM metrics; +SELECT id, any(name), anyLast(name), min(ts), max(ts) FROM events GROUP BY id; +SELECT id, argMax(name, version) FROM users_rmt GROUP BY id; +SELECT tuple(a, b) AS pair, (a, b) AS pair2, tupleElement(pair, 1) FROM t; +SELECT t.1, t.2 FROM (SELECT (1, 'a') AS t); +SELECT toString(now(), 'UTC'), toUnixTimestamp(now()), fromUnixTimestamp(1700000000), toDate32('1990-01-01'); +SELECT count() FROM events WHERE has(tags, 'x') AND length(tags) > 2 AND arrayExists(t -> t = 'y', tags); +SELECT if(isNull(x), 0, x) + 1 FROM t; +SELECT a > 1 ? 'big' : 'small' FROM t; +SELECT intDiv(toUInt32(ts), 300) * 300 AS bucket, count() FROM events GROUP BY bucket; +SELECT lower(hex(MD5(concat(a, '|', b)))) AS k FROM t; +SELECT (SELECT count() FROM events) AS total, (SELECT max(ts) FROM events) AS latest; +SELECT e.id FROM events e WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = e.user_id); +SELECT * FROM events WHERE user_id IN (SELECT id FROM users WHERE country IN ('US', 'CA')) AND ts > now() - INTERVAL 7 DAY ORDER BY ts DESC LIMIT 1000; +SELECT * FROM events e INNER JOIN (SELECT user_id, max(ts) AS ts FROM events GROUP BY user_id) m ON e.user_id = m.user_id AND e.ts = m.ts; +ALTER TABLE events REPLACE PARTITION '202401' FROM events_staging; +ALTER TABLE events MOVE PARTITION '202401' TO TABLE events_archive; +ALTER TABLE events UPDATE name = 'x' IN PARTITION '202401' WHERE id = 1; +ALTER TABLE events DELETE IN PARTITION '202401' WHERE ts < '2024-01-05'; +ALTER TABLE events CLEAR COLUMN legacy IN PARTITION '202401'; +OPTIMIZE TABLE events_staging FINAL; diff --git a/testdata/realworld/clickhouse/09_deep_nesting.sql b/testdata/realworld/clickhouse/09_deep_nesting.sql new file mode 100644 index 00000000..da92b92a --- /dev/null +++ b/testdata/realworld/clickhouse/09_deep_nesting.sql @@ -0,0 +1,38 @@ +-- Stress: deep nesting, long expressions, many CTEs, complex joins +SELECT ((((1 + 2) * (3 + 4)) - ((5 + 6) / (7 + 8))) + (((9)))) AS v; +SELECT if(a > 1, if(b > 2, if(c > 3, 'x', 'y'), 'z'), if(d > 4, 'w', 'v')) FROM t; +SELECT multiIf(a = 1, 'one', a = 2, 'two', a = 3, 'three', a = 4, 'four', a = 5, 'five', 'many') FROM t; +SELECT CASE a WHEN 1 THEN 'one' WHEN 2 THEN 'two' ELSE 'other' END, CASE WHEN a > 1 AND b < 2 OR c = 3 THEN 1 ELSE 0 END FROM t; +SELECT a AND b OR c AND NOT d, NOT a = b, a OR b AND c FROM t; +SELECT -a + b * c - d / e % f FROM t; +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT 1 AS x)))); +SELECT x FROM t WHERE x IN (SELECT y FROM u WHERE y IN (SELECT z FROM v WHERE z IN (SELECT w FROM w1))); +SELECT sum(x) FROM (SELECT a.x FROM a JOIN b ON a.k = b.k JOIN c ON b.k = c.k JOIN d ON c.k = d.k JOIN e ON d.k = e.k JOIN f ON e.k = f.k JOIN g ON f.k = g.k); +SELECT u.id, o.cnt, p.total, r.refunds FROM users AS u LEFT JOIN (SELECT user_id, count() AS cnt FROM orders GROUP BY user_id) AS o ON u.id = o.user_id LEFT JOIN (SELECT user_id, sum(amount) AS total FROM payments GROUP BY user_id) AS p ON u.id = p.user_id LEFT JOIN (SELECT user_id, sum(amount) AS refunds FROM refunds GROUP BY user_id) AS r ON u.id = r.user_id WHERE u.is_active = 1 ORDER BY p.total DESC LIMIT 50; +WITH base AS (SELECT * FROM events WHERE ts > now() - INTERVAL 1 DAY), by_user AS (SELECT user_id, count() AS c FROM base GROUP BY user_id), top AS (SELECT * FROM by_user ORDER BY c DESC LIMIT 100), joined AS (SELECT t.user_id, t.c, u.email FROM top AS t INNER JOIN users AS u ON t.user_id = u.id), fin AS (SELECT *, c * 2 AS c2 FROM joined) SELECT * FROM fin ORDER BY c DESC; +SELECT arrayMap(x -> arrayMap(y -> y * x, [1, 2, 3]), [1, 2, 3]); +SELECT arrayFilter(x -> x IN (SELECT id FROM t), arr) FROM s; +SELECT arrayMap(x -> if(x > 0, x, -x), arr), arrayMap(x -> (x, x * 2), arr) FROM t; +SELECT arrayMap(x -> x.1, arr) FROM t; +SELECT sumIf(a, b > 0 AND c IN (1, 2, 3)) / nullIf(countIf(d), 0) FROM t; +SELECT toStartOfInterval(ts, INTERVAL 15 MINUTE) AS b, groupArray((ts, v)) FROM t GROUP BY b; +SELECT a FROM t1 UNION ALL SELECT b FROM t2 UNION ALL (SELECT c FROM t3 ORDER BY c LIMIT 1) ORDER BY 1; +(SELECT 1) UNION ALL (SELECT 2); +SELECT * FROM (SELECT 1 AS a UNION ALL SELECT 2 AS a) AS u WHERE a > 1; +SELECT 1 AS a, 2 AS b ORDER BY a DESC, b ASC NULLS FIRST; +SELECT x FROM t ORDER BY x DESC NULLS LAST, y COLLATE 'en' LIMIT 5; +SELECT x FROM t ORDER BY x LIMIT 10 OFFSET 5; +SELECT x FROM t ORDER BY x OFFSET 5 ROWS FETCH FIRST 10 ROWS ONLY; +SELECT x FROM t ORDER BY x LIMIT 10 WITH TIES; +SELECT DISTINCT ON (a) a, b FROM t; +SELECT count(DISTINCT user_id), count(DISTINCT a, b), uniqCombined(a), uniqHLL12(a) FROM t; +SELECT a FROM t GROUP BY ALL; +SELECT a, b FROM t ORDER BY ALL; +SELECT a, count() FROM t GROUP BY 1 ORDER BY 2 DESC; +SELECT number AS n, n * 2 AS d, d + 1 AS e FROM numbers(3); +SELECT t.* FROM t; +SELECT db.t.col FROM db.t; +SELECT "a b" FROM "my table" AS "t 1"; +SELECT 1 /* inline */ + 2 -- trailing +; +SELECT '2024-01-01'::Date + INTERVAL 1 MONTH, now() - INTERVAL '1' DAY, INTERVAL 2 HOUR + INTERVAL 30 MINUTE; diff --git a/testdata/realworld/clickhouse/10_misc_real.sql b/testdata/realworld/clickhouse/10_misc_real.sql new file mode 100644 index 00000000..4d2c080b --- /dev/null +++ b/testdata/realworld/clickhouse/10_misc_real.sql @@ -0,0 +1,29 @@ +-- Miscellaneous real-world: Grafana, Superset, dbt-clickhouse, clickhouse-client generated +SELECT toStartOfInterval(toDateTime(ts), INTERVAL 60 second) AS t, avg(value) AS v FROM metrics WHERE ts >= toDateTime(1700000000) AND ts <= toDateTime(1700003600) GROUP BY t ORDER BY t ASC; +SELECT toUInt32(toStartOfInterval(ts, INTERVAL 1 MINUTE)) * 1000 AS time, count() AS value FROM web_events WHERE ts BETWEEN toDateTime(1700000000) AND toDateTime(1700003600) GROUP BY time ORDER BY time; +SELECT name FROM system.databases ORDER BY name; +SELECT database, name FROM system.tables WHERE engine NOT LIKE '%View' ORDER BY 1, 2; +SELECT * FROM events LIMIT 1 SETTINGS readonly = 1; +SELECT 'ok' FORMAT Null; +SELECT count() FROM events WHERE toDate(ts) = today() FORMAT TSV; +CREATE TABLE dbt_tmp__dbt_tmp (id UInt64, v String) ENGINE = MergeTree ORDER BY id SETTINGS replicated_deduplication_window = 0; +CREATE TABLE IF NOT EXISTS t_final (id UInt64, v String, ts DateTime) ENGINE = ReplacingMergeTree(ts) PRIMARY KEY id ORDER BY id; +CREATE TABLE t_primary (a UInt32, b UInt32) ENGINE = MergeTree PRIMARY KEY a ORDER BY (a, b); +CREATE TABLE t_comment (a UInt8) ENGINE = MergeTree ORDER BY a COMMENT 'a table'; +CREATE TABLE t_ttl_group (d Date, k UInt32, x UInt32, y UInt32) ENGINE = MergeTree ORDER BY (k, toStartOfMonth(d)) TTL d + INTERVAL 1 MONTH GROUP BY k, toStartOfMonth(d) SET x = max(x), y = min(y); +CREATE TABLE t_col_ttl (d DateTime, a Int32 TTL d + INTERVAL 1 DAY, b Int32) ENGINE = MergeTree ORDER BY d; +CREATE TABLE t_codec (a UInt64 CODEC(Delta(8), LZ4), b Float64 CODEC(Gorilla, ZSTD(3)), c String CODEC(ZSTD)) ENGINE = MergeTree ORDER BY a; +CREATE TABLE t_ipv (a IPv4, b IPv6, c UUID, d Date32, e DateTime('UTC'), f FixedString(16), g Bool, h Int128, i Float32) ENGINE = MergeTree ORDER BY a; +CREATE TABLE t_complex (a Array(Array(UInt8)), b Map(String, Array(UInt32)), c Tuple(UInt8, String), d Nullable(DateTime64(6, 'UTC')), e Array(Nullable(String)), f LowCardinality(Nullable(String)), g Map(LowCardinality(String), UInt64)) ENGINE = MergeTree ORDER BY tuple(); +CREATE TABLE t_agg (a SimpleAggregateFunction(sum, UInt64), b SimpleAggregateFunction(max, DateTime), c AggregateFunction(groupArray, String)) ENGINE = AggregatingMergeTree ORDER BY tuple(); +CREATE TEMPORARY TABLE tmp1 (a UInt8) ENGINE = Memory; +CREATE TEMPORARY TABLE tmp2 ENGINE = Memory AS SELECT 1 AS a; +CREATE TABLE t_settings (a UInt8) ENGINE = MergeTree ORDER BY a SETTINGS storage_policy = 'tiered', min_bytes_for_wide_part = 0, merge_with_ttl_timeout = 3600; +CREATE TABLE t_part_expr (ts DateTime, a UInt8) ENGINE = MergeTree PARTITION BY (toYYYYMM(ts), a % 4) ORDER BY ts; +CREATE TABLE t_orderby_expr (ts DateTime, u UInt64) ENGINE = MergeTree ORDER BY (toStartOfHour(ts), cityHash64(u) % 16, u); +CREATE TABLE t_ver (id UInt64, sign Int8, ver UInt32) ENGINE = VersionedCollapsingMergeTree(sign, ver) ORDER BY id; +CREATE TABLE t_cons (a UInt8, CONSTRAINT c1 CHECK a > 0, CONSTRAINT c2 ASSUME a < 100) ENGINE = MergeTree ORDER BY a; +CREATE TABLE t_cmt (a UInt8 COMMENT 'col a', b String CODEC(ZSTD) COMMENT 'col b') ENGINE = MergeTree ORDER BY a; +CREATE TABLE t_ro (a UInt8) ENGINE = MergeTree ORDER BY a SETTINGS index_granularity = 8192, index_granularity_bytes = 10485760; +CREATE TABLE t_sk (a UInt8, b String, INDEX i1 (a, b) TYPE set(100) GRANULARITY 2, INDEX i2 lower(b) TYPE ngrambf_v1(4, 1024, 1, 0) GRANULARITY 1) ENGINE = MergeTree ORDER BY a; +CREATE TABLE t_proj (a UInt8, b UInt8, PROJECTION p1 (SELECT a, sum(b) GROUP BY a)) ENGINE = MergeTree ORDER BY a; diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt new file mode 100644 index 00000000..be9163ad --- /dev/null +++ b/testdata/realworld/known_failures.txt @@ -0,0 +1,1255 @@ +# Real-world corpus statements that GoSQLX does not yet handle. +# Format: /# +# Stages: parse, reparse, idempotent, drop (formatter lost words), panic. +# Regenerate: go test ./pkg/gosqlx -run TestRealWorldCorpus -update-known-failures +clickhouse/01_app_crud.sql#10 parse +clickhouse/01_app_crud.sql#11 parse +clickhouse/01_app_crud.sql#12 parse +clickhouse/01_app_crud.sql#13 parse +clickhouse/01_app_crud.sql#18 parse +clickhouse/02_reporting.sql#17 drop +clickhouse/02_reporting.sql#18 drop +clickhouse/02_reporting.sql#19 drop +clickhouse/02_reporting.sql#2 drop +clickhouse/02_reporting.sql#25 drop +clickhouse/02_reporting.sql#3 drop +clickhouse/02_reporting.sql#6 drop +clickhouse/03_ddl_migrations.sql#0 drop +clickhouse/03_ddl_migrations.sql#1 drop +clickhouse/03_ddl_migrations.sql#10 drop +clickhouse/03_ddl_migrations.sql#11 drop +clickhouse/03_ddl_migrations.sql#12 parse +clickhouse/03_ddl_migrations.sql#13 parse +clickhouse/03_ddl_migrations.sql#14 drop +clickhouse/03_ddl_migrations.sql#17 drop +clickhouse/03_ddl_migrations.sql#18 reparse +clickhouse/03_ddl_migrations.sql#19 parse +clickhouse/03_ddl_migrations.sql#2 drop +clickhouse/03_ddl_migrations.sql#20 parse +clickhouse/03_ddl_migrations.sql#21 parse +clickhouse/03_ddl_migrations.sql#23 parse +clickhouse/03_ddl_migrations.sql#24 parse +clickhouse/03_ddl_migrations.sql#25 parse +clickhouse/03_ddl_migrations.sql#26 parse +clickhouse/03_ddl_migrations.sql#27 parse +clickhouse/03_ddl_migrations.sql#28 parse +clickhouse/03_ddl_migrations.sql#3 drop +clickhouse/03_ddl_migrations.sql#30 parse +clickhouse/03_ddl_migrations.sql#31 parse +clickhouse/03_ddl_migrations.sql#32 parse +clickhouse/03_ddl_migrations.sql#33 parse +clickhouse/03_ddl_migrations.sql#34 parse +clickhouse/03_ddl_migrations.sql#35 parse +clickhouse/03_ddl_migrations.sql#36 parse +clickhouse/03_ddl_migrations.sql#37 parse +clickhouse/03_ddl_migrations.sql#38 parse +clickhouse/03_ddl_migrations.sql#39 parse +clickhouse/03_ddl_migrations.sql#4 drop +clickhouse/03_ddl_migrations.sql#40 parse +clickhouse/03_ddl_migrations.sql#41 parse +clickhouse/03_ddl_migrations.sql#42 parse +clickhouse/03_ddl_migrations.sql#43 parse +clickhouse/03_ddl_migrations.sql#44 parse +clickhouse/03_ddl_migrations.sql#45 parse +clickhouse/03_ddl_migrations.sql#46 parse +clickhouse/03_ddl_migrations.sql#5 drop +clickhouse/03_ddl_migrations.sql#6 drop +clickhouse/03_ddl_migrations.sql#7 drop +clickhouse/03_ddl_migrations.sql#8 drop +clickhouse/03_ddl_migrations.sql#9 reparse +clickhouse/04_window_analytics.sql#10 reparse +clickhouse/04_window_analytics.sql#19 drop +clickhouse/04_window_analytics.sql#21 drop +clickhouse/04_window_analytics.sql#25 parse +clickhouse/04_window_analytics.sql#26 parse +clickhouse/04_window_analytics.sql#28 parse +clickhouse/04_window_analytics.sql#9 parse +clickhouse/05_dialect_specific.sql#0 drop +clickhouse/05_dialect_specific.sql#1 drop +clickhouse/05_dialect_specific.sql#10 drop +clickhouse/05_dialect_specific.sql#13 parse +clickhouse/05_dialect_specific.sql#16 drop +clickhouse/05_dialect_specific.sql#17 parse +clickhouse/05_dialect_specific.sql#26 parse +clickhouse/05_dialect_specific.sql#35 parse +clickhouse/05_dialect_specific.sql#36 parse +clickhouse/05_dialect_specific.sql#37 parse +clickhouse/05_dialect_specific.sql#38 drop +clickhouse/05_dialect_specific.sql#39 drop +clickhouse/05_dialect_specific.sql#40 drop +clickhouse/05_dialect_specific.sql#5 drop +clickhouse/05_dialect_specific.sql#50 parse +clickhouse/05_dialect_specific.sql#55 parse +clickhouse/05_dialect_specific.sql#56 parse +clickhouse/05_dialect_specific.sql#6 drop +clickhouse/05_dialect_specific.sql#62 drop +clickhouse/05_dialect_specific.sql#64 parse +clickhouse/05_dialect_specific.sql#65 parse +clickhouse/05_dialect_specific.sql#66 parse +clickhouse/05_dialect_specific.sql#67 parse +clickhouse/05_dialect_specific.sql#68 idempotent +clickhouse/05_dialect_specific.sql#7 drop +clickhouse/05_dialect_specific.sql#8 drop +clickhouse/05_dialect_specific.sql#9 drop +clickhouse/06_materialized_views.sql#0 drop +clickhouse/06_materialized_views.sql#1 drop +clickhouse/06_materialized_views.sql#10 drop +clickhouse/06_materialized_views.sql#14 parse +clickhouse/06_materialized_views.sql#16 parse +clickhouse/06_materialized_views.sql#17 parse +clickhouse/06_materialized_views.sql#18 parse +clickhouse/06_materialized_views.sql#2 drop +clickhouse/06_materialized_views.sql#22 parse +clickhouse/06_materialized_views.sql#23 parse +clickhouse/06_materialized_views.sql#24 parse +clickhouse/06_materialized_views.sql#25 parse +clickhouse/06_materialized_views.sql#27 parse +clickhouse/06_materialized_views.sql#28 parse +clickhouse/06_materialized_views.sql#29 parse +clickhouse/06_materialized_views.sql#3 drop +clickhouse/06_materialized_views.sql#30 parse +clickhouse/06_materialized_views.sql#31 parse +clickhouse/06_materialized_views.sql#32 parse +clickhouse/06_materialized_views.sql#33 parse +clickhouse/06_materialized_views.sql#34 parse +clickhouse/06_materialized_views.sql#35 parse +clickhouse/06_materialized_views.sql#4 parse +clickhouse/06_materialized_views.sql#5 parse +clickhouse/06_materialized_views.sql#6 drop +clickhouse/06_materialized_views.sql#8 drop +clickhouse/06_materialized_views.sql#9 parse +clickhouse/07_observability.sql#0 drop +clickhouse/07_observability.sql#16 parse +clickhouse/07_observability.sql#27 parse +clickhouse/07_observability.sql#29 drop +clickhouse/07_observability.sql#30 drop +clickhouse/07_observability.sql#31 parse +clickhouse/07_observability.sql#32 parse +clickhouse/07_observability.sql#33 parse +clickhouse/07_observability.sql#34 parse +clickhouse/07_observability.sql#35 parse +clickhouse/07_observability.sql#36 parse +clickhouse/07_observability.sql#37 parse +clickhouse/07_observability.sql#38 parse +clickhouse/07_observability.sql#39 parse +clickhouse/07_observability.sql#40 parse +clickhouse/08_etl_pipelines.sql#14 parse +clickhouse/08_etl_pipelines.sql#16 drop +clickhouse/08_etl_pipelines.sql#17 parse +clickhouse/08_etl_pipelines.sql#2 parse +clickhouse/08_etl_pipelines.sql#20 parse +clickhouse/08_etl_pipelines.sql#27 parse +clickhouse/08_etl_pipelines.sql#3 parse +clickhouse/08_etl_pipelines.sql#32 drop +clickhouse/08_etl_pipelines.sql#34 parse +clickhouse/08_etl_pipelines.sql#35 parse +clickhouse/08_etl_pipelines.sql#36 parse +clickhouse/08_etl_pipelines.sql#37 parse +clickhouse/08_etl_pipelines.sql#38 parse +clickhouse/08_etl_pipelines.sql#39 parse +clickhouse/09_deep_nesting.sql#10 drop +clickhouse/09_deep_nesting.sql#16 drop +clickhouse/09_deep_nesting.sql#17 parse +clickhouse/09_deep_nesting.sql#21 parse +clickhouse/09_deep_nesting.sql#24 parse +clickhouse/09_deep_nesting.sql#27 parse +clickhouse/09_deep_nesting.sql#28 parse +clickhouse/09_deep_nesting.sql#35 drop +clickhouse/10_misc_real.sql#0 drop +clickhouse/10_misc_real.sql#1 drop +clickhouse/10_misc_real.sql#10 drop +clickhouse/10_misc_real.sql#11 drop +clickhouse/10_misc_real.sql#12 parse +clickhouse/10_misc_real.sql#13 drop +clickhouse/10_misc_real.sql#14 drop +clickhouse/10_misc_real.sql#15 drop +clickhouse/10_misc_real.sql#16 drop +clickhouse/10_misc_real.sql#18 parse +clickhouse/10_misc_real.sql#19 drop +clickhouse/10_misc_real.sql#20 drop +clickhouse/10_misc_real.sql#21 drop +clickhouse/10_misc_real.sql#22 drop +clickhouse/10_misc_real.sql#23 parse +clickhouse/10_misc_real.sql#24 parse +clickhouse/10_misc_real.sql#25 drop +clickhouse/10_misc_real.sql#26 parse +clickhouse/10_misc_real.sql#27 drop +clickhouse/10_misc_real.sql#5 parse +clickhouse/10_misc_real.sql#6 parse +clickhouse/10_misc_real.sql#7 drop +clickhouse/10_misc_real.sql#8 drop +clickhouse/10_misc_real.sql#9 drop +mariadb/01_app_crud.sql#13 parse +mariadb/01_app_crud.sql#14 parse +mariadb/01_app_crud.sql#16 parse +mariadb/01_app_crud.sql#17 parse +mariadb/01_app_crud.sql#26 parse +mariadb/01_app_crud.sql#27 parse +mariadb/01_app_crud.sql#28 parse +mariadb/01_app_crud.sql#3 parse +mariadb/01_app_crud.sql#7 parse +mariadb/02_reporting.sql#1 drop +mariadb/02_reporting.sql#13 parse +mariadb/02_reporting.sql#17 parse +mariadb/02_reporting.sql#19 parse +mariadb/02_reporting.sql#20 parse +mariadb/02_reporting.sql#21 parse +mariadb/02_reporting.sql#25 drop +mariadb/02_reporting.sql#26 drop +mariadb/02_reporting.sql#27 parse +mariadb/02_reporting.sql#29 parse +mariadb/02_reporting.sql#3 drop +mariadb/02_reporting.sql#30 parse +mariadb/02_reporting.sql#31 parse +mariadb/02_reporting.sql#33 parse +mariadb/02_reporting.sql#38 parse +mariadb/02_reporting.sql#39 parse +mariadb/02_reporting.sql#40 parse +mariadb/02_reporting.sql#41 parse +mariadb/03_ddl_migrations.sql#0 parse +mariadb/03_ddl_migrations.sql#1 parse +mariadb/03_ddl_migrations.sql#10 drop +mariadb/03_ddl_migrations.sql#13 parse +mariadb/03_ddl_migrations.sql#14 parse +mariadb/03_ddl_migrations.sql#17 parse +mariadb/03_ddl_migrations.sql#18 parse +mariadb/03_ddl_migrations.sql#19 parse +mariadb/03_ddl_migrations.sql#2 parse +mariadb/03_ddl_migrations.sql#20 parse +mariadb/03_ddl_migrations.sql#21 parse +mariadb/03_ddl_migrations.sql#22 parse +mariadb/03_ddl_migrations.sql#23 parse +mariadb/03_ddl_migrations.sql#25 parse +mariadb/03_ddl_migrations.sql#26 parse +mariadb/03_ddl_migrations.sql#27 parse +mariadb/03_ddl_migrations.sql#29 parse +mariadb/03_ddl_migrations.sql#3 parse +mariadb/03_ddl_migrations.sql#31 parse +mariadb/03_ddl_migrations.sql#32 parse +mariadb/03_ddl_migrations.sql#36 parse +mariadb/03_ddl_migrations.sql#37 parse +mariadb/03_ddl_migrations.sql#38 parse +mariadb/03_ddl_migrations.sql#39 parse +mariadb/03_ddl_migrations.sql#4 parse +mariadb/03_ddl_migrations.sql#40 parse +mariadb/03_ddl_migrations.sql#41 parse +mariadb/03_ddl_migrations.sql#42 parse +mariadb/03_ddl_migrations.sql#43 parse +mariadb/03_ddl_migrations.sql#44 parse +mariadb/03_ddl_migrations.sql#45 parse +mariadb/03_ddl_migrations.sql#46 parse +mariadb/03_ddl_migrations.sql#47 parse +mariadb/03_ddl_migrations.sql#48 parse +mariadb/03_ddl_migrations.sql#49 parse +mariadb/03_ddl_migrations.sql#5 parse +mariadb/03_ddl_migrations.sql#50 parse +mariadb/03_ddl_migrations.sql#51 parse +mariadb/03_ddl_migrations.sql#52 parse +mariadb/03_ddl_migrations.sql#53 parse +mariadb/03_ddl_migrations.sql#6 parse +mariadb/03_ddl_migrations.sql#7 parse +mariadb/03_ddl_migrations.sql#8 reparse +mariadb/04_window_analytics.sql#14 drop +mariadb/04_window_analytics.sql#18 parse +mariadb/04_window_analytics.sql#4 drop +mariadb/04_window_analytics.sql#5 parse +mariadb/05_mariadb_specific.sql#11 parse +mariadb/05_mariadb_specific.sql#14 parse +mariadb/05_mariadb_specific.sql#17 parse +mariadb/05_mariadb_specific.sql#26 drop +mariadb/05_mariadb_specific.sql#27 drop +mariadb/05_mariadb_specific.sql#28 parse +mariadb/05_mariadb_specific.sql#29 parse +mariadb/05_mariadb_specific.sql#30 parse +mariadb/05_mariadb_specific.sql#31 parse +mariadb/05_mariadb_specific.sql#32 parse +mariadb/05_mariadb_specific.sql#33 parse +mariadb/05_mariadb_specific.sql#34 drop +mariadb/05_mariadb_specific.sql#35 drop +mariadb/05_mariadb_specific.sql#36 drop +mariadb/05_mariadb_specific.sql#37 drop +mariadb/05_mariadb_specific.sql#38 drop +mariadb/05_mariadb_specific.sql#39 parse +mariadb/05_mariadb_specific.sql#40 parse +mariadb/05_mariadb_specific.sql#41 parse +mariadb/05_mariadb_specific.sql#51 parse +mariadb/05_mariadb_specific.sql#53 parse +mariadb/05_mariadb_specific.sql#57 parse +mariadb/05_mariadb_specific.sql#58 parse +mariadb/05_mariadb_specific.sql#6 parse +mariadb/05_mariadb_specific.sql#63 parse +mariadb/05_mariadb_specific.sql#65 parse +mariadb/05_mariadb_specific.sql#66 parse +mariadb/05_mariadb_specific.sql#67 parse +mariadb/05_mariadb_specific.sql#68 parse +mariadb/05_mariadb_specific.sql#69 parse +mariadb/05_mariadb_specific.sql#9 parse +mariadb/06_etl_admin.sql#12 parse +mariadb/06_etl_admin.sql#13 parse +mariadb/06_etl_admin.sql#15 parse +mariadb/06_etl_admin.sql#16 parse +mariadb/06_etl_admin.sql#2 parse +mariadb/06_etl_admin.sql#22 parse +mariadb/06_etl_admin.sql#24 parse +mariadb/06_etl_admin.sql#25 parse +mariadb/06_etl_admin.sql#35 parse +mariadb/06_etl_admin.sql#36 parse +mariadb/06_etl_admin.sql#37 parse +mariadb/06_etl_admin.sql#38 parse +mariadb/06_etl_admin.sql#39 parse +mariadb/06_etl_admin.sql#40 parse +mariadb/06_etl_admin.sql#5 parse +mariadb/06_etl_admin.sql#6 parse +mariadb/06_etl_admin.sql#7 drop +mariadb/06_etl_admin.sql#8 parse +mariadb/06_etl_admin.sql#9 parse +mariadb/07_stored_programs.sql#0 parse +mariadb/07_stored_programs.sql#1 parse +mariadb/07_stored_programs.sql#10 parse +mariadb/07_stored_programs.sql#11 parse +mariadb/07_stored_programs.sql#12 parse +mariadb/07_stored_programs.sql#13 parse +mariadb/07_stored_programs.sql#14 parse +mariadb/07_stored_programs.sql#15 parse +mariadb/07_stored_programs.sql#16 parse +mariadb/07_stored_programs.sql#17 parse +mariadb/07_stored_programs.sql#18 parse +mariadb/07_stored_programs.sql#2 parse +mariadb/07_stored_programs.sql#3 parse +mariadb/07_stored_programs.sql#4 parse +mariadb/07_stored_programs.sql#5 parse +mariadb/07_stored_programs.sql#6 parse +mariadb/07_stored_programs.sql#7 parse +mariadb/07_stored_programs.sql#8 parse +mariadb/07_stored_programs.sql#9 parse +mariadb/08_complex_queries.sql#12 parse +mariadb/08_complex_queries.sql#18 drop +mariadb/08_complex_queries.sql#21 parse +mariadb/08_complex_queries.sql#26 reparse +mariadb/08_complex_queries.sql#27 reparse +mariadb/08_complex_queries.sql#29 parse +mariadb/08_complex_queries.sql#8 drop +mariadb/09_mysql_compat_misc.sql#0 parse +mariadb/09_mysql_compat_misc.sql#10 parse +mariadb/09_mysql_compat_misc.sql#11 parse +mariadb/09_mysql_compat_misc.sql#12 parse +mariadb/09_mysql_compat_misc.sql#13 drop +mariadb/09_mysql_compat_misc.sql#14 parse +mariadb/09_mysql_compat_misc.sql#17 parse +mariadb/09_mysql_compat_misc.sql#25 parse +mariadb/09_mysql_compat_misc.sql#33 parse +mariadb/09_mysql_compat_misc.sql#36 parse +mariadb/09_mysql_compat_misc.sql#37 parse +mariadb/09_mysql_compat_misc.sql#39 parse +mariadb/09_mysql_compat_misc.sql#40 parse +mariadb/09_mysql_compat_misc.sql#41 parse +mariadb/09_mysql_compat_misc.sql#42 parse +mariadb/09_mysql_compat_misc.sql#46 parse +mariadb/09_mysql_compat_misc.sql#47 parse +mariadb/09_mysql_compat_misc.sql#49 parse +mariadb/09_mysql_compat_misc.sql#5 parse +mariadb/09_mysql_compat_misc.sql#50 parse +mariadb/09_mysql_compat_misc.sql#54 parse +mariadb/09_mysql_compat_misc.sql#55 parse +mysql/01_app_crud.sql#11 parse +mysql/01_app_crud.sql#17 drop +mysql/01_app_crud.sql#18 parse +mysql/01_app_crud.sql#19 parse +mysql/01_app_crud.sql#21 parse +mysql/01_app_crud.sql#22 parse +mysql/01_app_crud.sql#23 parse +mysql/01_app_crud.sql#30 parse +mysql/01_app_crud.sql#6 parse +mysql/02_reporting.sql#1 drop +mysql/02_reporting.sql#22 drop +mysql/02_reporting.sql#23 drop +mysql/02_reporting.sql#24 drop +mysql/02_reporting.sql#28 parse +mysql/02_reporting.sql#30 parse +mysql/02_reporting.sql#31 parse +mysql/02_reporting.sql#32 parse +mysql/02_reporting.sql#34 parse +mysql/02_reporting.sql#5 drop +mysql/03_ddl_migrations.sql#0 parse +mysql/03_ddl_migrations.sql#1 parse +mysql/03_ddl_migrations.sql#10 parse +mysql/03_ddl_migrations.sql#11 parse +mysql/03_ddl_migrations.sql#12 parse +mysql/03_ddl_migrations.sql#14 parse +mysql/03_ddl_migrations.sql#15 parse +mysql/03_ddl_migrations.sql#16 parse +mysql/03_ddl_migrations.sql#17 parse +mysql/03_ddl_migrations.sql#18 parse +mysql/03_ddl_migrations.sql#20 parse +mysql/03_ddl_migrations.sql#21 parse +mysql/03_ddl_migrations.sql#22 parse +mysql/03_ddl_migrations.sql#23 parse +mysql/03_ddl_migrations.sql#25 parse +mysql/03_ddl_migrations.sql#26 parse +mysql/03_ddl_migrations.sql#27 parse +mysql/03_ddl_migrations.sql#3 parse +mysql/03_ddl_migrations.sql#30 parse +mysql/03_ddl_migrations.sql#32 parse +mysql/03_ddl_migrations.sql#33 parse +mysql/03_ddl_migrations.sql#37 parse +mysql/03_ddl_migrations.sql#38 parse +mysql/03_ddl_migrations.sql#4 parse +mysql/03_ddl_migrations.sql#42 parse +mysql/03_ddl_migrations.sql#43 parse +mysql/03_ddl_migrations.sql#6 parse +mysql/03_ddl_migrations.sql#7 reparse +mysql/03_ddl_migrations.sql#9 parse +mysql/04_window_analytics.sql#14 parse +mysql/04_window_analytics.sql#20 parse +mysql/04_window_analytics.sql#21 parse +mysql/04_window_analytics.sql#7 drop +mysql/04_window_analytics.sql#8 drop +mysql/05_dialect_specific.sql#10 reparse +mysql/05_dialect_specific.sql#11 reparse +mysql/05_dialect_specific.sql#12 parse +mysql/05_dialect_specific.sql#13 parse +mysql/05_dialect_specific.sql#14 parse +mysql/05_dialect_specific.sql#15 parse +mysql/05_dialect_specific.sql#18 parse +mysql/05_dialect_specific.sql#19 parse +mysql/05_dialect_specific.sql#20 parse +mysql/05_dialect_specific.sql#21 parse +mysql/05_dialect_specific.sql#22 parse +mysql/05_dialect_specific.sql#23 parse +mysql/05_dialect_specific.sql#24 parse +mysql/05_dialect_specific.sql#25 parse +mysql/05_dialect_specific.sql#26 parse +mysql/05_dialect_specific.sql#27 parse +mysql/05_dialect_specific.sql#28 parse +mysql/05_dialect_specific.sql#30 parse +mysql/05_dialect_specific.sql#35 parse +mysql/05_dialect_specific.sql#36 parse +mysql/05_dialect_specific.sql#38 parse +mysql/05_dialect_specific.sql#39 parse +mysql/05_dialect_specific.sql#41 parse +mysql/05_dialect_specific.sql#43 drop +mysql/05_dialect_specific.sql#47 parse +mysql/05_dialect_specific.sql#48 parse +mysql/05_dialect_specific.sql#49 parse +mysql/05_dialect_specific.sql#50 parse +mysql/05_dialect_specific.sql#51 parse +mysql/05_dialect_specific.sql#52 parse +mysql/05_dialect_specific.sql#53 parse +mysql/05_dialect_specific.sql#54 parse +mysql/05_dialect_specific.sql#55 parse +mysql/05_dialect_specific.sql#56 parse +mysql/05_dialect_specific.sql#57 parse +mysql/05_dialect_specific.sql#58 parse +mysql/05_dialect_specific.sql#59 parse +mysql/05_dialect_specific.sql#60 parse +mysql/05_dialect_specific.sql#61 parse +mysql/05_dialect_specific.sql#62 parse +mysql/05_dialect_specific.sql#63 parse +mysql/05_dialect_specific.sql#64 parse +mysql/05_dialect_specific.sql#65 parse +mysql/05_dialect_specific.sql#67 parse +mysql/05_dialect_specific.sql#68 parse +mysql/05_dialect_specific.sql#69 parse +mysql/05_dialect_specific.sql#8 parse +mysql/05_dialect_specific.sql#9 reparse +mysql/06_wordpress.sql#12 parse +mysql/06_wordpress.sql#13 parse +mysql/06_wordpress.sql#19 parse +mysql/06_wordpress.sql#20 parse +mysql/06_wordpress.sql#3 parse +mysql/07_laravel_magento.sql#10 parse +mysql/07_laravel_magento.sql#11 parse +mysql/07_laravel_magento.sql#15 parse +mysql/07_laravel_magento.sql#7 parse +mysql/07_laravel_magento.sql#8 parse +mysql/08_etl_admin.sql#10 parse +mysql/08_etl_admin.sql#11 parse +mysql/08_etl_admin.sql#12 parse +mysql/08_etl_admin.sql#19 parse +mysql/08_etl_admin.sql#2 parse +mysql/08_etl_admin.sql#20 parse +mysql/08_etl_admin.sql#21 parse +mysql/08_etl_admin.sql#22 parse +mysql/08_etl_admin.sql#23 parse +mysql/08_etl_admin.sql#24 parse +mysql/08_etl_admin.sql#25 parse +mysql/08_etl_admin.sql#26 parse +mysql/08_etl_admin.sql#27 parse +mysql/08_etl_admin.sql#28 parse +mysql/08_etl_admin.sql#29 parse +mysql/08_etl_admin.sql#3 parse +mysql/08_etl_admin.sql#30 parse +mysql/08_etl_admin.sql#31 parse +mysql/08_etl_admin.sql#34 parse +mysql/08_etl_admin.sql#35 parse +mysql/08_etl_admin.sql#36 parse +mysql/08_etl_admin.sql#4 parse +mysql/08_etl_admin.sql#6 parse +mysql/08_etl_admin.sql#7 parse +mysql/08_etl_admin.sql#8 parse +mysql/08_etl_admin.sql#9 parse +mysql/09_deep_nesting.sql#1 parse +mysql/09_deep_nesting.sql#11 parse +mysql/09_deep_nesting.sql#22 drop +mysql/09_deep_nesting.sql#25 drop +mysql/09_deep_nesting.sql#26 parse +mysql/09_deep_nesting.sql#27 parse +mysql/09_deep_nesting.sql#30 parse +mysql/09_deep_nesting.sql#31 parse +mysql/09_deep_nesting.sql#5 parse +mysql/10_misc_functions.sql#1 parse +mysql/10_misc_functions.sql#17 reparse +mysql/10_misc_functions.sql#18 drop +mysql/10_misc_functions.sql#19 parse +mysql/10_misc_functions.sql#2 parse +mysql/10_misc_functions.sql#21 parse +mysql/10_misc_functions.sql#22 parse +mysql/10_misc_functions.sql#23 parse +mysql/10_misc_functions.sql#25 parse +mysql/10_misc_functions.sql#27 parse +mysql/10_misc_functions.sql#3 parse +mysql/10_misc_functions.sql#8 drop +mysql/11_stored_programs.sql#10 parse +mysql/11_stored_programs.sql#11 parse +mysql/11_stored_programs.sql#12 parse +mysql/11_stored_programs.sql#13 parse +mysql/11_stored_programs.sql#14 parse +mysql/11_stored_programs.sql#15 parse +mysql/11_stored_programs.sql#16 parse +mysql/11_stored_programs.sql#17 parse +mysql/11_stored_programs.sql#18 parse +mysql/11_stored_programs.sql#20 parse +mysql/11_stored_programs.sql#21 parse +mysql/11_stored_programs.sql#23 parse +mysql/11_stored_programs.sql#24 parse +mysql/11_stored_programs.sql#25 parse +mysql/11_stored_programs.sql#26 parse +mysql/11_stored_programs.sql#27 parse +mysql/11_stored_programs.sql#28 parse +mysql/11_stored_programs.sql#29 parse +mysql/11_stored_programs.sql#3 parse +mysql/11_stored_programs.sql#30 parse +mysql/11_stored_programs.sql#31 parse +mysql/11_stored_programs.sql#32 parse +mysql/11_stored_programs.sql#33 parse +mysql/11_stored_programs.sql#34 parse +mysql/11_stored_programs.sql#35 parse +mysql/11_stored_programs.sql#5 parse +mysql/11_stored_programs.sql#6 parse +mysql/11_stored_programs.sql#7 parse +mysql/11_stored_programs.sql#8 parse +mysql/12_orm_misc.sql#12 parse +mysql/12_orm_misc.sql#16 parse +mysql/12_orm_misc.sql#17 parse +mysql/12_orm_misc.sql#19 parse +mysql/12_orm_misc.sql#20 parse +mysql/12_orm_misc.sql#22 parse +mysql/12_orm_misc.sql#23 parse +mysql/12_orm_misc.sql#24 parse +mysql/12_orm_misc.sql#4 parse +mysql/12_orm_misc.sql#6 parse +oracle/01_app_crud.sql#10 parse +oracle/01_app_crud.sql#24 parse +oracle/01_app_crud.sql#25 parse +oracle/01_app_crud.sql#30 parse +oracle/01_app_crud.sql#31 idempotent +oracle/01_app_crud.sql#33 parse +oracle/01_app_crud.sql#34 parse +oracle/02_reporting.sql#17 drop +oracle/02_reporting.sql#20 parse +oracle/02_reporting.sql#21 parse +oracle/02_reporting.sql#22 parse +oracle/02_reporting.sql#26 parse +oracle/02_reporting.sql#28 parse +oracle/02_reporting.sql#3 parse +oracle/02_reporting.sql#36 parse +oracle/03_ddl_migrations.sql#10 parse +oracle/03_ddl_migrations.sql#11 reparse +oracle/03_ddl_migrations.sql#12 reparse +oracle/03_ddl_migrations.sql#13 parse +oracle/03_ddl_migrations.sql#17 parse +oracle/03_ddl_migrations.sql#18 parse +oracle/03_ddl_migrations.sql#19 parse +oracle/03_ddl_migrations.sql#20 parse +oracle/03_ddl_migrations.sql#21 parse +oracle/03_ddl_migrations.sql#22 parse +oracle/03_ddl_migrations.sql#24 parse +oracle/03_ddl_migrations.sql#25 parse +oracle/03_ddl_migrations.sql#26 parse +oracle/03_ddl_migrations.sql#27 parse +oracle/03_ddl_migrations.sql#28 parse +oracle/03_ddl_migrations.sql#29 parse +oracle/03_ddl_migrations.sql#3 parse +oracle/03_ddl_migrations.sql#30 parse +oracle/03_ddl_migrations.sql#31 parse +oracle/03_ddl_migrations.sql#37 parse +oracle/03_ddl_migrations.sql#38 parse +oracle/03_ddl_migrations.sql#39 parse +oracle/03_ddl_migrations.sql#4 parse +oracle/03_ddl_migrations.sql#40 parse +oracle/03_ddl_migrations.sql#41 parse +oracle/03_ddl_migrations.sql#42 parse +oracle/03_ddl_migrations.sql#43 parse +oracle/03_ddl_migrations.sql#44 parse +oracle/03_ddl_migrations.sql#45 parse +oracle/03_ddl_migrations.sql#47 parse +oracle/03_ddl_migrations.sql#5 parse +oracle/03_ddl_migrations.sql#50 parse +oracle/03_ddl_migrations.sql#53 parse +oracle/03_ddl_migrations.sql#54 parse +oracle/03_ddl_migrations.sql#55 parse +oracle/03_ddl_migrations.sql#56 parse +oracle/03_ddl_migrations.sql#57 parse +oracle/03_ddl_migrations.sql#58 parse +oracle/03_ddl_migrations.sql#59 parse +oracle/03_ddl_migrations.sql#6 parse +oracle/03_ddl_migrations.sql#60 parse +oracle/03_ddl_migrations.sql#7 drop +oracle/03_ddl_migrations.sql#8 parse +oracle/03_ddl_migrations.sql#9 parse +oracle/04_window_analytics.sql#10 parse +oracle/04_window_analytics.sql#11 parse +oracle/04_window_analytics.sql#19 parse +oracle/04_window_analytics.sql#22 parse +oracle/04_window_analytics.sql#23 parse +oracle/04_window_analytics.sql#24 parse +oracle/04_window_analytics.sql#8 parse +oracle/04_window_analytics.sql#9 parse +oracle/05_dialect_specific.sql#0 drop +oracle/05_dialect_specific.sql#1 parse +oracle/05_dialect_specific.sql#16 parse +oracle/05_dialect_specific.sql#17 parse +oracle/05_dialect_specific.sql#18 parse +oracle/05_dialect_specific.sql#19 parse +oracle/05_dialect_specific.sql#2 drop +oracle/05_dialect_specific.sql#20 parse +oracle/05_dialect_specific.sql#21 parse +oracle/05_dialect_specific.sql#29 parse +oracle/05_dialect_specific.sql#3 parse +oracle/05_dialect_specific.sql#32 parse +oracle/05_dialect_specific.sql#4 parse +oracle/05_dialect_specific.sql#5 drop +oracle/05_dialect_specific.sql#6 drop +oracle/05_dialect_specific.sql#7 drop +oracle/05_dialect_specific.sql#8 drop +oracle/05_dialect_specific.sql#9 parse +oracle/06_etl_merge.sql#1 parse +oracle/06_etl_merge.sql#10 parse +oracle/06_etl_merge.sql#12 parse +oracle/06_etl_merge.sql#15 parse +oracle/06_etl_merge.sql#16 parse +oracle/06_etl_merge.sql#17 parse +oracle/06_etl_merge.sql#19 parse +oracle/06_etl_merge.sql#2 parse +oracle/06_etl_merge.sql#20 parse +oracle/06_etl_merge.sql#21 parse +oracle/06_etl_merge.sql#22 parse +oracle/06_etl_merge.sql#23 parse +oracle/06_etl_merge.sql#24 reparse +oracle/06_etl_merge.sql#25 parse +oracle/06_etl_merge.sql#3 parse +oracle/06_etl_merge.sql#31 parse +oracle/06_etl_merge.sql#32 parse +oracle/06_etl_merge.sql#33 parse +oracle/06_etl_merge.sql#36 parse +oracle/06_etl_merge.sql#4 parse +oracle/06_etl_merge.sql#6 parse +oracle/06_etl_merge.sql#7 parse +oracle/06_etl_merge.sql#8 parse +oracle/07_admin_ops.sql#0 parse +oracle/07_admin_ops.sql#1 parse +oracle/07_admin_ops.sql#10 parse +oracle/07_admin_ops.sql#11 parse +oracle/07_admin_ops.sql#12 parse +oracle/07_admin_ops.sql#13 parse +oracle/07_admin_ops.sql#14 parse +oracle/07_admin_ops.sql#15 parse +oracle/07_admin_ops.sql#16 parse +oracle/07_admin_ops.sql#17 parse +oracle/07_admin_ops.sql#18 parse +oracle/07_admin_ops.sql#19 parse +oracle/07_admin_ops.sql#2 parse +oracle/07_admin_ops.sql#20 parse +oracle/07_admin_ops.sql#21 parse +oracle/07_admin_ops.sql#22 parse +oracle/07_admin_ops.sql#23 parse +oracle/07_admin_ops.sql#24 parse +oracle/07_admin_ops.sql#25 parse +oracle/07_admin_ops.sql#26 parse +oracle/07_admin_ops.sql#27 parse +oracle/07_admin_ops.sql#28 parse +oracle/07_admin_ops.sql#29 parse +oracle/07_admin_ops.sql#3 parse +oracle/07_admin_ops.sql#30 parse +oracle/07_admin_ops.sql#31 parse +oracle/07_admin_ops.sql#34 parse +oracle/07_admin_ops.sql#35 parse +oracle/07_admin_ops.sql#36 parse +oracle/07_admin_ops.sql#37 parse +oracle/07_admin_ops.sql#4 parse +oracle/07_admin_ops.sql#40 parse +oracle/07_admin_ops.sql#44 parse +oracle/07_admin_ops.sql#45 parse +oracle/07_admin_ops.sql#5 parse +oracle/07_admin_ops.sql#50 parse +oracle/07_admin_ops.sql#6 parse +oracle/07_admin_ops.sql#7 parse +oracle/07_admin_ops.sql#8 parse +oracle/07_admin_ops.sql#9 parse +oracle/08_complex_nested.sql#0 parse +oracle/08_complex_nested.sql#15 parse +oracle/08_complex_nested.sql#17 parse +oracle/08_complex_nested.sql#19 parse +postgresql/01_app_crud.sql#1 parse +postgresql/01_app_crud.sql#11 parse +postgresql/01_app_crud.sql#13 parse +postgresql/01_app_crud.sql#20 parse +postgresql/01_app_crud.sql#8 parse +postgresql/02_reporting.sql#27 drop +postgresql/02_reporting.sql#4 parse +postgresql/02_reporting.sql#7 parse +postgresql/02_reporting.sql#8 parse +postgresql/03_ddl_migrations.sql#1 parse +postgresql/03_ddl_migrations.sql#10 parse +postgresql/03_ddl_migrations.sql#12 parse +postgresql/03_ddl_migrations.sql#14 parse +postgresql/03_ddl_migrations.sql#16 parse +postgresql/03_ddl_migrations.sql#17 parse +postgresql/03_ddl_migrations.sql#19 parse +postgresql/03_ddl_migrations.sql#21 parse +postgresql/03_ddl_migrations.sql#24 parse +postgresql/03_ddl_migrations.sql#25 drop +postgresql/03_ddl_migrations.sql#26 parse +postgresql/03_ddl_migrations.sql#28 parse +postgresql/03_ddl_migrations.sql#29 parse +postgresql/03_ddl_migrations.sql#3 parse +postgresql/03_ddl_migrations.sql#32 parse +postgresql/03_ddl_migrations.sql#34 parse +postgresql/03_ddl_migrations.sql#37 parse +postgresql/03_ddl_migrations.sql#38 parse +postgresql/03_ddl_migrations.sql#39 parse +postgresql/03_ddl_migrations.sql#4 parse +postgresql/03_ddl_migrations.sql#42 parse +postgresql/03_ddl_migrations.sql#43 parse +postgresql/03_ddl_migrations.sql#44 parse +postgresql/03_ddl_migrations.sql#45 parse +postgresql/03_ddl_migrations.sql#46 parse +postgresql/03_ddl_migrations.sql#47 parse +postgresql/03_ddl_migrations.sql#48 parse +postgresql/03_ddl_migrations.sql#49 parse +postgresql/03_ddl_migrations.sql#5 parse +postgresql/03_ddl_migrations.sql#50 parse +postgresql/03_ddl_migrations.sql#59 parse +postgresql/03_ddl_migrations.sql#6 parse +postgresql/03_ddl_migrations.sql#60 parse +postgresql/03_ddl_migrations.sql#61 parse +postgresql/03_ddl_migrations.sql#62 parse +postgresql/03_ddl_migrations.sql#63 parse +postgresql/03_ddl_migrations.sql#64 parse +postgresql/03_ddl_migrations.sql#65 parse +postgresql/03_ddl_migrations.sql#7 reparse +postgresql/03_ddl_migrations.sql#9 parse +postgresql/04_window_analytics.sql#21 parse +postgresql/05_dialect_specific.sql#12 parse +postgresql/05_dialect_specific.sql#14 parse +postgresql/05_dialect_specific.sql#15 parse +postgresql/05_dialect_specific.sql#17 parse +postgresql/05_dialect_specific.sql#27 parse +postgresql/05_dialect_specific.sql#28 parse +postgresql/05_dialect_specific.sql#29 parse +postgresql/05_dialect_specific.sql#30 parse +postgresql/05_dialect_specific.sql#31 parse +postgresql/05_dialect_specific.sql#32 reparse +postgresql/05_dialect_specific.sql#33 parse +postgresql/05_dialect_specific.sql#35 parse +postgresql/05_dialect_specific.sql#36 parse +postgresql/05_dialect_specific.sql#38 parse +postgresql/05_dialect_specific.sql#39 parse +postgresql/05_dialect_specific.sql#40 parse +postgresql/05_dialect_specific.sql#41 parse +postgresql/05_dialect_specific.sql#42 parse +postgresql/05_dialect_specific.sql#43 parse +postgresql/05_dialect_specific.sql#46 parse +postgresql/05_dialect_specific.sql#47 parse +postgresql/05_dialect_specific.sql#49 parse +postgresql/05_dialect_specific.sql#51 parse +postgresql/05_dialect_specific.sql#52 parse +postgresql/05_dialect_specific.sql#53 parse +postgresql/05_dialect_specific.sql#54 parse +postgresql/05_dialect_specific.sql#55 parse +postgresql/05_dialect_specific.sql#56 parse +postgresql/06_cte_dml_etl.sql#11 parse +postgresql/06_cte_dml_etl.sql#12 parse +postgresql/06_cte_dml_etl.sql#14 parse +postgresql/06_cte_dml_etl.sql#15 parse +postgresql/06_cte_dml_etl.sql#16 parse +postgresql/06_cte_dml_etl.sql#18 parse +postgresql/06_cte_dml_etl.sql#22 parse +postgresql/06_cte_dml_etl.sql#3 drop +postgresql/06_cte_dml_etl.sql#5 parse +postgresql/06_cte_dml_etl.sql#6 parse +postgresql/06_cte_dml_etl.sql#7 parse +postgresql/06_cte_dml_etl.sql#9 parse +postgresql/07_admin_ops.sql#0 parse +postgresql/07_admin_ops.sql#1 parse +postgresql/07_admin_ops.sql#10 parse +postgresql/07_admin_ops.sql#11 parse +postgresql/07_admin_ops.sql#12 parse +postgresql/07_admin_ops.sql#17 parse +postgresql/07_admin_ops.sql#18 parse +postgresql/07_admin_ops.sql#19 parse +postgresql/07_admin_ops.sql#2 parse +postgresql/07_admin_ops.sql#20 parse +postgresql/07_admin_ops.sql#3 parse +postgresql/07_admin_ops.sql#33 parse +postgresql/07_admin_ops.sql#34 parse +postgresql/07_admin_ops.sql#35 parse +postgresql/07_admin_ops.sql#36 parse +postgresql/07_admin_ops.sql#37 parse +postgresql/07_admin_ops.sql#38 parse +postgresql/07_admin_ops.sql#39 parse +postgresql/07_admin_ops.sql#4 parse +postgresql/07_admin_ops.sql#40 parse +postgresql/07_admin_ops.sql#41 parse +postgresql/07_admin_ops.sql#42 parse +postgresql/07_admin_ops.sql#43 parse +postgresql/07_admin_ops.sql#44 parse +postgresql/07_admin_ops.sql#5 parse +postgresql/07_admin_ops.sql#6 parse +postgresql/07_admin_ops.sql#7 parse +postgresql/07_admin_ops.sql#8 parse +postgresql/07_admin_ops.sql#9 parse +postgresql/08_supabase_rls_misc.sql#0 parse +postgresql/08_supabase_rls_misc.sql#10 parse +postgresql/08_supabase_rls_misc.sql#16 parse +postgresql/08_supabase_rls_misc.sql#17 parse +postgresql/08_supabase_rls_misc.sql#26 parse +postgresql/08_supabase_rls_misc.sql#28 parse +postgresql/08_supabase_rls_misc.sql#3 parse +postgresql/08_supabase_rls_misc.sql#4 parse +postgresql/08_supabase_rls_misc.sql#5 parse +postgresql/08_supabase_rls_misc.sql#6 parse +snowflake/01_app_crud.sql#10 parse +snowflake/01_app_crud.sql#18 parse +snowflake/01_app_crud.sql#19 parse +snowflake/02_reporting.sql#21 drop +snowflake/02_reporting.sql#4 parse +snowflake/03_ddl_migrations.sql#1 parse +snowflake/03_ddl_migrations.sql#10 parse +snowflake/03_ddl_migrations.sql#12 parse +snowflake/03_ddl_migrations.sql#16 parse +snowflake/03_ddl_migrations.sql#2 drop +snowflake/03_ddl_migrations.sql#20 parse +snowflake/03_ddl_migrations.sql#24 parse +snowflake/03_ddl_migrations.sql#25 parse +snowflake/03_ddl_migrations.sql#26 parse +snowflake/03_ddl_migrations.sql#3 parse +snowflake/03_ddl_migrations.sql#30 parse +snowflake/03_ddl_migrations.sql#31 reparse +snowflake/03_ddl_migrations.sql#32 reparse +snowflake/03_ddl_migrations.sql#33 reparse +snowflake/03_ddl_migrations.sql#34 reparse +snowflake/03_ddl_migrations.sql#35 reparse +snowflake/03_ddl_migrations.sql#36 parse +snowflake/03_ddl_migrations.sql#5 parse +snowflake/03_ddl_migrations.sql#6 parse +snowflake/03_ddl_migrations.sql#7 parse +snowflake/03_ddl_migrations.sql#8 reparse +snowflake/03_ddl_migrations.sql#9 reparse +snowflake/04_window_analytics.sql#1 drop +snowflake/04_window_analytics.sql#10 parse +snowflake/04_window_analytics.sql#14 drop +snowflake/04_window_analytics.sql#2 drop +snowflake/04_window_analytics.sql#3 drop +snowflake/04_window_analytics.sql#4 drop +snowflake/05_dialect_specific.sql#14 idempotent +snowflake/05_dialect_specific.sql#15 idempotent +snowflake/05_dialect_specific.sql#16 drop +snowflake/05_dialect_specific.sql#17 drop +snowflake/05_dialect_specific.sql#18 drop +snowflake/05_dialect_specific.sql#19 drop +snowflake/05_dialect_specific.sql#20 drop +snowflake/05_dialect_specific.sql#21 drop +snowflake/05_dialect_specific.sql#22 parse +snowflake/05_dialect_specific.sql#23 drop +snowflake/05_dialect_specific.sql#24 parse +snowflake/05_dialect_specific.sql#25 parse +snowflake/05_dialect_specific.sql#27 parse +snowflake/05_dialect_specific.sql#28 parse +snowflake/05_dialect_specific.sql#40 parse +snowflake/05_dialect_specific.sql#42 idempotent +snowflake/05_dialect_specific.sql#43 parse +snowflake/05_dialect_specific.sql#45 parse +snowflake/05_dialect_specific.sql#46 parse +snowflake/05_dialect_specific.sql#47 parse +snowflake/05_dialect_specific.sql#48 parse +snowflake/05_dialect_specific.sql#49 parse +snowflake/05_dialect_specific.sql#5 parse +snowflake/05_dialect_specific.sql#50 parse +snowflake/05_dialect_specific.sql#8 parse +snowflake/06_etl_merge.sql#10 parse +snowflake/06_etl_merge.sql#13 reparse +snowflake/06_etl_merge.sql#14 reparse +snowflake/06_etl_merge.sql#15 parse +snowflake/06_etl_merge.sql#16 reparse +snowflake/06_etl_merge.sql#17 reparse +snowflake/06_etl_merge.sql#18 parse +snowflake/06_etl_merge.sql#19 parse +snowflake/06_etl_merge.sql#2 parse +snowflake/06_etl_merge.sql#20 reparse +snowflake/06_etl_merge.sql#21 parse +snowflake/06_etl_merge.sql#22 reparse +snowflake/06_etl_merge.sql#23 parse +snowflake/06_etl_merge.sql#25 reparse +snowflake/06_etl_merge.sql#26 reparse +snowflake/06_etl_merge.sql#27 reparse +snowflake/06_etl_merge.sql#28 reparse +snowflake/06_etl_merge.sql#29 parse +snowflake/06_etl_merge.sql#30 parse +snowflake/06_etl_merge.sql#31 parse +snowflake/06_etl_merge.sql#32 parse +snowflake/06_etl_merge.sql#34 parse +snowflake/06_etl_merge.sql#35 parse +snowflake/06_etl_merge.sql#4 reparse +snowflake/06_etl_merge.sql#5 reparse +snowflake/06_etl_merge.sql#6 reparse +snowflake/06_etl_merge.sql#7 reparse +snowflake/07_admin.sql#0 reparse +snowflake/07_admin.sql#1 reparse +snowflake/07_admin.sql#10 parse +snowflake/07_admin.sql#11 parse +snowflake/07_admin.sql#14 parse +snowflake/07_admin.sql#18 drop +snowflake/07_admin.sql#19 drop +snowflake/07_admin.sql#2 reparse +snowflake/07_admin.sql#20 drop +snowflake/07_admin.sql#21 reparse +snowflake/07_admin.sql#22 parse +snowflake/07_admin.sql#23 parse +snowflake/07_admin.sql#24 reparse +snowflake/07_admin.sql#25 parse +snowflake/07_admin.sql#26 parse +snowflake/07_admin.sql#27 parse +snowflake/07_admin.sql#28 parse +snowflake/07_admin.sql#29 parse +snowflake/07_admin.sql#3 reparse +snowflake/07_admin.sql#30 parse +snowflake/07_admin.sql#31 parse +snowflake/07_admin.sql#32 parse +snowflake/07_admin.sql#33 parse +snowflake/07_admin.sql#34 parse +snowflake/07_admin.sql#35 parse +snowflake/07_admin.sql#36 parse +snowflake/07_admin.sql#37 parse +snowflake/07_admin.sql#4 reparse +snowflake/07_admin.sql#43 parse +snowflake/07_admin.sql#44 parse +snowflake/07_admin.sql#46 parse +snowflake/07_admin.sql#47 parse +snowflake/07_admin.sql#48 parse +snowflake/07_admin.sql#49 parse +snowflake/07_admin.sql#50 parse +snowflake/07_admin.sql#51 parse +snowflake/07_admin.sql#6 parse +snowflake/07_admin.sql#7 parse +snowflake/07_admin.sql#8 parse +snowflake/07_admin.sql#9 parse +snowflake/08_pivot_match.sql#1 parse +snowflake/08_pivot_match.sql#13 parse +snowflake/08_pivot_match.sql#14 parse +snowflake/08_pivot_match.sql#2 parse +snowflake/08_pivot_match.sql#5 parse +snowflake/08_pivot_match.sql#6 drop +snowflake/08_pivot_match.sql#7 drop +snowflake/09_dbt_models.sql#0 parse +snowflake/09_dbt_models.sql#1 parse +snowflake/09_dbt_models.sql#12 parse +snowflake/09_dbt_models.sql#13 drop +snowflake/09_dbt_models.sql#15 parse +snowflake/09_dbt_models.sql#17 parse +snowflake/09_dbt_models.sql#2 parse +snowflake/10_complex_nested.sql#12 parse +snowflake/10_complex_nested.sql#19 drop +snowflake/10_complex_nested.sql#21 drop +snowflake/10_complex_nested.sql#22 parse +snowflake/10_complex_nested.sql#26 parse +snowflake/10_complex_nested.sql#34 parse +snowflake/10_complex_nested.sql#39 parse +sqlite/01_app_crud.sql#0 drop +sqlite/01_app_crud.sql#4 parse +sqlite/01_app_crud.sql#7 parse +sqlite/01_app_crud.sql#8 parse +sqlite/02_upsert_replace.sql#0 drop +sqlite/02_upsert_replace.sql#13 parse +sqlite/02_upsert_replace.sql#16 parse +sqlite/02_upsert_replace.sql#17 parse +sqlite/02_upsert_replace.sql#19 parse +sqlite/02_upsert_replace.sql#2 parse +sqlite/02_upsert_replace.sql#3 parse +sqlite/02_upsert_replace.sql#4 parse +sqlite/02_upsert_replace.sql#5 parse +sqlite/02_upsert_replace.sql#6 parse +sqlite/02_upsert_replace.sql#7 parse +sqlite/02_upsert_replace.sql#8 parse +sqlite/03_ddl_migrations.sql#1 drop +sqlite/03_ddl_migrations.sql#11 parse +sqlite/03_ddl_migrations.sql#13 parse +sqlite/03_ddl_migrations.sql#15 drop +sqlite/03_ddl_migrations.sql#23 parse +sqlite/03_ddl_migrations.sql#24 parse +sqlite/03_ddl_migrations.sql#25 parse +sqlite/03_ddl_migrations.sql#26 parse +sqlite/03_ddl_migrations.sql#27 parse +sqlite/03_ddl_migrations.sql#28 parse +sqlite/03_ddl_migrations.sql#29 parse +sqlite/03_ddl_migrations.sql#3 parse +sqlite/03_ddl_migrations.sql#30 parse +sqlite/03_ddl_migrations.sql#31 parse +sqlite/03_ddl_migrations.sql#32 parse +sqlite/03_ddl_migrations.sql#33 parse +sqlite/03_ddl_migrations.sql#34 parse +sqlite/03_ddl_migrations.sql#35 parse +sqlite/03_ddl_migrations.sql#36 parse +sqlite/03_ddl_migrations.sql#37 parse +sqlite/03_ddl_migrations.sql#38 parse +sqlite/03_ddl_migrations.sql#4 parse +sqlite/03_ddl_migrations.sql#5 parse +sqlite/03_ddl_migrations.sql#6 parse +sqlite/03_ddl_migrations.sql#7 drop +sqlite/03_ddl_migrations.sql#9 reparse +sqlite/05_dialect_specific.sql#0 parse +sqlite/05_dialect_specific.sql#1 parse +sqlite/05_dialect_specific.sql#10 parse +sqlite/05_dialect_specific.sql#11 parse +sqlite/05_dialect_specific.sql#12 parse +sqlite/05_dialect_specific.sql#14 parse +sqlite/05_dialect_specific.sql#15 parse +sqlite/05_dialect_specific.sql#16 parse +sqlite/05_dialect_specific.sql#17 parse +sqlite/05_dialect_specific.sql#18 parse +sqlite/05_dialect_specific.sql#20 parse +sqlite/05_dialect_specific.sql#23 parse +sqlite/05_dialect_specific.sql#26 parse +sqlite/05_dialect_specific.sql#27 parse +sqlite/05_dialect_specific.sql#29 drop +sqlite/05_dialect_specific.sql#30 parse +sqlite/05_dialect_specific.sql#31 parse +sqlite/05_dialect_specific.sql#42 parse +sqlite/05_dialect_specific.sql#43 parse +sqlite/05_dialect_specific.sql#44 parse +sqlite/05_dialect_specific.sql#59 parse +sqlite/05_dialect_specific.sql#61 parse +sqlite/05_dialect_specific.sql#65 parse +sqlite/05_dialect_specific.sql#9 parse +sqlite/06_json.sql#16 parse +sqlite/06_json.sql#17 parse +sqlite/06_json.sql#18 parse +sqlite/06_json.sql#21 parse +sqlite/06_json.sql#22 parse +sqlite/07_ctes_recursive.sql#10 parse +sqlite/07_ctes_recursive.sql#11 parse +sqlite/07_ctes_recursive.sql#2 parse +sqlite/07_ctes_recursive.sql#7 drop +sqlite/07_ctes_recursive.sql#8 drop +sqlite/08_reporting.sql#13 parse +sqlite/08_reporting.sql#15 parse +sqlite/08_reporting.sql#16 parse +sqlite/09_etl_admin.sql#1 drop +sqlite/09_etl_admin.sql#10 parse +sqlite/09_etl_admin.sql#17 parse +sqlite/09_etl_admin.sql#18 parse +sqlite/09_etl_admin.sql#19 parse +sqlite/09_etl_admin.sql#2 parse +sqlite/09_etl_admin.sql#28 parse +sqlite/09_etl_admin.sql#29 parse +sqlite/09_etl_admin.sql#30 parse +sqlite/09_etl_admin.sql#33 parse +sqlite/09_etl_admin.sql#34 parse +sqlite/09_etl_admin.sql#35 parse +sqlite/09_etl_admin.sql#36 parse +sqlite/09_etl_admin.sql#37 parse +sqlite/09_etl_admin.sql#38 parse +sqlite/09_etl_admin.sql#39 parse +sqlite/09_etl_admin.sql#40 parse +sqlite/09_etl_admin.sql#8 parse +sqlite/10_mobile_offline_sync.sql#0 drop +sqlite/10_mobile_offline_sync.sql#15 parse +sqlite/10_mobile_offline_sync.sql#16 parse +sqlite/10_mobile_offline_sync.sql#18 parse +sqlite/10_mobile_offline_sync.sql#20 drop +sqlite/10_mobile_offline_sync.sql#21 parse +sqlite/10_mobile_offline_sync.sql#4 parse +sqlite/10_mobile_offline_sync.sql#5 parse +sqlite/10_mobile_offline_sync.sql#6 parse +sqlite/10_mobile_offline_sync.sql#7 parse +sqlite/10_mobile_offline_sync.sql#8 parse +sqlite/11_deep_nesting.sql#14 parse +sqlite/11_deep_nesting.sql#19 parse +sqlite/11_deep_nesting.sql#30 parse +sqlite/11_deep_nesting.sql#31 parse +sqlite/11_deep_nesting.sql#7 parse +sqlite/12_orm_generated.sql#10 parse +sqlite/12_orm_generated.sql#11 drop +sqlite/12_orm_generated.sql#12 drop +sqlite/12_orm_generated.sql#14 parse +sqlite/12_orm_generated.sql#16 parse +sqlite/12_orm_generated.sql#8 parse +sqlite/12_orm_generated.sql#9 drop +sqlserver/01_app_crud.sql#1 parse +sqlserver/01_app_crud.sql#10 parse +sqlserver/01_app_crud.sql#11 parse +sqlserver/01_app_crud.sql#12 parse +sqlserver/01_app_crud.sql#13 parse +sqlserver/01_app_crud.sql#14 parse +sqlserver/01_app_crud.sql#16 parse +sqlserver/01_app_crud.sql#18 parse +sqlserver/01_app_crud.sql#2 parse +sqlserver/01_app_crud.sql#20 parse +sqlserver/01_app_crud.sql#3 parse +sqlserver/01_app_crud.sql#4 parse +sqlserver/01_app_crud.sql#5 parse +sqlserver/01_app_crud.sql#6 parse +sqlserver/01_app_crud.sql#7 parse +sqlserver/01_app_crud.sql#8 parse +sqlserver/01_app_crud.sql#9 parse +sqlserver/02_reporting.sql#10 parse +sqlserver/02_reporting.sql#17 parse +sqlserver/02_reporting.sql#18 parse +sqlserver/02_reporting.sql#19 parse +sqlserver/02_reporting.sql#20 parse +sqlserver/02_reporting.sql#21 drop +sqlserver/02_reporting.sql#23 parse +sqlserver/02_reporting.sql#24 parse +sqlserver/02_reporting.sql#7 parse +sqlserver/02_reporting.sql#8 drop +sqlserver/02_reporting.sql#9 parse +sqlserver/03_ddl_migrations.sql#0 parse +sqlserver/03_ddl_migrations.sql#1 parse +sqlserver/03_ddl_migrations.sql#10 parse +sqlserver/03_ddl_migrations.sql#11 parse +sqlserver/03_ddl_migrations.sql#12 parse +sqlserver/03_ddl_migrations.sql#13 parse +sqlserver/03_ddl_migrations.sql#15 parse +sqlserver/03_ddl_migrations.sql#16 parse +sqlserver/03_ddl_migrations.sql#18 parse +sqlserver/03_ddl_migrations.sql#2 parse +sqlserver/03_ddl_migrations.sql#23 parse +sqlserver/03_ddl_migrations.sql#25 parse +sqlserver/03_ddl_migrations.sql#26 parse +sqlserver/03_ddl_migrations.sql#27 parse +sqlserver/03_ddl_migrations.sql#29 parse +sqlserver/03_ddl_migrations.sql#30 parse +sqlserver/03_ddl_migrations.sql#31 parse +sqlserver/03_ddl_migrations.sql#4 parse +sqlserver/03_ddl_migrations.sql#5 parse +sqlserver/03_ddl_migrations.sql#6 parse +sqlserver/03_ddl_migrations.sql#7 parse +sqlserver/03_ddl_migrations.sql#8 parse +sqlserver/03_ddl_migrations.sql#9 parse +sqlserver/05_dialect_specific.sql#0 reparse +sqlserver/05_dialect_specific.sql#1 reparse +sqlserver/05_dialect_specific.sql#11 parse +sqlserver/05_dialect_specific.sql#12 parse +sqlserver/05_dialect_specific.sql#13 parse +sqlserver/05_dialect_specific.sql#14 parse +sqlserver/05_dialect_specific.sql#16 parse +sqlserver/05_dialect_specific.sql#17 parse +sqlserver/05_dialect_specific.sql#18 parse +sqlserver/05_dialect_specific.sql#19 parse +sqlserver/05_dialect_specific.sql#2 parse +sqlserver/05_dialect_specific.sql#26 parse +sqlserver/05_dialect_specific.sql#27 parse +sqlserver/05_dialect_specific.sql#28 parse +sqlserver/05_dialect_specific.sql#3 parse +sqlserver/05_dialect_specific.sql#33 parse +sqlserver/05_dialect_specific.sql#4 parse +sqlserver/05_dialect_specific.sql#5 parse +sqlserver/05_dialect_specific.sql#6 parse +sqlserver/05_dialect_specific.sql#7 parse +sqlserver/05_dialect_specific.sql#8 parse +sqlserver/05_dialect_specific.sql#9 parse +sqlserver/06_merge_etl.sql#0 parse +sqlserver/06_merge_etl.sql#1 parse +sqlserver/06_merge_etl.sql#10 parse +sqlserver/06_merge_etl.sql#11 parse +sqlserver/06_merge_etl.sql#12 parse +sqlserver/06_merge_etl.sql#13 parse +sqlserver/06_merge_etl.sql#14 parse +sqlserver/06_merge_etl.sql#16 parse +sqlserver/06_merge_etl.sql#18 parse +sqlserver/06_merge_etl.sql#2 parse +sqlserver/06_merge_etl.sql#3 drop +sqlserver/06_merge_etl.sql#4 parse +sqlserver/07_ctes_deep.sql#0 parse +sqlserver/07_ctes_deep.sql#1 parse +sqlserver/07_ctes_deep.sql#11 parse +sqlserver/07_ctes_deep.sql#14 parse +sqlserver/07_ctes_deep.sql#15 parse +sqlserver/07_ctes_deep.sql#16 parse +sqlserver/07_ctes_deep.sql#2 parse +sqlserver/08_procs_batches.sql#0 parse +sqlserver/08_procs_batches.sql#1 parse +sqlserver/08_procs_batches.sql#10 parse +sqlserver/08_procs_batches.sql#11 parse +sqlserver/08_procs_batches.sql#12 parse +sqlserver/08_procs_batches.sql#13 parse +sqlserver/08_procs_batches.sql#14 parse +sqlserver/08_procs_batches.sql#15 parse +sqlserver/08_procs_batches.sql#16 parse +sqlserver/08_procs_batches.sql#17 parse +sqlserver/08_procs_batches.sql#18 parse +sqlserver/08_procs_batches.sql#19 parse +sqlserver/08_procs_batches.sql#2 parse +sqlserver/08_procs_batches.sql#20 parse +sqlserver/08_procs_batches.sql#21 parse +sqlserver/08_procs_batches.sql#22 parse +sqlserver/08_procs_batches.sql#23 parse +sqlserver/08_procs_batches.sql#24 parse +sqlserver/08_procs_batches.sql#25 parse +sqlserver/08_procs_batches.sql#26 parse +sqlserver/08_procs_batches.sql#27 parse +sqlserver/08_procs_batches.sql#28 parse +sqlserver/08_procs_batches.sql#3 parse +sqlserver/08_procs_batches.sql#30 parse +sqlserver/08_procs_batches.sql#31 parse +sqlserver/08_procs_batches.sql#32 parse +sqlserver/08_procs_batches.sql#33 parse +sqlserver/08_procs_batches.sql#34 parse +sqlserver/08_procs_batches.sql#35 parse +sqlserver/08_procs_batches.sql#36 parse +sqlserver/08_procs_batches.sql#37 parse +sqlserver/08_procs_batches.sql#38 parse +sqlserver/08_procs_batches.sql#39 parse +sqlserver/08_procs_batches.sql#4 parse +sqlserver/08_procs_batches.sql#40 parse +sqlserver/08_procs_batches.sql#5 parse +sqlserver/08_procs_batches.sql#6 parse +sqlserver/08_procs_batches.sql#7 parse +sqlserver/08_procs_batches.sql#8 parse +sqlserver/08_procs_batches.sql#9 parse +sqlserver/09_admin_dmv.sql#1 parse +sqlserver/09_admin_dmv.sql#13 parse +sqlserver/09_admin_dmv.sql#15 parse +sqlserver/09_admin_dmv.sql#16 parse +sqlserver/09_admin_dmv.sql#17 parse +sqlserver/09_admin_dmv.sql#18 parse +sqlserver/09_admin_dmv.sql#19 parse +sqlserver/09_admin_dmv.sql#3 parse +sqlserver/09_admin_dmv.sql#4 parse +sqlserver/09_admin_dmv.sql#7 parse +sqlserver/09_admin_dmv.sql#9 parse +sqlserver/10_etl_mixed.sql#1 parse +sqlserver/10_etl_mixed.sql#10 parse +sqlserver/10_etl_mixed.sql#11 parse +sqlserver/10_etl_mixed.sql#12 parse +sqlserver/10_etl_mixed.sql#13 parse +sqlserver/10_etl_mixed.sql#14 parse +sqlserver/10_etl_mixed.sql#15 parse +sqlserver/10_etl_mixed.sql#18 drop +sqlserver/10_etl_mixed.sql#19 parse +sqlserver/10_etl_mixed.sql#2 parse +sqlserver/10_etl_mixed.sql#20 parse +sqlserver/10_etl_mixed.sql#21 parse +sqlserver/10_etl_mixed.sql#25 reparse +sqlserver/10_etl_mixed.sql#28 parse +sqlserver/10_etl_mixed.sql#8 parse diff --git a/testdata/realworld/mariadb/01_app_crud.sql b/testdata/realworld/mariadb/01_app_crud.sql new file mode 100644 index 00000000..e75c75a7 --- /dev/null +++ b/testdata/realworld/mariadb/01_app_crud.sql @@ -0,0 +1,36 @@ +-- ORM-generated CRUD for a web application (users, orders, products) +SELECT `id`, `email`, `created_at` FROM `users` WHERE `id` = 42 LIMIT 1; +SELECT u.id, u.email FROM users AS u WHERE u.email = 'a@example.com' AND u.deleted_at IS NULL; +SELECT * FROM products WHERE category_id IN (1, 2, 3) AND price BETWEEN 10 AND 100 ORDER BY price DESC LIMIT 20 OFFSET 40; +SELECT * FROM products WHERE name LIKE '%widget%' ESCAPE '\\' OR sku LIKE 'WD-%'; +INSERT INTO users (email, name, created_at) VALUES ('a@example.com', 'Alice', NOW()); +INSERT INTO users (email, name) VALUES ('b@example.com', 'Bob'), ('c@example.com', 'Carol'), ('d@example.com', 'Dave'); +INSERT INTO users SET email = 'e@example.com', name = 'Eve'; +INSERT IGNORE INTO tags (name) VALUES ('go'), ('sql'); +INSERT INTO page_views (page_id, views) VALUES (1, 1) ON DUPLICATE KEY UPDATE views = views + 1; +INSERT INTO users (email, name) SELECT email, name FROM staging_users WHERE imported = 0; +REPLACE INTO settings (k, v) VALUES ('theme', 'dark'); +UPDATE users SET name = 'Alice B', updated_at = NOW() WHERE id = 1; +UPDATE products SET stock = stock - 1 WHERE id = 7 AND stock > 0; +UPDATE orders o JOIN users u ON u.id = o.user_id SET o.status = 'archived' WHERE u.deleted_at IS NOT NULL; +UPDATE products SET price = price * 1.1 WHERE category_id = 3 ORDER BY id LIMIT 100; +DELETE FROM sessions WHERE expires_at < NOW(); +DELETE FROM carts WHERE user_id = 5 ORDER BY id LIMIT 10; +DELETE o FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL; +SELECT o.id, o.total, u.email FROM orders o INNER JOIN users u ON u.id = o.user_id WHERE o.status = 'paid'; +SELECT o.id, i.product_id, i.qty FROM orders o LEFT JOIN order_items i ON i.order_id = o.id WHERE o.user_id = 3; +SELECT COUNT(*) FROM orders WHERE created_at >= '2025-01-01'; +SELECT p.* FROM products p WHERE EXISTS (SELECT 1 FROM order_items i WHERE i.product_id = p.id); +SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM bans); +SELECT id FROM users WHERE id = ? AND status = ?; +SELECT * FROM users WHERE (status, role) IN (('active', 'admin'), ('active', 'editor')); +SELECT * FROM orders WHERE user_id = 9 ORDER BY created_at DESC LIMIT 10 FOR UPDATE; +SELECT * FROM inventory WHERE sku = 'X1' LOCK IN SHARE MODE; +START TRANSACTION; +COMMIT; +SELECT LAST_INSERT_ID(); +SELECT DATABASE(), VERSION(), CURRENT_USER(); +SELECT IFNULL(nickname, name) AS display, COALESCE(phone, mobile, 'n/a') AS contact FROM users; +SELECT CASE WHEN total > 1000 THEN 'big' WHEN total > 100 THEN 'mid' ELSE 'small' END AS bucket FROM orders; +SELECT CONCAT(first_name, ' ', last_name) AS full_name, UPPER(email) FROM users; +SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, COUNT(*) FROM orders GROUP BY ym; diff --git a/testdata/realworld/mariadb/02_reporting.sql b/testdata/realworld/mariadb/02_reporting.sql new file mode 100644 index 00000000..2e295e70 --- /dev/null +++ b/testdata/realworld/mariadb/02_reporting.sql @@ -0,0 +1,46 @@ +-- Dashboard and reporting queries with aggregation, subqueries and CTEs +SELECT status, COUNT(*) AS n, SUM(total) AS revenue FROM orders GROUP BY status HAVING SUM(total) > 1000 ORDER BY revenue DESC; +SELECT YEAR(created_at) AS y, MONTH(created_at) AS m, SUM(total) FROM orders GROUP BY YEAR(created_at), MONTH(created_at) WITH ROLLUP; +SELECT category_id, COUNT(DISTINCT user_id) AS buyers FROM order_items i JOIN orders o ON o.id = i.order_id GROUP BY category_id; +SELECT GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') FROM tags; +SELECT user_id, GROUP_CONCAT(DISTINCT status) FROM orders GROUP BY user_id; +SELECT d.dt, COALESCE(s.cnt, 0) FROM calendar d LEFT JOIN (SELECT DATE(created_at) AS dt, COUNT(*) AS cnt FROM orders GROUP BY DATE(created_at)) s ON s.dt = d.dt; +WITH monthly AS (SELECT DATE_FORMAT(created_at, '%Y-%m-01') AS month, SUM(total) AS rev FROM orders GROUP BY 1) SELECT month, rev FROM monthly ORDER BY month; +WITH a AS (SELECT user_id, SUM(total) AS t FROM orders GROUP BY user_id), b AS (SELECT user_id, COUNT(*) AS c FROM sessions GROUP BY user_id), c AS (SELECT a.user_id, a.t, b.c FROM a JOIN b USING (user_id)) SELECT * FROM c WHERE t > 500; +SELECT u.id, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count FROM users u; +SELECT * FROM (SELECT user_id, MAX(total) AS mx FROM orders GROUP BY user_id) AS t WHERE mx > 100; +SELECT a.id FROM a JOIN b ON a.id = b.a_id JOIN c ON c.b_id = b.id JOIN d ON d.c_id = c.id LEFT JOIN e ON e.d_id = d.id LEFT JOIN f ON f.e_id = e.id WHERE f.id IS NULL; +SELECT t1.id FROM t1 NATURAL JOIN t2; +SELECT t1.id FROM t1 CROSS JOIN t2 WHERE t1.x = t2.y; +SELECT t1.id FROM t1 STRAIGHT_JOIN t2 ON t1.id = t2.id; +SELECT t1.id FROM t1 RIGHT JOIN t2 ON t1.id = t2.id; +SELECT * FROM t1 UNION SELECT * FROM t2 UNION ALL SELECT * FROM t3 ORDER BY 1 LIMIT 10; +(SELECT id FROM t1 ORDER BY id LIMIT 5) UNION ALL (SELECT id FROM t2 ORDER BY id LIMIT 5); +SELECT SQL_CALC_FOUND_ROWS id FROM users LIMIT 10; +SELECT FOUND_ROWS(); +SELECT * FROM t1 USE INDEX (idx_a) WHERE a = 1; +SELECT * FROM t1 FORCE INDEX (idx_a, idx_b) WHERE a = 1 OR b = 2; +SELECT * FROM t1 IGNORE INDEX FOR ORDER BY (idx_a) ORDER BY a; +SELECT AVG(total), STDDEV(total), MIN(total), MAX(total) FROM orders; +SELECT IF(total > 100, 'hi', 'lo') AS tier, COUNT(*) FROM orders GROUP BY tier; +SELECT DATEDIFF(NOW(), created_at) AS age_days, TIMESTAMPDIFF(HOUR, created_at, NOW()) FROM users; +SELECT DATE_ADD(NOW(), INTERVAL 7 DAY), DATE_SUB(CURDATE(), INTERVAL 1 MONTH); +SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL 30 DAY; +SELECT id, total INTO @id, @total FROM orders WHERE id = 1; +SELECT CAST(total AS DECIMAL(10, 2)), CAST(id AS CHAR), CONVERT(name, CHAR(50)) FROM orders; +SELECT 1 + 2 * 3, (1 + 2) * 3, 10 DIV 3, 10 MOD 3, 10 % 3, 2 << 3, 5 & 3, 5 | 3, 5 ^ 3, ~5; +SELECT * FROM users WHERE name REGEXP '^A' AND email NOT REGEXP 'test'; +SELECT * FROM users WHERE a <=> NULL AND b IS NOT NULL AND NOT c; +SELECT 'a' || 'b'; +SELECT * FROM t WHERE x = 1 XOR y = 2; +SHOW TABLES; +SHOW COLUMNS FROM users; +SHOW CREATE TABLE users; +SHOW INDEX FROM orders; +SHOW VARIABLES LIKE 'max_connections'; +SHOW FULL PROCESSLIST; +SHOW GRANTS FOR 'app'@'localhost'; +SHOW STATUS LIKE 'Threads%'; +DESCRIBE users; +EXPLAIN SELECT * FROM users WHERE id = 1; +EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 1; diff --git a/testdata/realworld/mariadb/03_ddl_migrations.sql b/testdata/realworld/mariadb/03_ddl_migrations.sql new file mode 100644 index 00000000..cf127753 --- /dev/null +++ b/testdata/realworld/mariadb/03_ddl_migrations.sql @@ -0,0 +1,55 @@ +-- Schema migrations: CREATE/ALTER/DROP with IF [NOT] EXISTS, indexes, constraints +CREATE DATABASE IF NOT EXISTS shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS users (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, name VARCHAR(100) DEFAULT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_email (email)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS orders (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, total DECIMAL(12, 2) NOT NULL DEFAULT 0.00, status ENUM('new', 'paid', 'shipped', 'cancelled') NOT NULL DEFAULT 'new', note TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_status_created (status, created_at), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE RESTRICT) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS order_items (order_id BIGINT UNSIGNED NOT NULL, product_id INT NOT NULL, qty SMALLINT NOT NULL DEFAULT 1, price DECIMAL(10, 2) NOT NULL, PRIMARY KEY (order_id, product_id), CONSTRAINT chk_qty CHECK (qty > 0)); +CREATE TABLE IF NOT EXISTS audit_log (id BIGINT AUTO_INCREMENT PRIMARY KEY, actor VARCHAR(64) NOT NULL, action VARCHAR(32) NOT NULL, payload JSON, at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)) ENGINE=InnoDB ROW_FORMAT=DYNAMIC; +CREATE TABLE IF NOT EXISTS events (id BIGINT NOT NULL, happened_on DATE NOT NULL, body TEXT, PRIMARY KEY (id, happened_on)) PARTITION BY RANGE (YEAR(happened_on)) (PARTITION p2024 VALUES LESS THAN (2025), PARTITION p2025 VALUES LESS THAN (2026), PARTITION pmax VALUES LESS THAN MAXVALUE); +CREATE TABLE IF NOT EXISTS people (id INT PRIMARY KEY, first_name VARCHAR(50), last_name VARCHAR(50), full_name VARCHAR(101) AS (CONCAT(first_name, ' ', last_name)) VIRTUAL, name_len INT AS (CHAR_LENGTH(first_name)) PERSISTENT); +CREATE TABLE IF NOT EXISTS temp_copy LIKE users; +CREATE TABLE IF NOT EXISTS user_emails AS SELECT id, email FROM users WHERE email IS NOT NULL; +CREATE TEMPORARY TABLE IF NOT EXISTS tmp_ids (id INT PRIMARY KEY); +CREATE OR REPLACE TABLE scratch (a INT, b VARCHAR(10)); +CREATE INDEX IF NOT EXISTS idx_orders_total ON orders (total); +CREATE UNIQUE INDEX IF NOT EXISTS uk_users_name ON users (name, email); +CREATE FULLTEXT INDEX IF NOT EXISTS ft_note ON orders (note); +CREATE INDEX idx_prefix ON users (email(20)); +CREATE OR REPLACE VIEW v_active_users AS SELECT id, email FROM users WHERE name IS NOT NULL; +CREATE VIEW IF NOT EXISTS v_order_totals AS SELECT user_id, SUM(total) AS total FROM orders GROUP BY user_id; +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NULL AFTER name; +ALTER TABLE users ADD COLUMN last_login DATETIME NULL, ADD COLUMN login_count INT NOT NULL DEFAULT 0; +ALTER TABLE users MODIFY COLUMN name VARCHAR(200) NOT NULL DEFAULT ''; +ALTER TABLE users CHANGE COLUMN phone mobile VARCHAR(40) NULL; +ALTER TABLE users DROP COLUMN IF EXISTS mobile; +ALTER TABLE users ADD INDEX IF NOT EXISTS idx_name (name); +ALTER TABLE users DROP INDEX IF EXISTS idx_name; +ALTER TABLE orders ADD CONSTRAINT fk_o_u FOREIGN KEY (user_id) REFERENCES users (id); +ALTER TABLE orders DROP FOREIGN KEY fk_o_u; +ALTER TABLE orders ADD PRIMARY KEY (id); +ALTER TABLE orders ENGINE=InnoDB, ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE orders RENAME TO orders_old; +ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'new'; +ALTER TABLE orders ALTER COLUMN note DROP DEFAULT; +ALTER ONLINE TABLE orders ADD COLUMN flag TINYINT(1) NOT NULL DEFAULT 0; +RENAME TABLE orders_old TO orders_archive, scratch TO scratch_old; +TRUNCATE TABLE audit_log; +DROP TABLE IF EXISTS scratch_old, temp_copy; +DROP VIEW IF EXISTS v_active_users; +DROP INDEX IF EXISTS idx_orders_total ON orders; +DROP DATABASE IF EXISTS shop_old; +CREATE TRIGGER trg_users_bu BEFORE UPDATE ON users FOR EACH ROW SET NEW.updated_at = NOW(); +CREATE OR REPLACE TRIGGER trg_orders_ai AFTER INSERT ON orders FOR EACH ROW INSERT INTO audit_log (actor, action) VALUES ('system', 'order'); +DROP TRIGGER IF EXISTS trg_users_bu; +CREATE USER IF NOT EXISTS 'app'@'%' IDENTIFIED BY 'secret'; +GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app'@'%'; +REVOKE DELETE ON shop.* FROM 'app'@'%'; +FLUSH PRIVILEGES; +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; +SET @a := 1; +SET SESSION sql_mode = 'STRICT_ALL_TABLES'; +USE shop; +ANALYZE TABLE orders; +OPTIMIZE TABLE orders; +LOCK TABLES orders WRITE, users READ; +UNLOCK TABLES; diff --git a/testdata/realworld/mariadb/04_window_analytics.sql b/testdata/realworld/mariadb/04_window_analytics.sql new file mode 100644 index 00000000..bda418e8 --- /dev/null +++ b/testdata/realworld/mariadb/04_window_analytics.sql @@ -0,0 +1,24 @@ +-- Analytics with window functions, CTEs and recursive CTEs +SELECT id, user_id, total, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn FROM orders; +SELECT id, RANK() OVER (ORDER BY total DESC) AS r, DENSE_RANK() OVER (ORDER BY total DESC) AS dr FROM orders; +SELECT id, SUM(total) OVER (PARTITION BY user_id ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running FROM orders; +SELECT id, AVG(total) OVER (ORDER BY created_at ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS ma5 FROM orders; +SELECT id, SUM(total) OVER (ORDER BY created_at RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW) FROM orders; +SELECT id, LAG(total, 1, 0) OVER (PARTITION BY user_id ORDER BY created_at) AS prev, LEAD(total) OVER (PARTITION BY user_id ORDER BY created_at) AS next FROM orders; +SELECT id, FIRST_VALUE(total) OVER w AS f, LAST_VALUE(total) OVER w AS l, NTH_VALUE(total, 2) OVER w AS n2 FROM orders WINDOW w AS (PARTITION BY user_id ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING); +SELECT id, NTILE(4) OVER (ORDER BY total) AS quartile, PERCENT_RANK() OVER (ORDER BY total) AS pr, CUME_DIST() OVER (ORDER BY total) AS cd FROM orders; +SELECT DISTINCT user_id, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total) OVER (PARTITION BY user_id) AS median FROM orders; +SELECT user_id, MEDIAN(total) OVER (PARTITION BY user_id) FROM orders; +SELECT * FROM (SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders o) t WHERE rn = 1; +WITH RECURSIVE nums (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 100) SELECT n FROM nums; +WITH RECURSIVE tree AS (SELECT id, parent_id, name, 0 AS depth, CAST(name AS CHAR(1000)) AS path FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id, c.name, t.depth + 1, CONCAT(t.path, '/', c.name) FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree ORDER BY path; +WITH RECURSIVE org AS (SELECT id, manager_id, name FROM employees WHERE id = 1 UNION SELECT e.id, e.manager_id, e.name FROM employees e INNER JOIN org o ON e.manager_id = o.id) SELECT * FROM org; +WITH RECURSIVE dates AS (SELECT DATE('2025-01-01') AS d UNION ALL SELECT d + INTERVAL 1 DAY FROM dates WHERE d < '2025-12-31') SELECT d, COUNT(o.id) FROM dates LEFT JOIN orders o ON DATE(o.created_at) = d GROUP BY d; +WITH first_orders AS (SELECT user_id, MIN(created_at) AS first_at FROM orders GROUP BY user_id), cohorts AS (SELECT user_id, DATE_FORMAT(first_at, '%Y-%m') AS cohort FROM first_orders), activity AS (SELECT c.cohort, DATE_FORMAT(o.created_at, '%Y-%m') AS active_month, COUNT(DISTINCT o.user_id) AS users FROM cohorts c JOIN orders o ON o.user_id = c.user_id GROUP BY c.cohort, active_month) SELECT cohort, active_month, users, FIRST_VALUE(users) OVER (PARTITION BY cohort ORDER BY active_month) AS cohort_size FROM activity ORDER BY cohort, active_month; +WITH t AS (SELECT 1 AS x) SELECT * FROM t; +WITH s1 AS (SELECT user_id, SUM(total) AS s FROM orders GROUP BY user_id), s2 AS (SELECT user_id, s, NTILE(10) OVER (ORDER BY s DESC) AS decile FROM s1), s3 AS (SELECT decile, COUNT(*) AS n, AVG(s) AS avg_s FROM s2 GROUP BY decile) SELECT * FROM s3 ORDER BY decile; +SELECT user_id, total, total / SUM(total) OVER (PARTITION BY user_id) AS share FROM orders; +SELECT COUNT(*) OVER () AS total_rows, id FROM orders LIMIT 10; +SELECT id, SUM(total) OVER (PARTITION BY user_id ORDER BY created_at ROWS UNBOUNDED PRECEDING) FROM orders; +SELECT a.id, b.id FROM (SELECT id FROM t1) a, (SELECT id FROM t2) b WHERE a.id < b.id; +SELECT id, CASE WHEN ROW_NUMBER() OVER (ORDER BY id) % 2 = 0 THEN 'even' ELSE 'odd' END FROM t1; diff --git a/testdata/realworld/mariadb/05_mariadb_specific.sql b/testdata/realworld/mariadb/05_mariadb_specific.sql new file mode 100644 index 00000000..d2d608e7 --- /dev/null +++ b/testdata/realworld/mariadb/05_mariadb_specific.sql @@ -0,0 +1,71 @@ +-- MariaDB specific: SEQUENCE, RETURNING, INTERSECT/EXCEPT, system versioning, invisible columns, JSON_TABLE +CREATE SEQUENCE IF NOT EXISTS seq_invoice START WITH 1000 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999999 CACHE 100 NOCYCLE; +CREATE OR REPLACE SEQUENCE seq_ticket START WITH 1 INCREMENT BY 5 CYCLE; +CREATE SEQUENCE seq_simple; +ALTER SEQUENCE seq_invoice RESTART WITH 5000; +ALTER SEQUENCE IF EXISTS seq_invoice INCREMENT BY 10; +DROP SEQUENCE IF EXISTS seq_ticket; +SELECT NEXT VALUE FOR seq_invoice; +SELECT NEXTVAL(seq_invoice), LASTVAL(seq_invoice); +SELECT SETVAL(seq_invoice, 7000); +INSERT INTO invoices (id, amount) VALUES (NEXT VALUE FOR seq_invoice, 99.50); +INSERT INTO invoices (id, amount) VALUES (NEXTVAL(seq_invoice), 10); +CREATE TABLE IF NOT EXISTS tickets (id BIGINT NOT NULL DEFAULT NEXT VALUE FOR seq_ticket PRIMARY KEY, title VARCHAR(100)); +INSERT INTO users (email, name) VALUES ('r@example.com', 'R') RETURNING id, created_at; +INSERT INTO users (email, name) VALUES ('s@example.com', 'S'), ('t@example.com', 'T') RETURNING *; +INSERT INTO users (email, name) SELECT email, name FROM staging_users RETURNING id AS new_id, email; +DELETE FROM sessions WHERE expires_at < NOW() RETURNING id, user_id; +DELETE FROM carts WHERE user_id = 5 RETURNING *; +REPLACE INTO settings (k, v) VALUES ('a', '1') RETURNING k, v; +INSERT INTO page_views (page_id, views) VALUES (1, 1) ON DUPLICATE KEY UPDATE views = views + 1 RETURNING page_id, views; +SELECT id FROM t1 INTERSECT SELECT id FROM t2; +SELECT id FROM t1 EXCEPT SELECT id FROM t2; +SELECT id FROM t1 INTERSECT ALL SELECT id FROM t2; +SELECT id FROM t1 EXCEPT ALL SELECT id FROM t2; +(SELECT id FROM t1 UNION SELECT id FROM t2) INTERSECT SELECT id FROM t3; +SELECT id FROM t1 UNION SELECT id FROM t2 EXCEPT SELECT id FROM t3 ORDER BY id; +SELECT user_id FROM orders WHERE status = 'paid' INTERSECT SELECT user_id FROM orders WHERE status = 'shipped' EXCEPT SELECT user_id FROM bans; +CREATE TABLE IF NOT EXISTS accounts (id INT PRIMARY KEY, balance DECIMAL(12, 2) NOT NULL) WITH SYSTEM VERSIONING; +CREATE TABLE IF NOT EXISTS prices (id INT PRIMARY KEY, price DECIMAL(10, 2), row_start TIMESTAMP(6) GENERATED ALWAYS AS ROW START, row_end TIMESTAMP(6) GENERATED ALWAYS AS ROW END, PERIOD FOR SYSTEM_TIME (row_start, row_end)) WITH SYSTEM VERSIONING; +CREATE TABLE IF NOT EXISTS hist (id INT, v INT) WITH SYSTEM VERSIONING PARTITION BY SYSTEM_TIME INTERVAL 1 MONTH (PARTITION p_old HISTORY, PARTITION p_cur CURRENT); +ALTER TABLE users ADD SYSTEM VERSIONING; +ALTER TABLE users DROP SYSTEM VERSIONING; +ALTER TABLE users ADD COLUMN IF NOT EXISTS secret VARCHAR(64) INVISIBLE; +CREATE TABLE IF NOT EXISTS inv (id INT PRIMARY KEY, a INT, hidden_note TEXT INVISIBLE); +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_hash CHAR(32) WITHOUT SYSTEM VERSIONING; +SELECT * FROM accounts FOR SYSTEM_TIME AS OF TIMESTAMP '2025-01-01 00:00:00' WHERE id = 1; +SELECT * FROM accounts FOR SYSTEM_TIME BETWEEN '2025-01-01' AND '2025-06-30' WHERE id = 1; +SELECT * FROM accounts FOR SYSTEM_TIME FROM '2025-01-01' TO '2025-02-01'; +SELECT * FROM accounts FOR SYSTEM_TIME ALL ORDER BY id; +SELECT id, ROW_START, ROW_END FROM accounts FOR SYSTEM_TIME ALL WHERE id = 1; +DELETE HISTORY FROM accounts BEFORE SYSTEM_TIME '2024-01-01 00:00:00'; +SELECT jt.* FROM JSON_TABLE('[{"a":1,"b":"x"},{"a":2,"b":"y"}]', '$[*]' COLUMNS (a INT PATH '$.a', b VARCHAR(10) PATH '$.b')) AS jt; +SELECT o.id, jt.sku, jt.qty FROM orders o, JSON_TABLE(o.items, '$[*]' COLUMNS (rn FOR ORDINALITY, sku VARCHAR(32) PATH '$.sku', qty INT PATH '$.qty' DEFAULT '1' ON EMPTY)) AS jt; +SELECT JSON_EXTRACT(payload, '$.user.id') AS uid, JSON_UNQUOTE(JSON_EXTRACT(payload, '$.name')) FROM audit_log; +SELECT JSON_VALUE(payload, '$.name') AS n, JSON_QUERY(payload, '$.tags') AS t FROM audit_log WHERE JSON_VALID(payload); +SELECT JSON_OBJECT('id', id, 'email', email) FROM users; +SELECT JSON_ARRAYAGG(email), JSON_OBJECTAGG(id, email) FROM users; +SELECT JSON_SET(payload, '$.seen', TRUE), JSON_ARRAY_APPEND(payload, '$.tags', 'x') FROM audit_log; +SELECT * FROM audit_log WHERE JSON_CONTAINS(payload, '"x"', '$.tags') AND JSON_LENGTH(payload) > 1; +SELECT UUID(), UUID_SHORT(); +CREATE TABLE IF NOT EXISTS things (id UUID NOT NULL DEFAULT UUID() PRIMARY KEY, addr INET6, ip4 INET4); +SELECT SYS_GUID(); +SELECT id, COLUMN_GET(dyn, 'k' AS CHAR) FROM dyn_t; +SELECT REGEXP_REPLACE(name, '[0-9]+', '#'), REGEXP_SUBSTR(name, '[a-z]+'), REGEXP_INSTR(name, 'x') FROM users; +SELECT LEFT(name, 3), RIGHT(name, 2), SUBSTRING_INDEX(email, '@', -1), TRIM(BOTH ' ' FROM name), LPAD(id, 8, '0') FROM users; +SELECT DATE_FORMAT(NOW(), '%Y-%m-%d'), STR_TO_DATE('2025-01-02', '%Y-%m-%d'), UNIX_TIMESTAMP(), FROM_UNIXTIME(0); +SELECT * FROM information_schema.tables WHERE table_schema = DATABASE(); +SELECT * FROM mysql.user LIMIT 1; +SET STATEMENT max_statement_time = 5 FOR SELECT * FROM orders; +SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 5 ROWS EXAMINED 1000; +SELECT * FROM orders FETCH FIRST 5 ROWS ONLY; +SELECT * FROM orders ORDER BY id OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY; +SELECT * FROM orders ORDER BY id FETCH FIRST 3 ROWS WITH TIES; +SELECT id FROM orders WHERE id > 5 LIMIT 5, 10; +SELECT * FROM t1 FOR UPDATE WAIT 5; +SELECT * FROM t1 FOR UPDATE NOWAIT; +CREATE ROLE IF NOT EXISTS reporting; +GRANT SELECT ON shop.* TO reporting; +SET ROLE reporting; +BACKUP STAGE START; +BACKUP STAGE END; diff --git a/testdata/realworld/mariadb/06_etl_admin.sql b/testdata/realworld/mariadb/06_etl_admin.sql new file mode 100644 index 00000000..3f362ee1 --- /dev/null +++ b/testdata/realworld/mariadb/06_etl_admin.sql @@ -0,0 +1,42 @@ +-- ETL batch loads, data cleanup and DBA administration queries +INSERT INTO dim_customer (customer_id, email, segment) SELECT u.id, LOWER(TRIM(u.email)), CASE WHEN s.total > 1000 THEN 'gold' ELSE 'std' END FROM users u LEFT JOIN (SELECT user_id, SUM(total) AS total FROM orders GROUP BY user_id) s ON s.user_id = u.id ON DUPLICATE KEY UPDATE email = VALUES(email), segment = VALUES(segment); +INSERT INTO daily_sales (day, revenue, orders) SELECT DATE(created_at), SUM(total), COUNT(*) FROM orders WHERE created_at >= CURDATE() - INTERVAL 1 DAY AND created_at < CURDATE() GROUP BY DATE(created_at); +UPDATE dim_customer c JOIN (SELECT user_id, MAX(created_at) AS last_order FROM orders GROUP BY user_id) o ON o.user_id = c.customer_id SET c.last_order = o.last_order; +UPDATE orders SET status = CASE id WHEN 1 THEN 'paid' WHEN 2 THEN 'shipped' ELSE status END WHERE id IN (1, 2, 3); +UPDATE users SET email = LOWER(email), name = TRIM(name) WHERE email <> LOWER(email); +UPDATE t1, t2 SET t1.a = t2.b WHERE t1.id = t2.id; +UPDATE IGNORE products SET sku = UPPER(sku); +DELETE FROM audit_log WHERE at < NOW() - INTERVAL 90 DAY LIMIT 10000; +DELETE t1 FROM t1 INNER JOIN t2 ON t1.id = t2.id WHERE t2.flag = 1; +DELETE FROM t1 USING t1 INNER JOIN t2 ON t1.id = t2.id; +INSERT INTO archive.orders SELECT * FROM orders WHERE created_at < '2023-01-01'; +INSERT INTO ledger (account, amount) VALUES (1, -5.00), (2, 5.00); +INSERT INTO t (a, b) VALUES (1, DEFAULT), (DEFAULT, 2); +INSERT INTO t () VALUES (); +INSERT INTO t1 (a) VALUES (1) ON DUPLICATE KEY UPDATE a = a + VALUES(a), b = IF(b IS NULL, 1, b); +LOAD DATA INFILE '/tmp/users.csv' INTO TABLE users FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (email, name); +SELECT * FROM orders INTO OUTFILE '/tmp/orders.csv' FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n'; +SELECT table_schema, SUM(data_length + index_length) / 1024 / 1024 AS size_mb FROM information_schema.tables GROUP BY table_schema ORDER BY size_mb DESC; +SELECT table_name, engine, table_rows FROM information_schema.tables WHERE table_schema = 'shop' AND table_type = 'BASE TABLE'; +SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'shop' AND table_name = 'users' ORDER BY ordinal_position; +SELECT constraint_name, referenced_table_name FROM information_schema.key_column_usage WHERE table_schema = DATABASE() AND referenced_table_name IS NOT NULL; +SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command <> 'Sleep' AND time > 10 ORDER BY time DESC; +SELECT @@global.max_connections, @@session.sql_mode, @@version, @@innodb_buffer_pool_size; +SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 50; +KILL 1234; +KILL QUERY 1234; +SELECT t.table_name FROM information_schema.tables t LEFT JOIN information_schema.statistics s ON s.table_schema = t.table_schema AND s.table_name = t.table_name WHERE s.index_name IS NULL AND t.table_schema = 'shop'; +SELECT COUNT(*) AS dupes, email FROM users GROUP BY email HAVING COUNT(*) > 1; +SELECT u1.id, u2.id FROM users u1 JOIN users u2 ON u1.email = u2.email AND u1.id < u2.id; +SELECT * FROM orders o WHERE o.total > (SELECT AVG(total) FROM orders WHERE user_id = o.user_id); +SELECT * FROM orders WHERE (user_id, created_at) IN (SELECT user_id, MAX(created_at) FROM orders GROUP BY user_id); +SELECT * FROM products WHERE price > ALL (SELECT price FROM products WHERE category_id = 2); +SELECT * FROM products WHERE price > ANY (SELECT price FROM products WHERE category_id = 2); +SELECT * FROM users WHERE id = (SELECT MAX(id) FROM users); +SELECT 1 FROM DUAL; +SELECT NULL, TRUE, FALSE, 0x1F, X'1F', b'101', 1.5e3, .5, 'it''s', "dq"; +SELECT _utf8mb4'abc' COLLATE utf8mb4_bin, BINARY name, name COLLATE utf8mb4_general_ci FROM users; +SELECT @rownum := @rownum + 1 AS rn, t.* FROM t, (SELECT @rownum := 0) r; +PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'; +EXECUTE stmt USING @id; +DEALLOCATE PREPARE stmt; diff --git a/testdata/realworld/mariadb/07_stored_programs.sql b/testdata/realworld/mariadb/07_stored_programs.sql new file mode 100644 index 00000000..094265c5 --- /dev/null +++ b/testdata/realworld/mariadb/07_stored_programs.sql @@ -0,0 +1,20 @@ +-- Stored procedures, functions, events and transactional patterns +CREATE PROCEDURE sp_touch_user(IN p_id BIGINT) UPDATE users SET updated_at = NOW() WHERE id = p_id; +CREATE OR REPLACE FUNCTION fn_double(x INT) RETURNS INT DETERMINISTIC RETURN x * 2; +CREATE FUNCTION IF NOT EXISTS fn_tax(amount DECIMAL(10, 2)) RETURNS DECIMAL(10, 2) DETERMINISTIC RETURN amount * 0.2; +DROP PROCEDURE IF EXISTS sp_touch_user; +DROP FUNCTION IF EXISTS fn_double; +CALL sp_touch_user(42); +CREATE EVENT IF NOT EXISTS ev_purge ON SCHEDULE EVERY 1 DAY STARTS '2025-01-01 03:00:00' DO DELETE FROM sessions WHERE expires_at < NOW(); +DROP EVENT IF EXISTS ev_purge; +BEGIN; +ROLLBACK; +SAVEPOINT sp1; +ROLLBACK TO SAVEPOINT sp1; +RELEASE SAVEPOINT sp1; +SET autocommit = 0; +SET TRANSACTION ISOLATION LEVEL READ COMMITTED; +START TRANSACTION READ ONLY; +START TRANSACTION WITH CONSISTENT SNAPSHOT; +XA START 'x1'; +CREATE DEFINER = 'root'@'localhost' PROCEDURE sp_purge() DELETE FROM sessions WHERE expires_at < NOW(); diff --git a/testdata/realworld/mariadb/08_complex_queries.sql b/testdata/realworld/mariadb/08_complex_queries.sql new file mode 100644 index 00000000..6fdf3dd6 --- /dev/null +++ b/testdata/realworld/mariadb/08_complex_queries.sql @@ -0,0 +1,32 @@ +-- Deeply nested and long-join analytical queries as produced by BI tools +SELECT a.id, a.name, b.total, c.cnt FROM accounts a INNER JOIN (SELECT account_id, SUM(amount) AS total FROM ledger WHERE posted_at BETWEEN '2025-01-01' AND '2025-12-31' GROUP BY account_id HAVING SUM(amount) <> 0) b ON b.account_id = a.id LEFT JOIN (SELECT account_id, COUNT(*) AS cnt FROM tickets WHERE status IN ('open', 'pending') GROUP BY account_id) c ON c.account_id = a.id WHERE a.active = 1 AND a.id NOT IN (SELECT account_id FROM blocked) ORDER BY b.total DESC, a.name ASC LIMIT 50; +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT id, v FROM t WHERE v > 0) l3 WHERE id > 1) l2 WHERE id < 100) l1 ORDER BY id; +SELECT x.id FROM t1 x WHERE x.id IN (SELECT y.id FROM t2 y WHERE y.v > (SELECT AVG(z.v) FROM t3 z WHERE z.k IN (SELECT w.k FROM t4 w WHERE EXISTS (SELECT 1 FROM t5 q WHERE q.w = w.id)))); +SELECT p.id, p.name, c.name AS category, b.name AS brand, s.name AS supplier, w.name AS warehouse, i.qty, pr.price, cur.code FROM products p JOIN categories c ON c.id = p.category_id JOIN brands b ON b.id = p.brand_id JOIN suppliers s ON s.id = p.supplier_id JOIN inventory i ON i.product_id = p.id JOIN warehouses w ON w.id = i.warehouse_id JOIN prices pr ON pr.product_id = p.id AND pr.valid_to IS NULL JOIN currencies cur ON cur.id = pr.currency_id WHERE i.qty > 0 AND p.active; +SELECT CASE WHEN a > 0 THEN CASE WHEN b > 0 THEN 'pp' ELSE 'pn' END ELSE CASE WHEN b > 0 THEN 'np' ELSE 'nn' END END AS quad, COUNT(*) FROM pts GROUP BY quad; +SELECT ((((a + b) * c) - d) / NULLIF(e, 0)) AS r, -a AS neg, +b AS pos, NOT (a > b AND (c < d OR e = f)) AS flag FROM nums; +SELECT * FROM t WHERE a = 1 AND (b = 2 OR c = 3) AND NOT (d = 4 OR e = 5) AND f IN (1, 2, 3) AND g BETWEEN 1 AND 10 AND h LIKE 'x%' AND i IS NULL; +SELECT COALESCE((SELECT MAX(v) FROM t2 WHERE t2.id = t1.id), (SELECT MIN(v) FROM t3 WHERE t3.id = t1.id), 0) FROM t1; +SELECT u.id, (SELECT GROUP_CONCAT(r.name SEPARATOR ',') FROM roles r JOIN user_roles ur ON ur.role_id = r.id WHERE ur.user_id = u.id) AS roles FROM users u; +SELECT t.id, t.n FROM (SELECT id, COUNT(*) AS n FROM events GROUP BY id) AS t JOIN (SELECT id FROM users WHERE active = 1) AS u USING (id) WHERE t.n > 5; +SELECT a.x, b.y FROM a JOIN b ON a.id = b.id AND (a.k = b.k OR (a.k IS NULL AND b.k IS NULL)); +SELECT * FROM a LEFT JOIN b ON a.id = b.a_id LEFT JOIN c ON b.id = c.b_id RIGHT JOIN d ON d.id = c.d_id; +SELECT * FROM a JOIN (b JOIN c ON b.id = c.b_id) ON a.id = b.a_id; +SELECT * FROM a, b, c WHERE a.id = b.id AND b.id = c.id; +SELECT DISTINCT a.x FROM a WHERE a.y > 1 ORDER BY a.x LIMIT 5; +SELECT COUNT(DISTINCT a, b) FROM t; +SELECT SUM(IF(status = 'paid', total, 0)) AS paid, SUM(IF(status = 'new', total, 0)) AS pending FROM orders; +SELECT user_id, MAX(CASE WHEN k = 'a' THEN v END) AS a, MAX(CASE WHEN k = 'b' THEN v END) AS b FROM kv GROUP BY user_id; +SELECT a, b, COUNT(*) FROM t GROUP BY a, b WITH ROLLUP HAVING COUNT(*) > 1 ORDER BY a DESC, b; +SELECT * FROM t1 WHERE id = ANY (SELECT id FROM t2) ORDER BY RAND() LIMIT 1; +SELECT ROUND(AVG(total), 2), FLOOR(MAX(total)), CEIL(MIN(total)), ABS(SUM(total)), POWER(2, 10), SQRT(16), MOD(10, 3) FROM orders; +VALUES (1, 'a'), (2, 'b'); +SELECT * FROM t1 WHERE a IN (SELECT a FROM t2 UNION SELECT a FROM t3); +SELECT * FROM (SELECT a FROM t1 UNION ALL SELECT a FROM t2) u ORDER BY a; +SELECT (SELECT 1) + (SELECT 2); +SELECT EXISTS (SELECT 1 FROM t WHERE a = 1); +SELECT id, name FROM users WHERE MATCH (name, bio) AGAINST ('database' IN NATURAL LANGUAGE MODE); +SELECT id FROM docs WHERE MATCH (body) AGAINST ('+sql -nosql' IN BOOLEAN MODE); +SELECT ST_AsText(ST_GeomFromText('POINT(1 1)')), ST_Distance(a.pt, b.pt) FROM a, b; +SELECT INTERVAL(5, 1, 3, 7), FIELD('b', 'a', 'b'), ELT(2, 'x', 'y'), FIND_IN_SET('a', 'a,b'); +SELECT BIT_AND(a), BIT_OR(a), BIT_XOR(a), VARIANCE(a), STD(a) FROM t; diff --git a/testdata/realworld/mariadb/09_mysql_compat_misc.sql b/testdata/realworld/mariadb/09_mysql_compat_misc.sql new file mode 100644 index 00000000..ecc82c5e --- /dev/null +++ b/testdata/realworld/mariadb/09_mysql_compat_misc.sql @@ -0,0 +1,57 @@ +-- Misc MySQL-compatible statements seen in migration tools, monitoring agents and ORMs (Django, Rails, Laravel) +SELECT @@version_comment LIMIT 1; +SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'users' LIMIT 1; +SELECT `users`.* FROM `users` WHERE `users`.`deleted_at` IS NULL ORDER BY `users`.`id` ASC LIMIT 1; +SELECT COUNT(*) AS `count` FROM `orders` WHERE `orders`.`user_id` = 5; +SELECT `orders`.`id` AS t0_r0, `users`.`id` AS t1_r0 FROM `orders` LEFT OUTER JOIN `users` ON `users`.`id` = `orders`.`user_id`; +SELECT 1 AS one FROM `users` WHERE `users`.`email` = BINARY 'a@b.c' LIMIT 1; +INSERT INTO `django_migrations` (`app`, `name`, `applied`) VALUES ('auth', '0001_initial', '2025-01-01 00:00:00.000000'); +UPDATE `users` SET `last_login` = '2025-01-01 00:00:00.000000' WHERE `users`.`id` = 1; +DELETE FROM `django_session` WHERE `django_session`.`expire_date` < '2025-01-01 00:00:00.000000'; +SELECT `a`.`id`, `a`.`title` FROM `articles` `a` WHERE `a`.`id` IN (1, 2, 3) AND `a`.`status` = 'published'; +SELECT SQL_NO_CACHE * FROM t1; +SELECT DISTINCTROW a FROM t1; +SELECT HIGH_PRIORITY a FROM t1; +SELECT ALL a FROM t1; +SELECT a FROM t1 WHERE b = 'x' COLLATE utf8mb4_bin; +SELECT CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, LOCALTIME, NOW(6), UTC_TIMESTAMP(); +SELECT EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at) FROM orders; +SELECT SUBSTRING(name FROM 2 FOR 3), POSITION('a' IN name), TRIM(LEADING '0' FROM code) FROM t; +SELECT CHAR_LENGTH(name), LENGTH(name), REVERSE(name), REPEAT('x', 3), REPLACE(name, 'a', 'b'), INSTR(name, 'a'), LOCATE('a', name) FROM t; +SELECT MD5(email), SHA1(email), SHA2(email, 256), HEX(name), UNHEX('41'), TO_BASE64(name), FROM_BASE64('QQ==') FROM users; +SELECT AES_ENCRYPT('x', 'k'), AES_DECRYPT(col, 'k'), COMPRESS(col), UNCOMPRESS(col) FROM t; +SELECT INET_ATON('1.2.3.4'), INET_NTOA(16909060), INET6_ATON('::1'); +SELECT IFNULL(a, 0) + IFNULL(b, 0), NULLIF(a, 0), GREATEST(a, b, c), LEAST(a, b, c) FROM t; +SELECT GET_LOCK('k', 10), RELEASE_LOCK('k'), IS_FREE_LOCK('k'); +SELECT SLEEP(1), BENCHMARK(1000, MD5('x')), CONNECTION_ID(), ROW_COUNT(); +SELECT a FROM t1 WHERE a > 1 UNION DISTINCT SELECT a FROM t2; +SELECT a, b FROM t1 ORDER BY a ASC, b DESC LIMIT 3 OFFSET 2; +SELECT * FROM t1 WHERE a = 1 ORDER BY 1, 2; +SELECT t1.*, t2.* FROM t1, t2; +SELECT db1.t1.col FROM db1.t1; +SELECT `my table`.`my col` FROM `my table`; +SELECT * FROM t1 WHERE a = -1 AND b = - 1 AND c = +1; +SELECT 1 FROM t1 WHERE a IN (SELECT 1) AND b NOT BETWEEN 1 AND 2 AND c NOT LIKE 'x' AND d NOT IN (1, 2); +SELECT a FROM t1 WHERE a SOUNDS LIKE 'abc'; +SELECT a FROM t1 WHERE a RLIKE '^x'; +SELECT a FROM t1 WHERE NOT EXISTS (SELECT 1 FROM t2 WHERE t2.a = t1.a); +SELECT a FROM t1 WHERE a IS TRUE AND b IS NOT FALSE AND c IS UNKNOWN; +SELECT a & b, a | b, a ^ b, a << 1, a >> 1 FROM t1; +SHOW CREATE VIEW v_active_users; +SHOW TABLE STATUS LIKE 'orders'; +SHOW ENGINE INNODB STATUS; +SHOW MASTER STATUS; +SHOW SLAVE STATUS; +SHOW WARNINGS; +SHOW ERRORS; +SHOW DATABASES; +SHOW FULL TABLES FROM shop WHERE Table_type = 'BASE TABLE'; +SHOW BINARY LOGS; +SHOW CREATE SEQUENCE seq_invoice; +SHOW COLLATION LIKE 'utf8mb4%'; +SHOW GLOBAL STATUS LIKE 'Questions'; +EXPLAIN EXTENDED SELECT * FROM t1; +EXPLAIN SELECT * FROM t1 WHERE a = 1; +EXPLAIN DELETE FROM t1 WHERE a = 1; +ANALYZE SELECT * FROM t1 WHERE a = 1; +ANALYZE FORMAT=JSON SELECT * FROM t1; diff --git a/testdata/realworld/mysql/01_app_crud.sql b/testdata/realworld/mysql/01_app_crud.sql new file mode 100644 index 00000000..5d67fb03 --- /dev/null +++ b/testdata/realworld/mysql/01_app_crud.sql @@ -0,0 +1,32 @@ +-- ORM-style application CRUD (Laravel/Eloquent, Django, Sequelize generated) +SELECT * FROM `users` WHERE `id` = 1 LIMIT 1; +SELECT `id`, `name`, `email` FROM `users` WHERE `email` = 'a@example.com' AND `deleted_at` IS NULL LIMIT 1; +SELECT * FROM `users` WHERE `id` IN (1, 2, 3, 4) ORDER BY `created_at` DESC; +SELECT * FROM `posts` WHERE `user_id` = 5 ORDER BY `created_at` DESC LIMIT 20 OFFSET 40; +SELECT * FROM `posts` ORDER BY `id` DESC LIMIT 40, 20; +SELECT COUNT(*) AS `aggregate` FROM `posts` WHERE `status` = 'published'; +SELECT EXISTS(SELECT * FROM `users` WHERE `email` = 'a@example.com') AS `exists`; +INSERT INTO `users` (`name`, `email`, `password`, `created_at`, `updated_at`) VALUES ('Ann', 'ann@example.com', 'hash', NOW(), NOW()); +INSERT INTO `users` (`name`, `email`) VALUES ('A', 'a@x.com'), ('B', 'b@x.com'), ('C', 'c@x.com'); +INSERT INTO `user_settings` (`user_id`, `k`, `v`) VALUES (1, 'theme', 'dark') ON DUPLICATE KEY UPDATE `v` = VALUES(`v`); +INSERT INTO `page_views` (`page_id`, `views`) VALUES (10, 1) ON DUPLICATE KEY UPDATE `views` = `views` + 1; +INSERT IGNORE INTO `tags` (`name`) VALUES ('go'), ('sql'); +REPLACE INTO `sessions` (`id`, `payload`, `last_activity`) VALUES ('abc', 'data', 1700000000); +INSERT INTO `archive_orders` SELECT * FROM `orders` WHERE `created_at` < '2023-01-01'; +INSERT INTO `t1` SET `a` = 1, `b` = 'x'; +UPDATE `users` SET `name` = 'Bob', `updated_at` = NOW() WHERE `id` = 7; +UPDATE `posts` SET `views` = `views` + 1 WHERE `id` = 99; +UPDATE `orders` SET `status` = 'shipped' WHERE `id` IN (1, 2, 3) AND `status` = 'paid' LIMIT 100; +UPDATE `products` p JOIN `categories` c ON c.id = p.category_id SET p.active = 0 WHERE c.archived = 1; +UPDATE orders o, customers c SET o.region = c.region WHERE o.customer_id = c.id; +DELETE FROM `sessions` WHERE `last_activity` < UNIX_TIMESTAMP() - 7200; +DELETE FROM `comments` WHERE `post_id` = 3 ORDER BY `id` LIMIT 500; +DELETE o FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL; +DELETE FROM a, b USING a INNER JOIN b ON a.id = b.a_id WHERE a.flag = 0; +SELECT `u`.*, `p`.`title` FROM `users` AS `u` INNER JOIN `posts` AS `p` ON `p`.`user_id` = `u`.`id` WHERE `u`.`id` = 1; +SELECT `id` FROM `users` WHERE `id` NOT IN (SELECT `user_id` FROM `bans`); +SELECT * FROM `users` WHERE `created_at` BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59'; +SELECT * FROM `users` WHERE `name` LIKE '%son' OR `name` NOT LIKE 'A%'; +SELECT `id` FROM `users` WHERE `id` > 100 FOR UPDATE; +SELECT * FROM `jobs` WHERE `queue` = 'default' AND `reserved_at` IS NULL ORDER BY `id` LIMIT 1 FOR UPDATE SKIP LOCKED; +SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; diff --git a/testdata/realworld/mysql/02_reporting.sql b/testdata/realworld/mysql/02_reporting.sql new file mode 100644 index 00000000..c1659b6e --- /dev/null +++ b/testdata/realworld/mysql/02_reporting.sql @@ -0,0 +1,36 @@ +-- Dashboard and reporting queries: aggregates, joins, subqueries +SELECT DATE(created_at) AS day, COUNT(*) AS orders, SUM(total) AS revenue FROM orders WHERE created_at >= NOW() - INTERVAL 30 DAY GROUP BY DATE(created_at) ORDER BY day; +SELECT YEAR(created_at) AS y, MONTH(created_at) AS m, SUM(total) FROM orders GROUP BY YEAR(created_at), MONTH(created_at) WITH ROLLUP; +SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, COUNT(DISTINCT customer_id) AS buyers FROM orders GROUP BY ym HAVING buyers > 10 ORDER BY ym DESC; +SELECT c.id, c.name, COUNT(o.id) AS n, COALESCE(SUM(o.total), 0) AS spent FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id, c.name ORDER BY spent DESC LIMIT 10; +SELECT product_id, SUM(qty) AS sold FROM order_items GROUP BY product_id ORDER BY sold DESC LIMIT 5; +SELECT category, GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') AS names FROM products GROUP BY category; +SELECT user_id, GROUP_CONCAT(DISTINCT role) FROM user_roles GROUP BY user_id; +SELECT * FROM orders WHERE total > (SELECT AVG(total) FROM orders); +SELECT p.name, (SELECT COUNT(*) FROM reviews r WHERE r.product_id = p.id) AS review_count FROM products p; +SELECT * FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.total > 1000); +SELECT * FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); +SELECT t.day, t.cnt FROM (SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM visits GROUP BY DATE(created_at)) AS t WHERE t.cnt > 100; +SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > 5 ORDER BY COUNT(*) DESC; +SELECT CASE WHEN total < 50 THEN 'small' WHEN total < 500 THEN 'medium' ELSE 'large' END AS bucket, COUNT(*) FROM orders GROUP BY bucket; +SELECT SUM(IF(status = 'paid', total, 0)) AS paid, SUM(IF(status = 'refunded', total, 0)) AS refunded FROM orders; +SELECT IFNULL(region, 'unknown') AS region, COUNT(*) FROM customers GROUP BY region; +SELECT id, total, ROUND(total * 1.2, 2) AS gross FROM orders WHERE total BETWEEN 10 AND 100; +SELECT a.id FROM a UNION SELECT b.id FROM b UNION ALL SELECT c.id FROM c; +(SELECT id, 'order' AS kind FROM orders ORDER BY id DESC LIMIT 5) UNION ALL (SELECT id, 'refund' FROM refunds ORDER BY id DESC LIMIT 5); +SELECT DISTINCT country FROM customers WHERE country IS NOT NULL ORDER BY country; +SELECT TIMESTAMPDIFF(DAY, created_at, shipped_at) AS days_to_ship FROM orders WHERE shipped_at IS NOT NULL; +SELECT DATEDIFF(NOW(), last_login) AS idle FROM users HAVING idle > 90; +SELECT * FROM events WHERE ts >= DATE_SUB(NOW(), INTERVAL 1 HOUR); +SELECT * FROM events WHERE ts > NOW() - INTERVAL 15 MINUTE; +SELECT DATE_ADD(CURDATE(), INTERVAL 1 MONTH) AS next_month; +SELECT o.id, c.name, p.name FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items i ON i.order_id = o.id JOIN products p ON p.id = i.product_id LEFT JOIN coupons cp ON cp.id = o.coupon_id WHERE o.status <> 'cancelled'; +SELECT a.x FROM a NATURAL JOIN b; +SELECT * FROM a JOIN b USING (id) JOIN c USING (id); +SELECT * FROM t1 LEFT JOIN (t2, t3) ON t1.id = t2.id AND t2.id = t3.id; +SELECT * FROM a CROSS JOIN b; +SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id; +SELECT STRAIGHT_JOIN * FROM a, b WHERE a.id = b.id; +SELECT SQL_CALC_FOUND_ROWS * FROM posts LIMIT 10; +SELECT FOUND_ROWS(); +SELECT * FROM users INTO OUTFILE '/tmp/users.csv'; diff --git a/testdata/realworld/mysql/03_ddl_migrations.sql b/testdata/realworld/mysql/03_ddl_migrations.sql new file mode 100644 index 00000000..3e12486e --- /dev/null +++ b/testdata/realworld/mysql/03_ddl_migrations.sql @@ -0,0 +1,45 @@ +-- DDL and schema migrations (InnoDB, utf8mb4, Laravel/Flyway style) +CREATE TABLE `users` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, `created_at` TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `users_email_unique` (`email`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS orders (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, total DECIMAL(10,2) NOT NULL DEFAULT 0.00, status ENUM('new','paid','shipped','cancelled') NOT NULL DEFAULT 'new', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_customer (customer_id), CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE ON UPDATE RESTRICT) ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8mb4; +CREATE TABLE user_prefs (user_id INT NOT NULL, flags SET('a','b','c') NOT NULL DEFAULT 'a', PRIMARY KEY (user_id)) ENGINE=InnoDB; +CREATE TABLE t_gen (a INT, b INT, c INT GENERATED ALWAYS AS (a + b) VIRTUAL, d INT AS (a * b) STORED); +CREATE TABLE docs (id INT PRIMARY KEY, body TEXT, FULLTEXT KEY ft_body (body)) ENGINE=InnoDB; +CREATE TABLE events (id BIGINT AUTO_INCREMENT, payload JSON NOT NULL, ts DATETIME(6) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB; +CREATE TABLE copy_of_users LIKE users; +CREATE TABLE top_orders AS SELECT * FROM orders WHERE total > 1000; +CREATE TEMPORARY TABLE tmp_ids (id INT PRIMARY KEY); +CREATE TABLE ints (a TINYINT UNSIGNED, b SMALLINT, c MEDIUMINT, d BIGINT UNSIGNED ZEROFILL, e FLOAT(7,3), f DOUBLE, g BIT(8), h YEAR, i TIME, j BLOB, k LONGTEXT, l VARBINARY(16)); +CREATE TABLE logs (id INT, msg VARCHAR(100)) PARTITION BY HASH(id) PARTITIONS 4; +ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL AFTER email; +ALTER TABLE users ADD COLUMN `locale` CHAR(5) NOT NULL DEFAULT 'en' FIRST; +ALTER TABLE users DROP COLUMN phone; +ALTER TABLE users MODIFY COLUMN name VARCHAR(500) NOT NULL; +ALTER TABLE users CHANGE COLUMN name full_name VARCHAR(255) NOT NULL; +ALTER TABLE users ADD INDEX idx_name (name); +ALTER TABLE users ADD UNIQUE INDEX uq_email (email); +ALTER TABLE users DROP INDEX idx_name; +ALTER TABLE orders ADD CONSTRAINT fk_cust FOREIGN KEY (customer_id) REFERENCES customers (id); +ALTER TABLE orders DROP FOREIGN KEY fk_cust; +ALTER TABLE users ENGINE=InnoDB; +ALTER TABLE users AUTO_INCREMENT = 5000; +ALTER TABLE users ADD COLUMN a INT, ADD COLUMN b INT, DROP COLUMN c; +ALTER TABLE users RENAME TO members; +ALTER TABLE t ALTER COLUMN x SET DEFAULT 5; +ALTER TABLE t ADD FULLTEXT INDEX ft_title (title); +ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +CREATE INDEX idx_created ON orders (created_at); +CREATE UNIQUE INDEX uq_sku ON products (sku); +CREATE INDEX idx_prefix ON users (email(10)); +CREATE INDEX idx_desc ON events (ts DESC, id ASC); +CREATE FULLTEXT INDEX ft_all ON docs (title, body); +DROP INDEX idx_created ON orders; +DROP TABLE IF EXISTS tmp_a, tmp_b; +DROP TABLE `old_users`; +TRUNCATE TABLE sessions; +RENAME TABLE old_name TO new_name; +RENAME TABLE a TO b, c TO d; +CREATE VIEW active_users AS SELECT id, name FROM users WHERE deleted_at IS NULL; +CREATE OR REPLACE VIEW v_totals AS SELECT customer_id, SUM(total) AS t FROM orders GROUP BY customer_id; +DROP VIEW IF EXISTS active_users; +CREATE DATABASE IF NOT EXISTS app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +DROP DATABASE IF EXISTS scratch; diff --git a/testdata/realworld/mysql/04_window_analytics.sql b/testdata/realworld/mysql/04_window_analytics.sql new file mode 100644 index 00000000..4d9ae874 --- /dev/null +++ b/testdata/realworld/mysql/04_window_analytics.sql @@ -0,0 +1,23 @@ +-- Analytics with window functions and CTE chains (MySQL 8.0) +SELECT id, customer_id, total, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS rn FROM orders; +SELECT id, RANK() OVER (ORDER BY total DESC) AS r, DENSE_RANK() OVER (ORDER BY total DESC) AS dr FROM orders; +SELECT id, SUM(total) OVER (PARTITION BY customer_id ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running FROM orders; +SELECT day, revenue, AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM daily_revenue; +SELECT id, LAG(total, 1, 0) OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_total, LEAD(total) OVER (PARTITION BY customer_id ORDER BY created_at) AS next_total FROM orders; +SELECT id, NTILE(4) OVER (ORDER BY total) AS quartile, PERCENT_RANK() OVER (ORDER BY total) AS pr, CUME_DIST() OVER (ORDER BY total) AS cd FROM orders; +SELECT id, FIRST_VALUE(total) OVER w AS f, LAST_VALUE(total) OVER (w ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS l, NTH_VALUE(total, 2) OVER w AS n FROM orders WINDOW w AS (PARTITION BY customer_id ORDER BY created_at); +SELECT id, SUM(total) OVER (ORDER BY created_at RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW) FROM orders; +WITH recent AS (SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL 7 DAY) SELECT customer_id, COUNT(*) FROM recent GROUP BY customer_id; +WITH a AS (SELECT 1 AS x), b AS (SELECT x + 1 AS y FROM a), c AS (SELECT y * 2 AS z FROM b) SELECT * FROM c; +WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 10) SELECT n FROM seq; +WITH RECURSIVE tree AS (SELECT id, parent_id, name, 0 AS depth FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id, c.name, t.depth + 1 FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree ORDER BY depth, name; +WITH ranked AS (SELECT p.*, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales DESC) AS rn FROM products p) SELECT * FROM ranked WHERE rn <= 3; +WITH monthly AS (SELECT DATE_FORMAT(created_at, '%Y-%m-01') AS m, SUM(total) AS rev FROM orders GROUP BY 1) SELECT m, rev, rev - LAG(rev) OVER (ORDER BY m) AS delta FROM monthly; +WITH t AS (SELECT * FROM a) UPDATE b JOIN t ON b.id = t.id SET b.v = t.v; +WITH dup AS (SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM users) DELETE FROM users WHERE id IN (SELECT id FROM dup WHERE rn > 1); +SELECT * FROM (SELECT customer_id, total, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rn FROM orders) x WHERE x.rn = 1; +SELECT customer_id, SUM(total) AS s, SUM(SUM(total)) OVER () AS grand, ROUND(100 * SUM(total) / SUM(SUM(total)) OVER (), 2) AS pct FROM orders GROUP BY customer_id; +SELECT COUNT(*) OVER () AS total_rows, id FROM users LIMIT 10; +SELECT * FROM orders o JOIN LATERAL (SELECT * FROM order_items i WHERE i.order_id = o.id ORDER BY qty DESC LIMIT 1) li ON TRUE; +SELECT a.n FROM (VALUES ROW(1), ROW(2), ROW(3)) AS a (n); +TABLE users ORDER BY id LIMIT 5; diff --git a/testdata/realworld/mysql/05_dialect_specific.sql b/testdata/realworld/mysql/05_dialect_specific.sql new file mode 100644 index 00000000..d49c982c --- /dev/null +++ b/testdata/realworld/mysql/05_dialect_specific.sql @@ -0,0 +1,74 @@ +-- MySQL-specific syntax: JSON, FULLTEXT, index hints, variables, SHOW, etc. +SELECT JSON_EXTRACT(payload, '$.user.id') AS uid FROM events; +SELECT payload->'$.name' AS n, payload->>'$.email' AS e FROM events; +SELECT JSON_UNQUOTE(JSON_EXTRACT(payload, '$.name')) FROM events WHERE JSON_CONTAINS(payload, '"x"', '$.tags'); +SELECT JSON_OBJECT('id', id, 'name', name) FROM users; +SELECT JSON_ARRAYAGG(name) FROM users; +SELECT JSON_OBJECTAGG(k, v) FROM user_settings GROUP BY user_id; +SELECT * FROM events WHERE payload->>'$.type' = 'click' AND payload->'$.n' > 3; +UPDATE events SET payload = JSON_SET(payload, '$.seen', TRUE) WHERE id = 1; +SELECT jt.* FROM events, JSON_TABLE(payload, '$.items[*]' COLUMNS (id INT PATH '$.id', name VARCHAR(50) PATH '$.name')) AS jt; +SELECT * FROM docs WHERE MATCH(title, body) AGAINST ('database' IN NATURAL LANGUAGE MODE); +SELECT *, MATCH(body) AGAINST ('+mysql -oracle' IN BOOLEAN MODE) AS score FROM docs WHERE MATCH(body) AGAINST ('+mysql -oracle' IN BOOLEAN MODE); +SELECT * FROM docs WHERE MATCH(body) AGAINST ('query expansion' WITH QUERY EXPANSION); +SELECT * FROM users USE INDEX (idx_name) WHERE name = 'x'; +SELECT * FROM users FORCE INDEX (PRIMARY) WHERE id > 5; +SELECT * FROM users IGNORE INDEX (idx_a, idx_b) WHERE a = 1; +SELECT * FROM users USE INDEX FOR ORDER BY (idx_created) ORDER BY created_at; +SELECT /*+ MAX_EXECUTION_TIME(1000) */ * FROM big_table; +SELECT /*! SQL_NO_CACHE */ * FROM t; +SELECT @rownum := @rownum + 1 AS rn, t.* FROM t, (SELECT @rownum := 0) r; +SET @a = 1, @b := 2; +SET @total = (SELECT COUNT(*) FROM users); +SET NAMES utf8mb4; +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; +SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'; +SET GLOBAL max_connections = 500; +SET autocommit = 0; +SET FOREIGN_KEY_CHECKS = 0; +SET TRANSACTION ISOLATION LEVEL READ COMMITTED; +SELECT @@version, @@session.sql_mode, @@global.max_connections; +SHOW TABLES; +SHOW FULL TABLES FROM app LIKE 'user%'; +SHOW COLUMNS FROM users; +SHOW CREATE TABLE users; +SHOW INDEX FROM users; +SHOW DATABASES; +SHOW VARIABLES LIKE 'max_%'; +SHOW STATUS LIKE 'Threads%'; +SHOW PROCESSLIST; +SHOW FULL PROCESSLIST; +SHOW ENGINE INNODB STATUS; +SHOW WARNINGS; +SHOW TABLE STATUS LIKE 'users'; +DESCRIBE users; +DESC users; +EXPLAIN SELECT * FROM users WHERE id = 1; +EXPLAIN FORMAT=JSON SELECT * FROM users; +EXPLAIN ANALYZE SELECT * FROM users; +USE app; +START TRANSACTION; +BEGIN; +COMMIT; +ROLLBACK; +SAVEPOINT sp1; +ROLLBACK TO SAVEPOINT sp1; +RELEASE SAVEPOINT sp1; +LOCK TABLES users WRITE, orders READ; +UNLOCK TABLES; +ANALYZE TABLE users; +OPTIMIZE TABLE users; +CHECK TABLE users; +KILL 123; +FLUSH PRIVILEGES; +LOAD DATA INFILE '/tmp/data.csv' INTO TABLE users FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (name, email); +SELECT CONVERT(x USING utf8mb4), CAST(y AS CHAR CHARACTER SET utf8mb4), CAST(z AS UNSIGNED), CAST(d AS DECIMAL(10,2)), CAST(s AS JSON) FROM t; +SELECT x COLLATE utf8mb4_bin FROM t WHERE name = _utf8mb4'abc' COLLATE utf8mb4_unicode_ci; +SELECT * FROM t WHERE a <=> NULL AND b REGEXP '^[a-z]+$' AND c RLIKE 'x' AND d DIV 3 = 1 AND e MOD 2 = 0 AND f XOR g; +SELECT 1 FROM DUAL; +SELECT 'it''s', "dq", 0x1F, X'4D', b'101', 1e3, .5; +SELECT BINARY name FROM t; +SELECT INTERVAL(5, 1, 10, 20); +SELECT CONCAT_WS(',', a, b), SUBSTRING_INDEX(email, '@', -1), FIND_IN_SET('a', 'a,b'), UUID(), RAND(), LAST_INSERT_ID(); +SELECT EXTRACT(YEAR FROM created_at), EXTRACT(YEAR_MONTH FROM created_at) FROM t; +SELECT STR_TO_DATE('01/02/2024', '%d/%m/%Y'), FROM_UNIXTIME(1700000000), UNIX_TIMESTAMP('2024-01-01'); diff --git a/testdata/realworld/mysql/06_wordpress.sql b/testdata/realworld/mysql/06_wordpress.sql new file mode 100644 index 00000000..80677c19 --- /dev/null +++ b/testdata/realworld/mysql/06_wordpress.sql @@ -0,0 +1,22 @@ +-- WordPress core / WooCommerce style queries +SELECT option_value FROM wp_options WHERE option_name = 'siteurl' LIMIT 1; +SELECT autoload, option_name, option_value FROM wp_options WHERE autoload = 'yes'; +INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('transient_x', 'v', 'no') ON DUPLICATE KEY UPDATE option_name = VALUES(option_name), option_value = VALUES(option_value), autoload = VALUES(autoload); +SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'post' AND ((wp_posts.post_status = 'publish')) ORDER BY wp_posts.post_date DESC LIMIT 0, 10; +SELECT wp_posts.* FROM wp_posts WHERE ID IN (1, 2, 3); +SELECT p.ID FROM wp_posts p INNER JOIN wp_term_relationships tr ON (p.ID = tr.object_id) INNER JOIN wp_term_taxonomy tt ON (tr.term_taxonomy_id = tt.term_taxonomy_id) WHERE tt.taxonomy = 'category' AND tt.term_id IN (4) GROUP BY p.ID ORDER BY p.post_date DESC; +SELECT post_id, meta_key, meta_value FROM wp_postmeta WHERE post_id IN (10, 11, 12) ORDER BY meta_id ASC; +SELECT wp_posts.ID FROM wp_posts INNER JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id) INNER JOIN wp_postmeta AS mt1 ON (wp_posts.ID = mt1.post_id) WHERE 1=1 AND (wp_postmeta.meta_key = '_price' AND CAST(wp_postmeta.meta_value AS SIGNED) > 10) AND (mt1.meta_key = '_stock' AND mt1.meta_value != '0') GROUP BY wp_posts.ID; +SELECT * FROM wp_users WHERE user_login = 'admin' OR user_email = 'a@b.c' LIMIT 1; +SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE user_id = 1; +UPDATE wp_posts SET post_content = REPLACE(post_content, 'http://old.com', 'https://new.com'); +UPDATE wp_postmeta SET meta_value = REPLACE(meta_value, 'old', 'new') WHERE meta_key = '_x'; +DELETE FROM wp_options WHERE option_name LIKE '\_transient\_%'; +DELETE a, b FROM wp_posts a LEFT JOIN wp_postmeta b ON b.post_id = a.ID WHERE a.post_type = 'revision'; +SELECT COUNT(*) FROM wp_comments WHERE comment_approved = '1' AND comment_post_ID = 5; +SELECT comment_approved, COUNT(*) AS num_comments FROM wp_comments GROUP BY comment_approved; +SELECT t.*, tt.* FROM wp_terms AS t INNER JOIN wp_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE tt.taxonomy IN ('category') ORDER BY t.name ASC; +SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month, count(ID) as posts FROM wp_posts WHERE post_type = 'post' AND post_status = 'publish' GROUP BY YEAR(post_date), MONTH(post_date) ORDER BY post_date DESC; +SELECT order_id, SUM(meta_value) FROM wp_woocommerce_order_itemmeta m INNER JOIN wp_woocommerce_order_items i ON m.order_item_id = i.order_item_id WHERE meta_key = '_line_total' GROUP BY order_id; +SHOW TABLES LIKE 'wp_options'; +CREATE TABLE wp_example (id bigint(20) unsigned NOT NULL AUTO_INCREMENT, name varchar(191) NOT NULL DEFAULT '', data longtext, PRIMARY KEY (id), KEY name (name(191))) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci; diff --git a/testdata/realworld/mysql/07_laravel_magento.sql b/testdata/realworld/mysql/07_laravel_magento.sql new file mode 100644 index 00000000..8ba09075 --- /dev/null +++ b/testdata/realworld/mysql/07_laravel_magento.sql @@ -0,0 +1,17 @@ +-- Laravel Eloquent / queue / Magento EAV style +select * from `jobs` where `queue` = 'default' and ((`reserved_at` is null and `available_at` <= 1700000000) or (`reserved_at` <= 1699999000)) order by `id` asc limit 1 for update; +select `users`.*, (select count(*) from `posts` where `users`.`id` = `posts`.`user_id`) as `posts_count` from `users` where exists (select * from `roles` inner join `role_user` on `roles`.`id` = `role_user`.`role_id` where `users`.`id` = `role_user`.`user_id` and `name` = 'admin'); +select * from `posts` where `posts`.`user_id` in (1, 2, 3) and `posts`.`deleted_at` is null; +select `roles`.*, `role_user`.`user_id` as `pivot_user_id` from `roles` inner join `role_user` on `roles`.`id` = `role_user`.`role_id` where `role_user`.`user_id` in (1, 2); +select * from `users` where date(`created_at`) = '2024-01-01' and year(`created_at`) = 2024 and json_unquote(json_extract(`options`, '$."lang"')) = 'en'; +select max(`batch`) as `aggregate` from `migrations`; +insert into `migrations` (`migration`, `batch`) values ('2024_01_01_000000_create_users_table', 1); +create table `password_resets` (`email` varchar(255) not null, `token` varchar(255) not null, `created_at` timestamp null) default character set utf8mb4 collate 'utf8mb4_unicode_ci'; +alter table `password_resets` add index `password_resets_email_index`(`email`); +update `users` set `remember_token` = 'tok', `updated_at` = '2024-01-01 00:00:00' where `id` = 1; +select * from `cache` where `key` in ('a', 'b'); +select SQL_NO_CACHE e.entity_id, e.sku, v.value AS name FROM catalog_product_entity e INNER JOIN catalog_product_entity_varchar v ON v.entity_id = e.entity_id AND v.store_id = 0 AND v.attribute_id = 73; +SELECT main_table.*, COUNT(items.item_id) AS items_count FROM sales_order AS main_table LEFT JOIN sales_order_item AS items ON items.order_id = main_table.entity_id WHERE (main_table.state IN ('new', 'processing')) GROUP BY main_table.entity_id ORDER BY main_table.created_at DESC LIMIT 20; +SELECT e.*, IF(at_status.value_id > 0, at_status.value, at_status_default.value) AS status FROM catalog_product_entity AS e LEFT JOIN catalog_product_entity_int AS at_status_default ON (at_status_default.entity_id = e.entity_id) AND at_status_default.attribute_id = 97 AND at_status_default.store_id = 0 LEFT JOIN catalog_product_entity_int AS at_status ON (at_status.entity_id = e.entity_id) AND at_status.attribute_id = 97 AND at_status.store_id = 1; +INSERT INTO quote_item (quote_id, product_id, qty) VALUES (1, 2, 3) ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty), updated_at = NOW(); +INSERT INTO new_alias (id, v) VALUES (1, 2) AS new ON DUPLICATE KEY UPDATE v = new.v; diff --git a/testdata/realworld/mysql/08_etl_admin.sql b/testdata/realworld/mysql/08_etl_admin.sql new file mode 100644 index 00000000..27a8844b --- /dev/null +++ b/testdata/realworld/mysql/08_etl_admin.sql @@ -0,0 +1,38 @@ +-- ETL, data maintenance and admin queries +INSERT INTO dim_customer (customer_id, name, region) SELECT c.id, c.name, r.name FROM staging_customers c JOIN regions r ON r.id = c.region_id ON DUPLICATE KEY UPDATE name = VALUES(name), region = VALUES(region); +INSERT INTO daily_stats (day, orders, revenue) SELECT DATE(created_at), COUNT(*), SUM(total) FROM orders WHERE created_at >= CURDATE() - INTERVAL 1 DAY GROUP BY DATE(created_at) ON DUPLICATE KEY UPDATE orders = VALUES(orders), revenue = VALUES(revenue); +UPDATE target t INNER JOIN source s ON s.id = t.id SET t.a = s.a, t.b = s.b WHERE t.a <> s.a OR t.b <> s.b; +UPDATE users u SET u.order_count = (SELECT COUNT(*) FROM orders o WHERE o.customer_id = u.id); +UPDATE orders SET total = total * 1.1 WHERE status = 'new' ORDER BY id LIMIT 1000; +UPDATE t SET a = b, b = a; +UPDATE IGNORE t SET id = id + 1000; +UPDATE LOW_PRIORITY t SET x = 1; +DELETE LOW_PRIORITY QUICK IGNORE FROM logs WHERE ts < NOW() - INTERVAL 90 DAY; +DELETE FROM t1 USING t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.id IS NULL; +INSERT LOW_PRIORITY INTO t (a) VALUES (1); +REPLACE INTO t (id, v) SELECT id, v FROM staging; +REPLACE t SET id = 1, v = 'x'; +SELECT table_schema, table_name, ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY size_mb DESC LIMIT 20; +SELECT user, host FROM mysql.user; +SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'app' AND TABLE_NAME = 'users' ORDER BY ORDINAL_POSITION; +SELECT * FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 10; +SELECT * FROM sys.schema_unused_indexes; +SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command <> 'Sleep' ORDER BY time DESC; +GRANT SELECT, INSERT, UPDATE ON app.* TO 'app'@'%'; +GRANT ALL PRIVILEGES ON *.* TO 'admin'@'localhost' WITH GRANT OPTION; +REVOKE INSERT ON app.* FROM 'app'@'%'; +CREATE USER 'app'@'%' IDENTIFIED BY 'secret'; +CREATE USER IF NOT EXISTS 'ro'@'10.0.0.%' IDENTIFIED BY 'x'; +DROP USER IF EXISTS 'old'@'%'; +ALTER USER 'app'@'%' IDENTIFIED BY 'new'; +CREATE ROLE 'reporting'; +CALL refresh_stats(2024, 'full'); +CALL sp_noargs(); +PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'; +EXECUTE stmt USING @id; +DEALLOCATE PREPARE stmt; +SELECT * FROM users WHERE id = ? AND status = ?; +INSERT INTO t (a, b) VALUES (?, ?), (?, ?); +CREATE TRIGGER trg_bi BEFORE INSERT ON users FOR EACH ROW SET NEW.created_at = NOW(); +CREATE EVENT ev_purge ON SCHEDULE EVERY 1 DAY DO DELETE FROM sessions WHERE expires < NOW(); +CREATE FUNCTION add1(x INT) RETURNS INT DETERMINISTIC RETURN x + 1; diff --git a/testdata/realworld/mysql/09_deep_nesting.sql b/testdata/realworld/mysql/09_deep_nesting.sql new file mode 100644 index 00000000..214aaf0d --- /dev/null +++ b/testdata/realworld/mysql/09_deep_nesting.sql @@ -0,0 +1,35 @@ +-- Deeply nested, many-join, long CTE and expression-heavy queries +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT id FROM users) a) b) c) d; +SELECT ((((1 + 2) * 3) - 4) / 5) AS v, -(-1), NOT NOT TRUE, !0, ~5, 1 + 2 * 3 - 4 % 3; +SELECT a FROM t WHERE (a = 1 OR b = 2) AND (c = 3 OR (d = 4 AND (e = 5 OR f = 6))); +SELECT a FROM t WHERE a = 1 OR b = 2 AND c = 3; +SELECT a FROM t WHERE NOT a = 1 AND b BETWEEN 1 AND 5 OR c IN (1, 2) AND d IS NOT NULL; +SELECT 1 << 2, 8 >> 1, 5 & 3, 5 | 3, 5 ^ 3, 'a' || 'b', 'a' && 'b'; +SELECT IF(a > 1, IF(b > 2, 'x', 'y'), CASE WHEN c THEN 'z' ELSE NULL END) FROM t; +SELECT CASE status WHEN 'a' THEN 1 WHEN 'b' THEN 2 ELSE 3 END FROM t; +SELECT COALESCE(NULLIF(a, ''), b, c, 'default') FROM t; +SELECT * FROM t WHERE (a, b) IN ((1, 2), (3, 4)); +SELECT * FROM t WHERE (a, b) = (SELECT x, y FROM u LIMIT 1); +SELECT * FROM t WHERE a = ANY (SELECT x FROM u) AND b > ALL (SELECT y FROM v) AND c = SOME (SELECT z FROM w); +SELECT * FROM t WHERE a > (SELECT MAX(x) FROM u WHERE u.k = t.k AND u.d > (SELECT MIN(d) FROM v WHERE v.k = u.k)); +SELECT a.id, b.id, c.id, d.id, e.id, f.id FROM a JOIN b ON b.a_id = a.id JOIN c ON c.b_id = b.id LEFT JOIN d ON d.c_id = c.id LEFT JOIN e ON e.d_id = d.id RIGHT JOIN f ON f.e_id = e.id WHERE a.x = 1; +SELECT * FROM a LEFT OUTER JOIN b ON a.id = b.id RIGHT OUTER JOIN c ON b.id = c.id INNER JOIN d ON c.id = d.id; +WITH c1 AS (SELECT id, v FROM t1), c2 AS (SELECT id, v FROM t2 WHERE id IN (SELECT id FROM c1)), c3 AS (SELECT c1.id, c1.v + c2.v AS s FROM c1 JOIN c2 USING (id)), c4 AS (SELECT id, s, SUM(s) OVER (ORDER BY id) AS run FROM c3), c5 AS (SELECT * FROM c4 WHERE run > 10) SELECT * FROM c5 ORDER BY id LIMIT 10; +WITH cte (a, b) AS (SELECT 1, 2 UNION ALL SELECT 3, 4) SELECT * FROM cte; +SELECT t.* FROM t WHERE t.id IN (SELECT id FROM (SELECT id FROM u ORDER BY id DESC LIMIT 5) AS x); +SELECT COUNT(*) FROM (SELECT DISTINCT a, b FROM t) AS d; +(SELECT 1) UNION (SELECT 2) ORDER BY 1 LIMIT 1; +SELECT a FROM t1 INTERSECT SELECT a FROM t2; +SELECT a FROM t1 EXCEPT SELECT a FROM t2; +SELECT a, b, COUNT(*) FROM t GROUP BY a, b WITH ROLLUP HAVING COUNT(*) > 1 ORDER BY a DESC, b ASC LIMIT 10 OFFSET 5; +SELECT a FROM t ORDER BY FIELD(a, 'x', 'y', 'z'), RAND() LIMIT 3; +SELECT a FROM t GROUP BY a ORDER BY NULL; +SELECT SUM(DISTINCT a), COUNT(DISTINCT a, b), AVG(a), MIN(a), MAX(a), STDDEV(a), VARIANCE(a), BIT_OR(a), GROUP_CONCAT(a SEPARATOR '|') FROM t; +SELECT x FROM t WHERE x REGEXP BINARY 'A' AND y SOUNDS LIKE 'z' AND w LIKE 'a\_%' ESCAPE '\\'; +SELECT a AS 'alias with space', b "other alias" FROM t; +SELECT t.a, t2.* FROM db1.t JOIN db2.t2 ON t.id = t2.id; +SELECT `db`.`t`.`c` FROM `db`.`t`; +SELECT * FROM t PARTITION (p0, p1); +SELECT * FROM t1 JOIN t2 ON t1.a = t2.a AND t1.b <=> t2.b WHERE t1.c LIKE CONCAT('%', t2.d, '%'); +SELECT * FROM t FOR SHARE; +SELECT * FROM t FOR UPDATE NOWAIT; diff --git a/testdata/realworld/mysql/10_misc_functions.sql b/testdata/realworld/mysql/10_misc_functions.sql new file mode 100644 index 00000000..6e940952 --- /dev/null +++ b/testdata/realworld/mysql/10_misc_functions.sql @@ -0,0 +1,31 @@ +-- String, numeric, date and misc function calls plus assorted statements +SELECT UPPER(name), LOWER(name), TRIM(name), LTRIM(name), RTRIM(name), LENGTH(name), CHAR_LENGTH(name), REVERSE(name), REPEAT('ab', 3), LPAD(id, 8, '0'), RPAD(name, 10, '.') FROM t; +SELECT TRIM(BOTH 'x' FROM name), TRIM(LEADING '0' FROM code), TRIM(TRAILING FROM s) FROM t; +SELECT SUBSTRING(name, 2, 3), SUBSTRING(name FROM 2 FOR 3), LEFT(name, 2), RIGHT(name, 2), LOCATE('a', name), INSTR(name, 'a'), REPLACE(name, 'a', 'b') FROM t; +SELECT ABS(x), CEIL(x), FLOOR(x), ROUND(x, 2), TRUNCATE(x, 1), POW(x, 2), SQRT(x), MOD(x, 3), GREATEST(a, b, c), LEAST(a, b, c), SIGN(x) FROM t; +SELECT NOW(), CURDATE(), CURTIME(), CURRENT_TIMESTAMP, CURRENT_DATE, UTC_TIMESTAMP(), SYSDATE(), LOCALTIME; +SELECT CURRENT_TIMESTAMP(6), NOW(3); +SELECT DAYOFWEEK(d), DAYNAME(d), MONTHNAME(d), WEEK(d), WEEKDAY(d), QUARTER(d), LAST_DAY(d), HOUR(t), MINUTE(t), SECOND(t) FROM t; +SELECT DATE_FORMAT(NOW(), '%W, %M %e, %Y %H:%i:%s'), TIME_FORMAT(t, '%h:%i %p'); +SELECT DATE_ADD(d, INTERVAL 1 DAY), DATE_SUB(d, INTERVAL 2 WEEK), d + INTERVAL 3 HOUR, d - INTERVAL '1:30' HOUR_MINUTE, ADDDATE(d, 7), SUBDATE(d, INTERVAL 1 YEAR) FROM t; +SELECT TIMESTAMPADD(MINUTE, 30, ts), TIMESTAMPDIFF(SECOND, a, b), PERIOD_DIFF(202401, 202312), SEC_TO_TIME(3600), TIME_TO_SEC('01:00:00') FROM t; +SELECT MD5(x), SHA1(x), SHA2(x, 256), HEX(x), UNHEX(h), TO_BASE64(x), FROM_BASE64(y), CRC32(x), AES_ENCRYPT(x, 'k'), INET_ATON(ip), INET_NTOA(n) FROM t; +SELECT IFNULL(a, 0), NULLIF(a, 0), ISNULL(a), IF(a, 1, 2), COALESCE(a, b) FROM t; +SELECT DATABASE(), USER(), CURRENT_USER(), VERSION(), CONNECTION_ID(), ROW_COUNT(), SCHEMA(); +SELECT BENCHMARK(1000, MD5('x')), SLEEP(0); +SELECT JSON_LENGTH(j), JSON_TYPE(j), JSON_VALID(j), JSON_KEYS(j), JSON_SEARCH(j, 'one', 'x'), JSON_MERGE_PATCH(a, b), JSON_REMOVE(j, '$.a'), JSON_ARRAY(1, 2, 3), JSON_INSERT(j, '$.b', 1) FROM t; +SELECT j->'$.a[0]', j->>'$.b.c', j -> '$**.id' FROM t; +SELECT ST_AsText(ST_GeomFromText('POINT(1 1)')), ST_Distance_Sphere(p1, p2) FROM places; +SELECT a FROM t WHERE MATCH(a) AGAINST ('x'); +SELECT id FROM t WHERE ts > '2024-01-01' - INTERVAL 1 DAY; +SELECT CAST('2024-01-01' AS DATE), CAST(1 AS SIGNED INTEGER), CAST(x AS CHAR(10)), CAST(x AS DATETIME), CAST(x AS BINARY), CAST(x AS DOUBLE); +SELECT CONVERT(x, SIGNED), CONVERT(x, CHAR); +SELECT DEFAULT(col) FROM t; +SELECT ROW(1, 2) = ROW(1, 2); +SELECT 'a' 'b'; +SELECT 1 AS `select`, 2 AS `from`; +SELECT `order`, `group`, `key` FROM `index`; +SELECT TRUE, FALSE, NULL; +SELECT x IS TRUE, x IS NOT FALSE, x IS UNKNOWN FROM t; +SELECT NOW() - INTERVAL 1 DAY, NOW() + INTERVAL '1' DAY; +SELECT 1; SELECT 2; diff --git a/testdata/realworld/mysql/11_stored_programs.sql b/testdata/realworld/mysql/11_stored_programs.sql new file mode 100644 index 00000000..bcedec46 --- /dev/null +++ b/testdata/realworld/mysql/11_stored_programs.sql @@ -0,0 +1,37 @@ +-- Short stored-program and DML variants without DELIMITER +CREATE TABLE audit_log (id INT AUTO_INCREMENT PRIMARY KEY, tbl VARCHAR(64), action ENUM('I','U','D'), at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE child (id INT PRIMARY KEY, pid INT, CONSTRAINT fk_p FOREIGN KEY (pid) REFERENCES parent(id) ON DELETE SET NULL); +CREATE TABLE ck (a INT, CONSTRAINT chk_a CHECK (a > 0), b INT CHECK (b < 100)); +CREATE TABLE idx_t (a INT, b INT, c VARCHAR(100), INDEX ix_ab (a, b), INDEX ix_c (c(20)), UNIQUE KEY uq (a, c)); +CREATE TABLE def_expr (id INT PRIMARY KEY, uuid CHAR(36) DEFAULT (UUID()), created DATETIME DEFAULT NOW()); +CREATE TABLE inv (id INT, v INT INVISIBLE, KEY ((v + 1))); +CREATE TABLE cmt (id INT COMMENT 'the id', name VARCHAR(10) COMMENT 'nm') COMMENT='a table'; +CREATE TABLE rowfmt (id INT) ENGINE=InnoDB ROW_FORMAT=DYNAMIC KEY_BLOCK_SIZE=8; +CREATE TABLE big (id INT) ENGINE=MyISAM DEFAULT CHARSET=latin1; +CREATE TABLE `select` (`from` INT, `where` INT); +CREATE TABLE ts (a TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)); +CREATE TABLE sp (id INT, g GEOMETRY NOT NULL SRID 4326, SPATIAL INDEX(g)); +CREATE TABLE ch (a CHAR(3) CHARACTER SET ascii COLLATE ascii_bin, b TEXT CHARSET utf8mb4); +CREATE TABLE ai (id INT UNSIGNED NOT NULL AUTO_INCREMENT, PRIMARY KEY (id)) AUTO_INCREMENT=100; +CREATE TABLE decs (a DECIMAL, b DECIMAL(5), c NUMERIC(8,2), d DEC(4,1), e FIXED(3,1), f REAL, g DOUBLE PRECISION, h BOOL, i BOOLEAN, j INTEGER, k INT(11)); +ALTER TABLE t ADD PRIMARY KEY (id); +ALTER TABLE t DROP PRIMARY KEY; +ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0 COMMENT 'x', ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE t ADD PARTITION (PARTITION p3 VALUES LESS THAN (2030)); +ALTER TABLE t RENAME COLUMN a TO b; +ALTER TABLE t RENAME INDEX i1 TO i2; +ALTER TABLE t DROP CHECK chk_a; +ALTER TABLE t ADD CONSTRAINT uq UNIQUE (a, b); +ALTER TABLE t DISABLE KEYS; +ALTER TABLE t COMMENT = 'new comment'; +ALTER TABLE t DEFAULT CHARSET=utf8mb4; +ALTER TABLE t MODIFY a INT NOT NULL AFTER b; +ALTER DATABASE app CHARACTER SET utf8mb4; +DROP INDEX `PRIMARY` ON t; +DROP TRIGGER IF EXISTS trg_bi; +DROP PROCEDURE IF EXISTS sp_x; +DROP FUNCTION IF EXISTS f_x; +DROP EVENT IF EXISTS ev_purge; +CREATE TABLE IF NOT EXISTS x LIKE y; +CREATE SCHEMA IF NOT EXISTS s; +XA START 'a'; diff --git a/testdata/realworld/mysql/12_orm_misc.sql b/testdata/realworld/mysql/12_orm_misc.sql new file mode 100644 index 00000000..1f5be9da --- /dev/null +++ b/testdata/realworld/mysql/12_orm_misc.sql @@ -0,0 +1,27 @@ +-- SQLAlchemy / Django / Hibernate / Rails generated queries +SELECT `auth_user`.`id`, `auth_user`.`username` FROM `auth_user` WHERE `auth_user`.`is_active` = true ORDER BY `auth_user`.`date_joined` DESC LIMIT 21; +SELECT COUNT(*) AS `__count` FROM `django_session` WHERE `django_session`.`expire_date` < '2024-01-01 00:00:00'; +SELECT `django_content_type`.`id` FROM `django_content_type` WHERE (`django_content_type`.`app_label` = 'auth' AND `django_content_type`.`model` = 'user') LIMIT 21; +SELECT this_.id AS id1_0_0_, this_.name AS name2_0_0_ FROM employee this_ WHERE this_.id = ?; +SELECT e.id, e.name FROM employee e WHERE e.dept_id = ? ORDER BY e.name LIMIT ?, ?; +SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 /* loading for inspect */; +SELECT 1 AS one FROM `users` WHERE `users`.`email` = BINARY 'a@b.c' LIMIT 1; +INSERT INTO `users` (`email`, `created_at`) VALUES ('a@b.c', '2024-01-01 00:00:00.123456'); +UPDATE `users` SET `login_count` = COALESCE(`login_count`, 0) + 1 WHERE `users`.`id` = 1; +SELECT tbl.col FROM tbl -- trailing comment +WHERE tbl.id = 1; +/* leading block comment */ SELECT 1; +SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME NOT IN ('mysql', 'sys'); +SELECT @@SESSION.transaction_isolation, @@max_allowed_packet, @@lower_case_table_names, @@autocommit; +SELECT VERSION() AS v; +SELECT 1 + 1 AS two; +SELECT CONNECTION_ID(); +SHOW VARIABLES LIKE 'sql_mode'; +SHOW SESSION VARIABLES LIKE 'tx_isolation'; +SHOW COLLATION; +SHOW CHARACTER SET; +SHOW GRANTS FOR 'app'@'%'; +SHOW CREATE VIEW v; +SHOW FULL COLUMNS FROM users; +SHOW MASTER STATUS; +SHOW SLAVE STATUS; diff --git a/testdata/realworld/oracle/01_app_crud.sql b/testdata/realworld/oracle/01_app_crud.sql new file mode 100644 index 00000000..8bb460d4 --- /dev/null +++ b/testdata/realworld/oracle/01_app_crud.sql @@ -0,0 +1,39 @@ +-- ORM-style application CRUD for an order management schema (Oracle 19c) +SELECT * FROM customers WHERE customer_id = :id; +SELECT c.customer_id, c.first_name, c.last_name, c.email FROM customers c WHERE c.status = 'ACTIVE' AND c.created_at > SYSDATE - 30 ORDER BY c.created_at DESC; +SELECT "ID", "NAME", "CreatedAt" FROM "AppUsers" WHERE "ID" = :1; +INSERT INTO customers (customer_id, first_name, last_name, email, created_at) VALUES (customers_seq.NEXTVAL, :first_name, :last_name, :email, SYSDATE); +INSERT INTO orders (order_id, customer_id, order_date, status, total_amount) VALUES (orders_seq.NEXTVAL, :cust_id, SYSTIMESTAMP, 'NEW', 0); +INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price) VALUES (order_items_seq.NEXTVAL, :order_id, :product_id, :qty, :price); +INSERT INTO audit_log (id, action, performed_by, performed_at) SELECT audit_seq.NEXTVAL, 'PURGE', USER, SYSTIMESTAMP FROM dual; +INSERT INTO customers_archive SELECT * FROM customers WHERE status = 'CLOSED'; +UPDATE customers SET email = :email, updated_at = SYSDATE WHERE customer_id = :id; +UPDATE orders SET status = 'SHIPPED', shipped_at = SYSTIMESTAMP WHERE order_id = :order_id AND status = 'PAID'; +UPDATE order_items oi SET unit_price = (SELECT p.list_price FROM products p WHERE p.product_id = oi.product_id) WHERE EXISTS (SELECT 1 FROM products p WHERE p.product_id = oi.product_id); +UPDATE employees SET salary = salary * 1.05 WHERE department_id = 50 AND ROWNUM <= 100; +DELETE FROM sessions WHERE expires_at < SYSTIMESTAMP; +DELETE FROM order_items WHERE order_id IN (SELECT order_id FROM orders WHERE status = 'CANCELLED'); +DELETE FROM temp_staging; +SELECT order_id, status FROM orders WHERE order_id = :id FOR UPDATE; +SELECT order_id FROM orders WHERE status = 'NEW' ORDER BY order_date FOR UPDATE SKIP LOCKED; +SELECT account_id, balance FROM accounts WHERE account_id = :id FOR UPDATE NOWAIT; +SELECT * FROM products WHERE ROWNUM <= 10; +SELECT * FROM (SELECT p.*, ROWNUM rn FROM (SELECT * FROM products ORDER BY product_name) p WHERE ROWNUM <= 40) WHERE rn > 20; +SELECT * FROM products ORDER BY product_name OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY; +SELECT * FROM products ORDER BY list_price DESC FETCH FIRST 10 ROWS ONLY; +SELECT * FROM products ORDER BY list_price DESC FETCH FIRST 5 PERCENT ROWS ONLY; +SELECT * FROM products ORDER BY list_price DESC FETCH FIRST 3 ROWS WITH TIES; +INSERT INTO orders (order_id, customer_id, status) VALUES (orders_seq.NEXTVAL, :cid, 'NEW') RETURNING order_id INTO :new_id; +UPDATE accounts SET balance = balance - :amt WHERE account_id = :id RETURNING balance INTO :new_balance; +SELECT orders_seq.CURRVAL FROM dual; +SELECT customer_id, NVL(phone, 'N/A') AS phone, NVL2(fax, 'HAS_FAX', 'NO_FAX') AS fax_flag FROM customers; +SELECT order_id, DECODE(status, 'N', 'New', 'P', 'Paid', 'S', 'Shipped', 'Unknown') AS status_text FROM orders; +SELECT COALESCE(mobile, phone, email) AS contact FROM customers; +SELECT customer_id FROM customers WHERE UPPER(email) LIKE UPPER(:pattern) ESCAPE '\'; +SELECT * FROM customers WHERE REGEXP_LIKE(email, '^[a-z0-9._%-]+@[a-z0-9.-]+\.[a-z]{2,4}$', 'i'); +SELECT * FROM orders WHERE order_date BETWEEN TO_DATE('2024-01-01', 'YYYY-MM-DD') AND TO_DATE('2024-12-31 23:59:59', 'YYYY-MM-DD HH24:MI:SS'); +SELECT * FROM orders WHERE order_date >= DATE '2024-01-01' AND order_date < DATE '2025-01-01'; +SELECT * FROM events WHERE created_at > TIMESTAMP '2024-06-01 00:00:00'; +SELECT TRUNC(SYSDATE) AS today, TRUNC(SYSDATE, 'MM') AS month_start, ADD_MONTHS(SYSDATE, 3) AS plus3, LAST_DAY(SYSDATE) AS eom FROM dual; +SELECT SYSDATE + INTERVAL '7' DAY FROM dual; +SELECT TO_CHAR(order_date, 'YYYY-MM-DD HH24:MI:SS') FROM orders; diff --git a/testdata/realworld/oracle/02_reporting.sql b/testdata/realworld/oracle/02_reporting.sql new file mode 100644 index 00000000..8b8eb868 --- /dev/null +++ b/testdata/realworld/oracle/02_reporting.sql @@ -0,0 +1,38 @@ +-- Dashboards and BI reports: aggregates, rollups, CTE chains +SELECT department_id, COUNT(*) AS headcount, AVG(salary) AS avg_sal, MAX(salary) AS max_sal FROM employees GROUP BY department_id HAVING COUNT(*) > 5 ORDER BY headcount DESC; +SELECT region, product_line, SUM(amount) AS total FROM sales GROUP BY ROLLUP (region, product_line); +SELECT region, product_line, SUM(amount) AS total FROM sales GROUP BY CUBE (region, product_line); +SELECT region, product_line, channel, SUM(amount) AS total, GROUPING(region) AS g_region, GROUPING_ID(region, product_line) AS gid FROM sales GROUP BY GROUPING SETS ((region, product_line), (channel), ()); +SELECT TO_CHAR(order_date, 'YYYY-MM') AS month, COUNT(*) AS orders, SUM(total_amount) AS revenue FROM orders WHERE order_date >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -12) GROUP BY TO_CHAR(order_date, 'YYYY-MM') ORDER BY 1; +WITH monthly AS (SELECT TRUNC(order_date, 'MM') AS m, SUM(total_amount) AS rev FROM orders GROUP BY TRUNC(order_date, 'MM')) SELECT m, rev, LAG(rev) OVER (ORDER BY m) AS prev_rev, ROUND((rev - LAG(rev) OVER (ORDER BY m)) / NULLIF(LAG(rev) OVER (ORDER BY m), 0) * 100, 2) AS growth_pct FROM monthly ORDER BY m; +WITH dept_totals AS (SELECT department_id, SUM(salary) AS total_sal FROM employees GROUP BY department_id), avg_total AS (SELECT AVG(total_sal) AS avg_sal FROM dept_totals) SELECT d.department_name, dt.total_sal FROM dept_totals dt JOIN departments d ON d.department_id = dt.department_id CROSS JOIN avg_total a WHERE dt.total_sal > a.avg_sal; +WITH a AS (SELECT 1 AS x FROM dual), b AS (SELECT x + 1 AS x FROM a), c AS (SELECT x + 1 AS x FROM b), d AS (SELECT x + 1 AS x FROM c) SELECT * FROM d; +WITH emp_cte (emp_id, mgr_id, lvl) AS (SELECT employee_id, manager_id, 1 FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.manager_id, c.lvl + 1 FROM employees e JOIN emp_cte c ON e.manager_id = c.emp_id) SELECT * FROM emp_cte; +WITH nums (n) AS (SELECT 1 FROM dual UNION ALL SELECT n + 1 FROM nums WHERE n < 10) SELECT n FROM nums; +SELECT c.customer_id, c.last_name, COUNT(o.order_id) AS order_count, NVL(SUM(o.total_amount), 0) AS lifetime_value FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.last_name HAVING NVL(SUM(o.total_amount), 0) > 1000 ORDER BY lifetime_value DESC FETCH FIRST 50 ROWS ONLY; +SELECT p.product_name, c.category_name, s.supplier_name, SUM(oi.quantity) AS units, SUM(oi.quantity * oi.unit_price) AS revenue FROM order_items oi JOIN orders o ON o.order_id = oi.order_id JOIN products p ON p.product_id = oi.product_id JOIN categories c ON c.category_id = p.category_id JOIN suppliers s ON s.supplier_id = p.supplier_id JOIN customers cu ON cu.customer_id = o.customer_id WHERE o.status <> 'CANCELLED' AND cu.country = 'US' GROUP BY p.product_name, c.category_name, s.supplier_name ORDER BY revenue DESC; +SELECT * FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.total_amount > 500) AND NOT EXISTS (SELECT 1 FROM returns r WHERE r.customer_id = c.customer_id); +SELECT department_id, (SELECT MAX(salary) FROM employees e2 WHERE e2.department_id = e.department_id) AS max_sal FROM employees e GROUP BY department_id; +SELECT * FROM employees WHERE salary > ALL (SELECT salary FROM employees WHERE department_id = 30); +SELECT * FROM employees WHERE salary > ANY (SELECT salary FROM employees WHERE department_id = 30); +SELECT * FROM employees WHERE (department_id, job_id) IN (SELECT department_id, job_id FROM job_history); +SELECT employee_id FROM employees MINUS SELECT employee_id FROM terminated_employees; +SELECT employee_id FROM employees INTERSECT SELECT employee_id FROM badges; +SELECT city FROM offices UNION SELECT city FROM warehouses UNION ALL SELECT city FROM stores ORDER BY 1; +SELECT d.department_name, e.cnt FROM departments d, (SELECT department_id, COUNT(*) cnt FROM employees GROUP BY department_id) e WHERE d.department_id = e.department_id(+); +SELECT e.last_name, d.department_name, l.city FROM employees e, departments d, locations l WHERE e.department_id = d.department_id(+) AND d.location_id = l.location_id(+) AND e.salary > 5000; +SELECT e.last_name, m.last_name AS manager FROM employees e, employees m WHERE e.manager_id = m.employee_id (+); +SELECT s.*, d.* FROM sales s INNER JOIN dim_date d ON s.date_key = d.date_key LEFT OUTER JOIN dim_store st ON s.store_key = st.store_key FULL OUTER JOIN dim_promo p ON s.promo_key = p.promo_key; +SELECT * FROM employees e JOIN departments d USING (department_id); +SELECT * FROM employees NATURAL JOIN departments; +SELECT d.department_name, e.last_name FROM departments d CROSS APPLY (SELECT * FROM employees e WHERE e.department_id = d.department_id ORDER BY salary DESC FETCH FIRST 3 ROWS ONLY) e; +SELECT d.department_name, e.last_name FROM departments d, LATERAL (SELECT * FROM employees e WHERE e.department_id = d.department_id) e; +SELECT d.department_name, e.last_name FROM departments d OUTER APPLY (SELECT last_name FROM employees e WHERE e.department_id = d.department_id FETCH FIRST 1 ROW ONLY) e; +SELECT CASE WHEN salary < 3000 THEN 'LOW' WHEN salary < 8000 THEN 'MID' ELSE 'HIGH' END AS band, COUNT(*) FROM employees GROUP BY CASE WHEN salary < 3000 THEN 'LOW' WHEN salary < 8000 THEN 'MID' ELSE 'HIGH' END; +SELECT SUM(CASE WHEN status = 'PAID' THEN 1 ELSE 0 END) AS paid, SUM(CASE WHEN status = 'NEW' THEN 1 ELSE 0 END) AS new_cnt FROM orders; +SELECT COUNT(DISTINCT customer_id) FROM orders WHERE order_date > SYSDATE - 90; +SELECT ROUND(AVG(total_amount), 2) AS avg_order, MEDIAN(total_amount) AS med, STDDEV(total_amount) AS sd, VARIANCE(total_amount) AS var FROM orders; +SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_sal, PERCENTILE_DISC(0.9) WITHIN GROUP (ORDER BY salary) AS p90 FROM employees; +SELECT department_id, LISTAGG(last_name, ', ') WITHIN GROUP (ORDER BY last_name) AS names FROM employees GROUP BY department_id; +SELECT LISTAGG(DISTINCT city, ';') WITHIN GROUP (ORDER BY city) FROM customers; +SELECT department_id, LISTAGG(last_name, ', ' ON OVERFLOW TRUNCATE '...' WITH COUNT) WITHIN GROUP (ORDER BY last_name) FROM employees GROUP BY department_id; diff --git a/testdata/realworld/oracle/03_ddl_migrations.sql b/testdata/realworld/oracle/03_ddl_migrations.sql new file mode 100644 index 00000000..6a10f082 --- /dev/null +++ b/testdata/realworld/oracle/03_ddl_migrations.sql @@ -0,0 +1,62 @@ +-- DDL migrations: tables, constraints, indexes, partitioning, sequences, views +CREATE TABLE customers (customer_id NUMBER(10) NOT NULL, first_name VARCHAR2(50 CHAR) NOT NULL, last_name VARCHAR2(50 CHAR) NOT NULL, email VARCHAR2(255) UNIQUE, balance NUMBER(12,2) DEFAULT 0 NOT NULL, notes CLOB, avatar BLOB, created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, CONSTRAINT pk_customers PRIMARY KEY (customer_id)); +CREATE TABLE orders (order_id NUMBER(12) PRIMARY KEY, customer_id NUMBER(10) NOT NULL REFERENCES customers (customer_id), status VARCHAR2(20) DEFAULT 'NEW' CHECK (status IN ('NEW', 'PAID', 'SHIPPED', 'CANCELLED')), total_amount NUMBER(12,2), order_date DATE DEFAULT SYSDATE); +CREATE TABLE order_items (item_id NUMBER PRIMARY KEY, order_id NUMBER NOT NULL, product_id NUMBER NOT NULL, quantity NUMBER(5) NOT NULL, unit_price NUMBER(10,2) NOT NULL, CONSTRAINT fk_oi_order FOREIGN KEY (order_id) REFERENCES orders (order_id) ON DELETE CASCADE, CONSTRAINT chk_qty CHECK (quantity > 0)); +CREATE TABLE ids_demo (id NUMBER GENERATED ALWAYS AS IDENTITY, name VARCHAR2(100)); +CREATE TABLE ids_demo2 (id NUMBER GENERATED BY DEFAULT AS IDENTITY (START WITH 1000 INCREMENT BY 1), name VARCHAR2(100)); +CREATE TABLE line_totals (qty NUMBER, price NUMBER, total NUMBER GENERATED ALWAYS AS (qty * price) VIRTUAL); +CREATE GLOBAL TEMPORARY TABLE temp_staging (id NUMBER, payload VARCHAR2(4000)) ON COMMIT DELETE ROWS; +CREATE TABLE sales_hist (sale_id NUMBER, sale_date DATE NOT NULL, amount NUMBER(12,2)) PARTITION BY RANGE (sale_date) (PARTITION p2023 VALUES LESS THAN (TO_DATE('2024-01-01', 'YYYY-MM-DD')), PARTITION p2024 VALUES LESS THAN (TO_DATE('2025-01-01', 'YYYY-MM-DD')), PARTITION pmax VALUES LESS THAN (MAXVALUE)); +CREATE TABLE sales_region (sale_id NUMBER, region VARCHAR2(10), amount NUMBER) PARTITION BY LIST (region) (PARTITION p_east VALUES ('NY', 'NJ'), PARTITION p_west VALUES ('CA', 'WA'), PARTITION p_other VALUES (DEFAULT)); +CREATE TABLE sales_hash (sale_id NUMBER, amount NUMBER) PARTITION BY HASH (sale_id) PARTITIONS 8; +CREATE TABLE sales_interval (sale_id NUMBER, sale_date DATE) PARTITION BY RANGE (sale_date) INTERVAL (NUMTOYMINTERVAL(1, 'MONTH')) (PARTITION p0 VALUES LESS THAN (TO_DATE('2020-01-01', 'YYYY-MM-DD'))); +CREATE TABLE emp_copy AS SELECT * FROM employees WHERE department_id = 10; +CREATE TABLE emp_empty AS SELECT * FROM employees WHERE 1 = 0; +CREATE TABLE app_config (key VARCHAR2(100) NOT NULL, value VARCHAR2(4000), PRIMARY KEY (key)) TABLESPACE users PCTFREE 10; +CREATE INDEX idx_orders_cust ON orders (customer_id); +CREATE UNIQUE INDEX uq_customers_email ON customers (email); +CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date DESC); +CREATE INDEX idx_cust_upper_email ON customers (UPPER(email)); +CREATE BITMAP INDEX bix_orders_status ON orders (status); +CREATE INDEX idx_sales_local ON sales_hist (sale_date) LOCAL; +CREATE INDEX idx_orders_online ON orders (order_date) TABLESPACE idx_ts ONLINE; +CREATE SEQUENCE customers_seq START WITH 1 INCREMENT BY 1 NOCACHE NOCYCLE; +CREATE SEQUENCE orders_seq START WITH 1000 INCREMENT BY 1 MAXVALUE 9999999999 CACHE 20 NOORDER; +CREATE VIEW v_active_customers AS SELECT customer_id, first_name, last_name FROM customers WHERE status = 'ACTIVE'; +CREATE OR REPLACE VIEW v_order_summary AS SELECT o.order_id, c.last_name, o.total_amount FROM orders o JOIN customers c ON c.customer_id = o.customer_id WITH READ ONLY; +CREATE OR REPLACE FORCE VIEW v_future AS SELECT * FROM not_yet_created; +CREATE MATERIALIZED VIEW mv_sales_by_month BUILD IMMEDIATE REFRESH FAST ON COMMIT AS SELECT TRUNC(sale_date, 'MM') AS m, SUM(amount) AS total FROM sales_hist GROUP BY TRUNC(sale_date, 'MM'); +CREATE SYNONYM emp FOR hr.employees; +CREATE PUBLIC SYNONYM all_emp FOR hr.employees; +ALTER TABLE customers ADD (phone VARCHAR2(30), loyalty_tier VARCHAR2(10) DEFAULT 'BASIC'); +ALTER TABLE customers ADD phone2 VARCHAR2(30); +ALTER TABLE customers MODIFY (email VARCHAR2(320) NOT NULL); +ALTER TABLE customers DROP COLUMN phone2; +ALTER TABLE customers RENAME COLUMN loyalty_tier TO tier; +ALTER TABLE customers RENAME TO clients; +ALTER TABLE orders ADD CONSTRAINT fk_orders_cust FOREIGN KEY (customer_id) REFERENCES customers (customer_id); +ALTER TABLE orders DROP CONSTRAINT fk_orders_cust; +ALTER TABLE orders DISABLE CONSTRAINT chk_status; +ALTER TABLE orders ENABLE NOVALIDATE CONSTRAINT chk_status; +ALTER TABLE sales_hist ADD PARTITION p2025 VALUES LESS THAN (TO_DATE('2026-01-01', 'YYYY-MM-DD')); +ALTER TABLE sales_hist DROP PARTITION p2023; +ALTER TABLE sales_hist TRUNCATE PARTITION p2024; +ALTER TABLE orders MOVE TABLESPACE users2; +ALTER INDEX idx_orders_cust REBUILD; +ALTER INDEX idx_orders_cust RENAME TO idx_orders_customer; +ALTER SEQUENCE orders_seq INCREMENT BY 10; +DROP TABLE temp_staging; +DROP TABLE old_orders CASCADE CONSTRAINTS PURGE; +DROP INDEX idx_orders_cust; +DROP VIEW v_future; +DROP SEQUENCE customers_seq; +DROP MATERIALIZED VIEW mv_sales_by_month; +TRUNCATE TABLE audit_log; +COMMENT ON TABLE customers IS 'Customer master'; +COMMENT ON COLUMN customers.email IS 'Primary contact email'; +GRANT SELECT, INSERT, UPDATE ON customers TO app_user; +GRANT ALL PRIVILEGES ON orders TO app_admin WITH GRANT OPTION; +REVOKE DELETE ON customers FROM app_user; +CREATE USER app_user IDENTIFIED BY "S3cretPass#1" DEFAULT TABLESPACE users QUOTA UNLIMITED ON users; +CREATE ROLE app_role; +GRANT CREATE SESSION, CREATE TABLE TO app_role; diff --git a/testdata/realworld/oracle/04_window_analytics.sql b/testdata/realworld/oracle/04_window_analytics.sql new file mode 100644 index 00000000..cd7d0698 --- /dev/null +++ b/testdata/realworld/oracle/04_window_analytics.sql @@ -0,0 +1,29 @@ +-- Analytic and window functions, including KEEP DENSE_RANK and frames +SELECT employee_id, department_id, salary, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS drnk, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC, employee_id) AS rn FROM employees; +SELECT * FROM (SELECT e.*, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees e) WHERE rn <= 3; +SELECT employee_id, salary, SUM(salary) OVER (ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total FROM employees; +SELECT order_date, amount, AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM daily_sales; +SELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW) AS week_sum FROM daily_sales; +SELECT employee_id, salary, LAG(salary, 1, 0) OVER (ORDER BY hire_date) AS prev_sal, LEAD(salary) OVER (ORDER BY hire_date) AS next_sal FROM employees; +SELECT employee_id, FIRST_VALUE(salary) OVER (PARTITION BY department_id ORDER BY hire_date) AS first_sal, LAST_VALUE(salary) OVER (PARTITION BY department_id ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_sal FROM employees; +SELECT employee_id, NTILE(4) OVER (ORDER BY salary) AS quartile, PERCENT_RANK() OVER (ORDER BY salary) AS pr, CUME_DIST() OVER (ORDER BY salary) AS cd FROM employees; +SELECT department_id, MIN(salary) KEEP (DENSE_RANK FIRST ORDER BY hire_date) AS sal_of_earliest, MAX(salary) KEEP (DENSE_RANK LAST ORDER BY hire_date) AS sal_of_latest FROM employees GROUP BY department_id; +SELECT department_id, MAX(last_name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC) AS top_earner FROM employees GROUP BY department_id; +SELECT employee_id, MIN(salary) KEEP (DENSE_RANK FIRST ORDER BY hire_date) OVER (PARTITION BY department_id) AS first_sal FROM employees; +SELECT department_id, SUM(salary) OVER (PARTITION BY department_id) AS dept_total, salary / SUM(salary) OVER (PARTITION BY department_id) AS share FROM employees; +SELECT employee_id, RATIO_TO_REPORT(salary) OVER (PARTITION BY department_id) AS ratio FROM employees; +SELECT employee_id, NTH_VALUE(salary, 2) OVER (ORDER BY salary DESC) AS second_sal FROM employees; +SELECT employee_id, LISTAGG(last_name, ',') WITHIN GROUP (ORDER BY last_name) OVER (PARTITION BY department_id) AS peers FROM employees; +SELECT employee_id, LAG(salary) IGNORE NULLS OVER (ORDER BY hire_date) AS prev_nonnull FROM employees; +SELECT RANK(5000) WITHIN GROUP (ORDER BY salary) AS hyp_rank FROM employees; +SELECT product_id, SUM(amount) OVER (PARTITION BY product_id ORDER BY sale_date ROWS 3 PRECEDING) AS s FROM sales; +SELECT * FROM (SELECT region, product, amount FROM sales) PIVOT (SUM(amount) FOR product IN ('A' AS prod_a, 'B' AS prod_b, 'C' AS prod_c)); +SELECT * FROM (SELECT department_id, job_id, salary FROM employees) PIVOT (SUM(salary) AS total, COUNT(*) AS cnt FOR job_id IN ('IT_PROG' AS it, 'SA_REP' AS sales)); +SELECT * FROM (SELECT year, quarter, revenue FROM fin) PIVOT (SUM(revenue) FOR quarter IN (1 AS q1, 2 AS q2, 3 AS q3, 4 AS q4)) ORDER BY year; +SELECT * FROM quarterly_sales UNPIVOT (amount FOR quarter IN (q1, q2, q3, q4)); +SELECT * FROM quarterly_sales UNPIVOT INCLUDE NULLS (amount FOR quarter IN (q1 AS 'Q1', q2 AS 'Q2')); +SELECT * FROM sales_wide UNPIVOT ((units, revenue) FOR period IN ((u1, r1) AS 'P1', (u2, r2) AS 'P2')); +SELECT * FROM sales PIVOT XML (SUM(amount) FOR product IN (ANY)); +SELECT employee_id, COUNT(*) OVER () AS total_rows FROM employees; +SELECT department_id, employee_id, salary, salary - AVG(salary) OVER (PARTITION BY department_id) AS diff_from_avg FROM employees ORDER BY department_id, diff_from_avg DESC; +SELECT SUM(salary) OVER (ORDER BY salary RANGE UNBOUNDED PRECEDING) FROM employees; diff --git a/testdata/realworld/oracle/05_dialect_specific.sql b/testdata/realworld/oracle/05_dialect_specific.sql new file mode 100644 index 00000000..ba756890 --- /dev/null +++ b/testdata/realworld/oracle/05_dialect_specific.sql @@ -0,0 +1,73 @@ +-- Oracle-specific syntax: hierarchical queries, DUAL, flashback, sequences, special functions +SELECT employee_id, last_name, manager_id, LEVEL FROM employees START WITH manager_id IS NULL CONNECT BY PRIOR employee_id = manager_id; +SELECT LPAD(' ', 2 * (LEVEL - 1)) || last_name AS org_chart FROM employees START WITH manager_id IS NULL CONNECT BY PRIOR employee_id = manager_id ORDER SIBLINGS BY last_name; +SELECT employee_id, SYS_CONNECT_BY_PATH(last_name, '/') AS path FROM employees START WITH employee_id = 100 CONNECT BY PRIOR employee_id = manager_id; +SELECT last_name, CONNECT_BY_ROOT last_name AS root_name, CONNECT_BY_ISLEAF AS is_leaf FROM employees WHERE LEVEL > 1 START WITH manager_id IS NULL CONNECT BY PRIOR employee_id = manager_id; +SELECT employee_id FROM employees CONNECT BY NOCYCLE PRIOR employee_id = manager_id START WITH employee_id = 101; +SELECT LEVEL FROM dual CONNECT BY LEVEL <= 10; +SELECT TRUNC(SYSDATE) - LEVEL + 1 AS dt FROM dual CONNECT BY LEVEL <= 30; +SELECT TO_DATE('2024-01-01', 'YYYY-MM-DD') + LEVEL - 1 AS d FROM dual CONNECT BY LEVEL <= 366; +SELECT REGEXP_SUBSTR(:csv, '[^,]+', 1, LEVEL) AS token FROM dual CONNECT BY REGEXP_SUBSTR(:csv, '[^,]+', 1, LEVEL) IS NOT NULL; +SELECT employee_id, PRIOR last_name AS manager_name FROM employees CONNECT BY PRIOR employee_id = manager_id AND LEVEL <= 3 START WITH manager_id IS NULL; +SELECT SYSDATE FROM dual; +SELECT SYSTIMESTAMP, CURRENT_TIMESTAMP, LOCALTIMESTAMP, CURRENT_DATE FROM dual; +SELECT USER, SYS_CONTEXT('USERENV', 'SESSION_USER') AS su, SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS cs FROM dual; +SELECT 1 FROM dual; +SELECT sys_guid() FROM dual; +SELECT customers_seq.NEXTVAL, customers_seq.CURRVAL FROM dual; +SELECT * FROM employees AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '1' HOUR) WHERE employee_id = 100; +SELECT * FROM employees AS OF SCN 1234567 WHERE department_id = 10; +SELECT * FROM employees AS OF TIMESTAMP TO_TIMESTAMP('2024-06-01 10:00:00', 'YYYY-MM-DD HH24:MI:SS'); +SELECT versions_starttime, versions_endtime, versions_operation, salary FROM employees VERSIONS BETWEEN TIMESTAMP SYSTIMESTAMP - INTERVAL '1' DAY AND SYSTIMESTAMP WHERE employee_id = 100; +FLASHBACK TABLE employees TO TIMESTAMP SYSTIMESTAMP - INTERVAL '15' MINUTE; +FLASHBACK TABLE orders TO BEFORE DROP; +SELECT ROWID, employee_id FROM employees WHERE ROWNUM = 1; +SELECT * FROM employees WHERE ROWID = :rid; +SELECT ORA_ROWSCN, employee_id FROM employees WHERE employee_id = 100; +SELECT employee_id, TO_CHAR(hire_date, 'DD-MON-YYYY') AS hired, TO_CHAR(salary, 'FM$999,999.00') AS sal, TO_NUMBER('1,234.50', '9,999.99') AS n FROM employees; +SELECT TO_CHAR(SYSDATE, 'Day, DD Month YYYY', 'NLS_DATE_LANGUAGE=ENGLISH') FROM dual; +SELECT MONTHS_BETWEEN(SYSDATE, hire_date) / 12 AS years, NEXT_DAY(SYSDATE, 'MONDAY') AS next_mon, EXTRACT(YEAR FROM hire_date) AS yr FROM employees; +SELECT CAST(salary AS VARCHAR2(20)) AS s, CAST(hire_date AS TIMESTAMP) AS ts, CAST(SYSTIMESTAMP AS DATE) FROM employees; +SELECT SUBSTR(last_name, 1, 3), INSTR(email, '@'), LENGTH(last_name), REPLACE(phone, '-', ''), TRIM(BOTH ' ' FROM name), LTRIM(name), RTRIM(name, '.'), INITCAP(name), TRANSLATE(name, 'abc', 'xyz') FROM customers; +SELECT NULLIF(a, b), GREATEST(a, b, c), LEAST(a, b, c), SIGN(x), MOD(x, 3), POWER(2, 10), CEIL(1.2), FLOOR(1.8), TRUNC(1.567, 2), ROUND(1.567, 1), ABS(-3) FROM t; +SELECT first_name || ' ' || last_name AS full_name FROM employees; +SELECT q'[It's quoted]' AS s, N'unicode' AS n FROM dual; +SELECT 'O''Brien' FROM dual; +SELECT 1.5E3, .5, 5., 1e-3 FROM dual; +SELECT INTERVAL '1-6' YEAR TO MONTH, INTERVAL '2 03:04:05' DAY TO SECOND FROM dual; +SELECT NUMTODSINTERVAL(5, 'MINUTE'), NUMTOYMINTERVAL(2, 'YEAR') FROM dual; +SELECT CAST(MULTISET(SELECT order_id FROM orders WHERE customer_id = c.customer_id) AS id_list_t) FROM customers c; +SELECT * FROM TABLE(my_pkg.get_rows(:p1)); +SELECT * FROM TABLE(CAST(:coll AS number_tab_t)); +SELECT column_value FROM TABLE(sys.odcinumberlist(1, 2, 3)); +SELECT * FROM JSON_TABLE(:doc, '$.items[*]' COLUMNS (id NUMBER PATH '$.id', name VARCHAR2(100) PATH '$.name')) jt; +SELECT JSON_VALUE(payload, '$.user.name') AS uname, JSON_QUERY(payload, '$.tags') AS tags FROM events WHERE JSON_EXISTS(payload, '$.user'); +SELECT JSON_OBJECT('id' VALUE employee_id, 'name' VALUE last_name) FROM employees; +SELECT JSON_ARRAYAGG(last_name ORDER BY last_name) FROM employees; +SELECT XMLELEMENT("emp", XMLATTRIBUTES(employee_id AS "id"), last_name) FROM employees; +SELECT * FROM XMLTABLE('/rows/row' PASSING XMLTYPE(:x) COLUMNS id NUMBER PATH 'id', nm VARCHAR2(50) PATH 'name'); +SELECT /*+ INDEX(e idx_emp_dept) PARALLEL(4) */ employee_id FROM employees e WHERE department_id = 10; +SELECT /*+ FULL(o) NO_INDEX(o idx_orders_cust) */ COUNT(*) FROM orders o; +INSERT /*+ APPEND */ INTO sales_hist SELECT * FROM sales_staging; +SELECT * FROM employees SAMPLE (10); +SELECT * FROM employees PARTITION (p2024); +SELECT * FROM sales_hist PARTITION FOR (TO_DATE('2024-05-01', 'YYYY-MM-DD')); +SELECT * FROM remote_table@prod_link WHERE id = 1; +SELECT * FROM hr.employees e WHERE e.hire_date > SYSDATE - 7; +SELECT e.* FROM scott.emp e, scott.dept d WHERE e.deptno = d.deptno; +SELECT * FROM v$session WHERE username IS NOT NULL; +SELECT * FROM v$sql WHERE sql_text LIKE '%orders%'; +SELECT table_name, num_rows FROM user_tables ORDER BY num_rows DESC NULLS LAST; +SELECT owner, segment_name, ROUND(SUM(bytes) / 1024 / 1024, 2) AS mb FROM dba_segments GROUP BY owner, segment_name ORDER BY mb DESC FETCH FIRST 20 ROWS ONLY; +SELECT column_name, data_type, data_length, nullable FROM all_tab_columns WHERE table_name = UPPER(:tbl) AND owner = :owner ORDER BY column_id; +SELECT constraint_name, constraint_type, search_condition FROM user_constraints WHERE table_name = 'ORDERS'; +SELECT * FROM employees ORDER BY commission_pct DESC NULLS FIRST, last_name ASC NULLS LAST; +SELECT employee_id FROM employees WHERE commission_pct IS NOT NULL AND department_id NOT IN (10, 20, 30); +SELECT * FROM employees WHERE last_name LIKE 'S%' OR first_name LIKE '_a%'; +SELECT * FROM employees WHERE salary * 12 > 100000 AND NOT (department_id = 10 OR department_id = 20); +SELECT -salary, +salary, salary - -1, 2 * (3 + 4) / 5 FROM employees; +SELECT DISTINCT job_id FROM employees; +SELECT UNIQUE job_id FROM employees; +SELECT ALL job_id FROM employees; +SELECT * FROM employees e1 WHERE salary = (SELECT MAX(salary) FROM employees e2 WHERE e2.department_id = e1.department_id); +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT employee_id, salary FROM employees) WHERE salary > 1000) WHERE salary < 20000) WHERE ROWNUM < 5; diff --git a/testdata/realworld/oracle/06_etl_merge.sql b/testdata/realworld/oracle/06_etl_merge.sql new file mode 100644 index 00000000..483d46e8 --- /dev/null +++ b/testdata/realworld/oracle/06_etl_merge.sql @@ -0,0 +1,39 @@ +-- ETL and data-loading: MERGE, multitable insert, bulk loads, upserts +MERGE INTO customers c USING customers_stage s ON (c.customer_id = s.customer_id) WHEN MATCHED THEN UPDATE SET c.email = s.email, c.updated_at = SYSDATE WHEN NOT MATCHED THEN INSERT (customer_id, first_name, last_name, email) VALUES (s.customer_id, s.first_name, s.last_name, s.email); +MERGE INTO inventory i USING (SELECT product_id, SUM(qty) AS qty FROM shipments GROUP BY product_id) s ON (i.product_id = s.product_id) WHEN MATCHED THEN UPDATE SET i.on_hand = i.on_hand + s.qty; +MERGE INTO inventory i USING (SELECT :pid AS product_id, :qty AS qty FROM dual) s ON (i.product_id = s.product_id) WHEN MATCHED THEN UPDATE SET i.on_hand = s.qty WHEN NOT MATCHED THEN INSERT (product_id, on_hand) VALUES (s.product_id, s.qty); +MERGE INTO employees e USING hr_updates u ON (e.employee_id = u.employee_id) WHEN MATCHED THEN UPDATE SET e.salary = u.salary DELETE WHERE (u.status = 'TERMINATED'); +MERGE INTO employees e USING hr_updates u ON (e.employee_id = u.employee_id) WHEN MATCHED THEN UPDATE SET e.salary = u.salary WHERE e.salary <> u.salary WHEN NOT MATCHED THEN INSERT (employee_id, salary) VALUES (u.employee_id, u.salary) WHERE u.status = 'ACTIVE'; +MERGE /*+ PARALLEL(t 4) */ INTO target t USING source s ON (t.id = s.id) WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.val); +INSERT ALL INTO sales_east (sale_id, amount) VALUES (sale_id, amount) INTO sales_west (sale_id, amount) VALUES (sale_id, amount) SELECT sale_id, amount FROM sales_stage; +INSERT ALL WHEN region = 'E' THEN INTO sales_east VALUES (sale_id, amount) WHEN region = 'W' THEN INTO sales_west VALUES (sale_id, amount) ELSE INTO sales_other VALUES (sale_id, amount) SELECT sale_id, region, amount FROM sales_stage; +INSERT FIRST WHEN amount > 10000 THEN INTO big_sales VALUES (sale_id, amount) ELSE INTO small_sales VALUES (sale_id, amount) SELECT sale_id, amount FROM sales_stage; +INSERT INTO t1 (a, b) SELECT a, b FROM t2 WHERE NOT EXISTS (SELECT 1 FROM t1 WHERE t1.a = t2.a); +INSERT INTO dest SELECT * FROM src LOG ERRORS INTO err$_dest ('nightly load') REJECT LIMIT UNLIMITED; +INSERT INTO t (id, name) VALUES (1, 'a'); +INSERT INTO t (id, created) VALUES (:1, DEFAULT); +INSERT INTO emp_summary (department_id, total) SELECT department_id, SUM(salary) FROM employees GROUP BY department_id; +INSERT INTO log_table VALUES (log_seq.NEXTVAL, SYSDATE, 'started'); +UPDATE (SELECT e.salary AS old_sal, n.salary AS new_sal FROM employees e JOIN new_salaries n ON e.employee_id = n.employee_id) SET old_sal = new_sal; +UPDATE employees SET (salary, commission_pct) = (SELECT salary, commission_pct FROM employees WHERE employee_id = 205) WHERE employee_id = 206; +UPDATE orders o SET (o.total_amount, o.item_count) = (SELECT SUM(quantity * unit_price), COUNT(*) FROM order_items i WHERE i.order_id = o.order_id) WHERE o.status = 'NEW'; +UPDATE customers SET last_login = SYSTIMESTAMP, login_count = NVL(login_count, 0) + 1 WHERE customer_id = :id; +UPDATE employees SET job_id = DEFAULT WHERE employee_id = 1; +DELETE FROM employees e WHERE e.rowid > (SELECT MIN(e2.rowid) FROM employees e2 WHERE e2.email = e.email); +DELETE FROM (SELECT * FROM employees WHERE department_id = 10) WHERE salary < 1000; +DELETE employees WHERE employee_id = 999; +DELETE FROM orders WHERE order_date < ADD_MONTHS(SYSDATE, -36) RETURNING COUNT(*) INTO :cnt; +CREATE TABLE stage_load AS SELECT * FROM src_table WHERE load_date >= TRUNC(SYSDATE) - 1; +TRUNCATE TABLE stage_load DROP STORAGE; +SELECT COUNT(*) FROM stage_load s WHERE NOT EXISTS (SELECT NULL FROM dest d WHERE d.id = s.id); +SELECT s.id, d.id FROM src s FULL OUTER JOIN dest d ON s.id = d.id WHERE s.id IS NULL OR d.id IS NULL; +SELECT id, COUNT(*) FROM stage_load GROUP BY id HAVING COUNT(*) > 1; +SELECT * FROM stage_load WHERE ORA_HASH(id) = ORA_HASH(:v); +SELECT STANDARD_HASH(payload, 'SHA256') AS h FROM stage_load; +SELECT DBMS_RANDOM.VALUE(1, 100) FROM dual; +SELECT DBMS_LOB.GETLENGTH(notes) FROM customers WHERE notes IS NOT NULL; +SELECT UTL_RAW.CAST_TO_VARCHAR2(HEXTORAW('48656C6C6F')) FROM dual; +SELECT TO_CLOB('abc') || TO_CLOB('def') FROM dual; +SELECT employee_id, TO_CHAR(hire_date, 'IYYY-IW') AS iso_week FROM employees; +SELECT * FROM src_table@etl_link s WHERE s.updated_at > (SELECT NVL(MAX(updated_at), DATE '1970-01-01') FROM dest_table); +SELECT 'A' AS grp, COUNT(*) AS n FROM t1 UNION ALL SELECT 'B', COUNT(*) FROM t2 UNION ALL SELECT 'C', COUNT(*) FROM t3; diff --git a/testdata/realworld/oracle/07_admin_ops.sql b/testdata/realworld/oracle/07_admin_ops.sql new file mode 100644 index 00000000..5ebddc7b --- /dev/null +++ b/testdata/realworld/oracle/07_admin_ops.sql @@ -0,0 +1,53 @@ +-- DBA and operations: session control, transactions, system views, tuning queries +ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'; +ALTER SESSION SET CURRENT_SCHEMA = hr; +ALTER SESSION SET TIME_ZONE = 'UTC'; +ALTER SESSION SET NLS_LANGUAGE = 'AMERICAN' NLS_TERRITORY = 'AMERICA'; +ALTER SESSION ENABLE PARALLEL DML; +ALTER SYSTEM KILL SESSION '123,4567' IMMEDIATE; +ALTER SYSTEM SET open_cursors = 500 SCOPE = BOTH; +ALTER SYSTEM FLUSH SHARED_POOL; +ALTER USER app_user IDENTIFIED BY "N3wPass#2" ACCOUNT UNLOCK; +ALTER USER app_user QUOTA 500M ON users; +ALTER TABLESPACE users ADD DATAFILE '/u01/oradata/users02.dbf' SIZE 1G AUTOEXTEND ON NEXT 100M MAXSIZE UNLIMITED; +CREATE TABLESPACE app_data DATAFILE '/u01/oradata/app_data01.dbf' SIZE 500M AUTOEXTEND ON; +CREATE PROFILE app_profile LIMIT SESSIONS_PER_USER 10 IDLE_TIME 30; +CREATE DATABASE LINK prod_link CONNECT TO remote_user IDENTIFIED BY "pwd" USING 'PRODDB'; +DROP DATABASE LINK prod_link; +DROP USER app_user CASCADE; +DROP ROLE app_role; +DROP TABLESPACE app_data INCLUDING CONTENTS AND DATAFILES; +GRANT EXECUTE ON dbms_stats TO app_user; +GRANT app_role TO app_user; +GRANT SELECT ANY TABLE TO reporting_user; +REVOKE CREATE SESSION FROM app_user; +COMMIT; +COMMIT WORK; +ROLLBACK; +ROLLBACK TO SAVEPOINT sp1; +SAVEPOINT sp1; +SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; +SET TRANSACTION READ ONLY; +LOCK TABLE orders IN EXCLUSIVE MODE NOWAIT; +LOCK TABLE orders IN ROW SHARE MODE; +ANALYZE TABLE orders COMPUTE STATISTICS; +EXPLAIN PLAN FOR SELECT * FROM orders WHERE customer_id = 5; +EXPLAIN PLAN SET STATEMENT_ID = 'q1' FOR SELECT * FROM orders; +SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); +SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST')); +SELECT s.sid, s.serial#, s.username, s.status, s.machine, s.program, q.sql_text FROM v$session s LEFT JOIN v$sql q ON s.sql_id = q.sql_id WHERE s.type = 'USER' AND s.status = 'ACTIVE'; +SELECT l.session_id, o.object_name, l.locked_mode FROM v$locked_object l JOIN dba_objects o ON o.object_id = l.object_id; +SELECT tablespace_name, ROUND(SUM(bytes) / 1048576) AS mb_free FROM dba_free_space GROUP BY tablespace_name; +SELECT df.tablespace_name, df.total_mb, NVL(fs.free_mb, 0) AS free_mb, ROUND((df.total_mb - NVL(fs.free_mb, 0)) / df.total_mb * 100, 1) AS pct_used FROM (SELECT tablespace_name, SUM(bytes) / 1048576 AS total_mb FROM dba_data_files GROUP BY tablespace_name) df LEFT JOIN (SELECT tablespace_name, SUM(bytes) / 1048576 AS free_mb FROM dba_free_space GROUP BY tablespace_name) fs ON df.tablespace_name = fs.tablespace_name ORDER BY pct_used DESC; +SELECT sql_id, executions, elapsed_time / 1000000 AS elapsed_s, buffer_gets, SUBSTR(sql_text, 1, 100) AS txt FROM v$sqlarea WHERE parsing_schema_name = 'APP' ORDER BY elapsed_time DESC FETCH FIRST 25 ROWS ONLY; +SELECT object_name, object_type, status FROM user_objects WHERE status = 'INVALID'; +SELECT index_name, uniqueness, status FROM user_indexes WHERE table_name = 'ORDERS'; +SELECT * FROM nls_session_parameters WHERE parameter LIKE 'NLS_DATE%'; +SELECT banner FROM v$version; +SELECT name, value FROM v$parameter WHERE name IN ('sga_target', 'pga_aggregate_target'); +SELECT username, account_status, expiry_date FROM dba_users WHERE expiry_date < SYSDATE + 14; +SELECT grantee, privilege FROM dba_sys_privs WHERE grantee = :u; +SELECT job_name, state, last_start_date FROM dba_scheduler_jobs WHERE enabled = 'TRUE'; +SELECT * FROM dba_hist_snapshot WHERE begin_interval_time > SYSTIMESTAMP - INTERVAL '1' DAY; +SELECT inst_id, COUNT(*) FROM gv$session GROUP BY inst_id; +SELECT * FROM all_source WHERE name = 'MY_PKG' AND type = 'PACKAGE BODY' ORDER BY line; diff --git a/testdata/realworld/oracle/08_complex_nested.sql b/testdata/realworld/oracle/08_complex_nested.sql new file mode 100644 index 00000000..3cbb10a3 --- /dev/null +++ b/testdata/realworld/oracle/08_complex_nested.sql @@ -0,0 +1,27 @@ +-- Deeply nested and many-join enterprise queries (ERP / finance / HR) +WITH sales_q AS (SELECT region_id, TRUNC(sale_date, 'Q') AS qtr, SUM(amount) AS amt FROM sales GROUP BY region_id, TRUNC(sale_date, 'Q')), ranked AS (SELECT region_id, qtr, amt, RANK() OVER (PARTITION BY qtr ORDER BY amt DESC) AS rk FROM sales_q), top3 AS (SELECT * FROM ranked WHERE rk <= 3), named AS (SELECT t.qtr, r.region_name, t.amt, t.rk FROM top3 t JOIN regions r ON r.region_id = t.region_id), final AS (SELECT n.*, SUM(n.amt) OVER (PARTITION BY n.qtr) AS qtr_total FROM named n) SELECT qtr, region_name, amt, ROUND(amt / qtr_total * 100, 1) AS pct FROM final ORDER BY qtr, rk; +SELECT * FROM (SELECT a.*, ROWNUM AS rnum FROM (SELECT o.order_id, o.order_date, c.last_name, SUM(oi.quantity * oi.unit_price) AS total FROM orders o JOIN customers c ON c.customer_id = o.customer_id JOIN order_items oi ON oi.order_id = o.order_id WHERE o.order_date >= TRUNC(SYSDATE) - 90 GROUP BY o.order_id, o.order_date, c.last_name ORDER BY total DESC) a WHERE ROWNUM <= :max_row) WHERE rnum >= :min_row; +SELECT e.employee_id, e.last_name, d.department_name, j.job_title, l.city, c.country_name, r.region_name, m.last_name AS mgr, (SELECT COUNT(*) FROM employees x WHERE x.manager_id = e.employee_id) AS reports FROM employees e JOIN departments d ON e.department_id = d.department_id JOIN jobs j ON e.job_id = j.job_id JOIN locations l ON d.location_id = l.location_id JOIN countries c ON l.country_id = c.country_id JOIN regions r ON c.region_id = r.region_id LEFT JOIN employees m ON e.manager_id = m.employee_id WHERE e.salary BETWEEN 3000 AND 12000 AND r.region_name IN ('Europe', 'Americas'); +SELECT department_id, (SELECT AVG(salary) FROM employees WHERE department_id = d.department_id) AS avg_sal, (SELECT COUNT(*) FROM (SELECT DISTINCT job_id FROM employees WHERE department_id = d.department_id)) AS distinct_jobs FROM departments d WHERE d.department_id IN (SELECT department_id FROM employees GROUP BY department_id HAVING AVG(salary) > (SELECT AVG(salary) FROM employees)); +SELECT * FROM employees WHERE salary > (SELECT AVG(salary) FROM employees WHERE department_id IN (SELECT department_id FROM departments WHERE location_id IN (SELECT location_id FROM locations WHERE country_id IN (SELECT country_id FROM countries WHERE region_id = 1)))); +SELECT CASE WHEN x.a > 0 THEN CASE WHEN x.b > 0 THEN 'PP' ELSE 'PN' END ELSE CASE WHEN x.b > 0 THEN 'NP' ELSE 'NN' END END AS quad FROM x; +SELECT a.acct_id, a.balance + NVL((SELECT SUM(t.amount) FROM txns t WHERE t.acct_id = a.acct_id AND t.posted = 'N'), 0) AS available FROM accounts a WHERE a.status = 'OPEN'; +SELECT ((a + b) * (c - d)) / NULLIF((e + (f * (g - (h + i)))), 0) AS ratio FROM calc; +SELECT * FROM t WHERE (a = 1 AND (b = 2 OR (c = 3 AND (d = 4 OR e = 5)))) OR (f IS NULL AND NOT (g > 10 AND h < 20)); +SELECT * FROM t WHERE a = 1 OR b = 2 AND c = 3; +SELECT * FROM t WHERE NOT a = 1 AND b = 2; +SELECT 1 + 2 * 3 - 4 / 2 FROM dual; +SELECT * FROM t WHERE a BETWEEN 1 AND 5 AND b NOT BETWEEN 10 AND 20 AND c NOT LIKE 'x%' AND d NOT IN (1, 2); +SELECT NVL((SELECT MAX(x) FROM t1), (SELECT MIN(x) FROM t2)) FROM dual; +SELECT d.department_name, (SELECT LISTAGG(e.last_name, ', ') WITHIN GROUP (ORDER BY e.last_name) FROM employees e WHERE e.department_id = d.department_id) AS emps FROM departments d; +WITH bom (part_id, parent_id, qty, lvl) AS (SELECT part_id, parent_id, qty, 1 FROM parts WHERE parent_id IS NULL UNION ALL SELECT p.part_id, p.parent_id, p.qty * b.qty, b.lvl + 1 FROM parts p JOIN bom b ON p.parent_id = b.part_id) SEARCH DEPTH FIRST BY part_id SET ord CYCLE part_id SET is_cycle TO 'Y' DEFAULT 'N' SELECT * FROM bom ORDER BY ord; +WITH t1 AS (SELECT /*+ MATERIALIZE */ * FROM big_table WHERE flag = 'Y') SELECT COUNT(*) FROM t1 a JOIN t1 b ON a.id = b.parent_id; +SELECT * FROM orders o WHERE o.total_amount = (SELECT MAX(total_amount) KEEP (DENSE_RANK LAST ORDER BY order_date) FROM orders o2 WHERE o2.customer_id = o.customer_id); +SELECT customer_id, order_id, total_amount, SUM(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date, order_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS recency FROM orders; +SELECT t.* FROM (SELECT DISTINCT customer_id FROM orders) o, TABLE(get_recs(o.customer_id)) t; +SELECT * FROM (SELECT e.*, DENSE_RANK() OVER (ORDER BY salary DESC) AS dr FROM employees e) WHERE dr = 3; +SELECT e.employee_id FROM employees e WHERE e.department_id = (SELECT d.department_id FROM departments d WHERE d.department_name = 'Sales') AND e.hire_date = (SELECT MIN(hire_date) FROM employees WHERE department_id = e.department_id); +SELECT a.id FROM a WHERE a.id IN (SELECT b.id FROM b UNION SELECT c.id FROM c) AND a.id NOT IN (SELECT d.id FROM d WHERE d.id IS NOT NULL); +(SELECT id FROM a) UNION ALL (SELECT id FROM b) ORDER BY 1; +SELECT id FROM (SELECT id FROM a UNION ALL SELECT id FROM b) ORDER BY id; +SELECT * FROM a, b, c, d, e WHERE a.id = b.a_id AND b.id = c.b_id AND c.id = d.c_id AND d.id = e.d_id; diff --git a/testdata/realworld/postgresql/01_app_crud.sql b/testdata/realworld/postgresql/01_app_crud.sql new file mode 100644 index 00000000..9654de3e --- /dev/null +++ b/testdata/realworld/postgresql/01_app_crud.sql @@ -0,0 +1,27 @@ +-- Rails/Django style ORM generated CRUD for a users/orders app +SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1; +SELECT "users".* FROM "users" WHERE "users"."email" = $1 AND "users"."deleted_at" IS NULL ORDER BY "users"."id" ASC LIMIT $2; +SELECT "orders"."id", "orders"."total_cents", "orders"."status" FROM "orders" WHERE "orders"."user_id" IN ($1, $2, $3) ORDER BY "orders"."created_at" DESC LIMIT 20 OFFSET 40; +SELECT COUNT(*) FROM "orders" WHERE "orders"."status" = 'pending' AND "orders"."created_at" >= NOW() - INTERVAL '7 days'; +INSERT INTO users (email, name, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING id; +INSERT INTO orders (user_id, total_cents, status, metadata, created_at) VALUES ($1, $2, 'pending', '{"source": "web"}'::jsonb, now()) RETURNING *; +INSERT INTO order_items (order_id, product_id, quantity, unit_price_cents) VALUES (1, 10, 2, 1999), (1, 11, 1, 4999), (1, 12, 5, 99) RETURNING id, order_id; +UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, name, updated_at; +UPDATE orders SET status = 'shipped', shipped_at = CURRENT_TIMESTAMP WHERE id = ANY($1::bigint[]) AND status = 'paid'; +UPDATE products SET stock = stock - oi.quantity FROM order_items oi WHERE oi.product_id = products.id AND oi.order_id = $1; +DELETE FROM sessions WHERE expires_at < NOW(); +DELETE FROM order_items USING orders WHERE order_items.order_id = orders.id AND orders.status = 'cancelled' RETURNING order_items.id; +DELETE FROM users WHERE id = $1 RETURNING *; +INSERT INTO users (email, name) VALUES ('a@example.com', 'A') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, updated_at = now() RETURNING id, (xmax = 0) AS inserted; +INSERT INTO page_views (page_id, day, hits) VALUES (1, CURRENT_DATE, 1) ON CONFLICT (page_id, day) DO UPDATE SET hits = page_views.hits + 1; +INSERT INTO tags (name) VALUES ('go') ON CONFLICT DO NOTHING; +INSERT INTO tags (name) VALUES ('sql') ON CONFLICT ON CONSTRAINT tags_name_key DO NOTHING RETURNING id; +INSERT INTO accounts (id, balance) VALUES (1, 100) ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + EXCLUDED.balance WHERE accounts.active; +SELECT u.id, u.email, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email HAVING COUNT(o.id) > 5 ORDER BY order_count DESC; +SELECT * FROM users WHERE email ILIKE '%@example.com' AND name NOT ILIKE 'test%'; +SELECT * FROM products WHERE id = ANY(ARRAY[1, 2, 3]) AND price BETWEEN 10 AND 100; +SELECT * FROM users WHERE created_at > $1 FOR UPDATE SKIP LOCKED; +SELECT id FROM jobs WHERE state = 'queued' ORDER BY priority DESC, id LIMIT 1 FOR UPDATE SKIP LOCKED; +SELECT EXISTS (SELECT 1 FROM users WHERE email = $1); +SELECT * FROM orders WHERE user_id = (SELECT id FROM users WHERE email = $1); +SELECT "posts"."id" FROM "posts" WHERE "posts"."author_id" = $1 AND "posts"."published_at" IS NOT NULL AND ("posts"."title" LIKE $2 OR "posts"."body" LIKE $2) ORDER BY "posts"."published_at" DESC FETCH FIRST 10 ROWS ONLY; diff --git a/testdata/realworld/postgresql/02_reporting.sql b/testdata/realworld/postgresql/02_reporting.sql new file mode 100644 index 00000000..da8624c8 --- /dev/null +++ b/testdata/realworld/postgresql/02_reporting.sql @@ -0,0 +1,29 @@ +-- Dashboard and reporting queries: aggregates, GROUPING SETS, FILTER, CTE chains +SELECT date_trunc('day', created_at) AS day, COUNT(*) AS orders, SUM(total_cents) / 100.0 AS revenue FROM orders WHERE created_at >= date_trunc('month', now()) - INTERVAL '1 month' GROUP BY 1 ORDER BY 1; +SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded, AVG(total_cents) FILTER (WHERE status = 'paid') AS avg_paid FROM orders; +SELECT region, product_category, SUM(amount) AS total FROM sales GROUP BY GROUPING SETS ((region, product_category), (region), ()) ORDER BY region, product_category; +SELECT region, product_category, SUM(amount) FROM sales GROUP BY ROLLUP (region, product_category); +SELECT region, channel, SUM(amount), GROUPING(region, channel) AS g FROM sales GROUP BY CUBE (region, channel); +SELECT extract(year FROM created_at) AS yr, extract(month FROM created_at) AS mo, COUNT(DISTINCT user_id) AS mau FROM events GROUP BY 1, 2 ORDER BY 1, 2; +WITH monthly AS (SELECT date_trunc('month', created_at) AS m, SUM(total_cents) AS revenue FROM orders GROUP BY 1), growth AS (SELECT m, revenue, LAG(revenue) OVER (ORDER BY m) AS prev FROM monthly) SELECT m, revenue, ROUND(100.0 * (revenue - prev) / NULLIF(prev, 0), 2) AS pct_growth FROM growth ORDER BY m; +WITH RECURSIVE tree AS (SELECT id, parent_id, name, 1 AS depth, ARRAY[id] AS path FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id, c.name, t.depth + 1, t.path || c.id FROM categories c JOIN tree t ON c.parent_id = t.id WHERE NOT c.id = ANY(t.path)) SELECT * FROM tree ORDER BY path; +WITH RECURSIVE nums(n) AS (VALUES (1) UNION ALL SELECT n + 1 FROM nums WHERE n < 10) SELECT n FROM nums; +WITH cohort AS (SELECT user_id, date_trunc('week', MIN(created_at)) AS cohort_week FROM events GROUP BY user_id), activity AS (SELECT e.user_id, date_trunc('week', e.created_at) AS active_week FROM events e GROUP BY 1, 2) SELECT c.cohort_week, (a.active_week - c.cohort_week) / 7 AS week_no, COUNT(DISTINCT a.user_id) AS users FROM cohort c JOIN activity a USING (user_id) GROUP BY 1, 2 ORDER BY 1, 2; +SELECT p.category, p.name, p.price FROM products p WHERE p.price > (SELECT AVG(price) FROM products WHERE category = p.category); +SELECT c.name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS n_orders, (SELECT MAX(o.created_at) FROM orders o WHERE o.customer_id = c.id) AS last_order FROM customers c; +SELECT * FROM (SELECT department, salary, RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk FROM employees) t WHERE rnk <= 3; +SELECT o.id, c.name, p.name, oi.quantity FROM orders o INNER JOIN customers c ON c.id = o.customer_id INNER JOIN order_items oi ON oi.order_id = o.id INNER JOIN products p ON p.id = oi.product_id LEFT JOIN shipments s ON s.order_id = o.id LEFT JOIN warehouses w ON w.id = s.warehouse_id WHERE w.region = 'EU' OR s.id IS NULL; +SELECT a.id FROM a FULL OUTER JOIN b ON a.id = b.id WHERE a.id IS NULL OR b.id IS NULL; +SELECT * FROM a CROSS JOIN b; +SELECT * FROM a NATURAL JOIN b; +SELECT id FROM t1 UNION SELECT id FROM t2 EXCEPT SELECT id FROM t3; +(SELECT id FROM t1 ORDER BY id LIMIT 5) UNION ALL (SELECT id FROM t2 ORDER BY id DESC LIMIT 5); +SELECT id FROM t1 INTERSECT ALL SELECT id FROM t2; +SELECT CASE WHEN total_cents < 1000 THEN 'small' WHEN total_cents < 10000 THEN 'medium' ELSE 'large' END AS bucket, COUNT(*) FROM orders GROUP BY 1; +SELECT COALESCE(NULLIF(trim(name), ''), 'unknown') AS name, GREATEST(a, b, c) AS mx, LEAST(a, b) AS mn FROM t; +SELECT customer_id, SUM(total_cents) AS spend FROM orders GROUP BY customer_id ORDER BY spend DESC NULLS LAST LIMIT 10; +SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY response_ms) AS p50, percentile_cont(0.95) WITHIN GROUP (ORDER BY response_ms) AS p95 FROM requests; +SELECT mode() WITHIN GROUP (ORDER BY status_code) FROM requests; +SELECT string_agg(name, ', ' ORDER BY name) AS names, array_agg(DISTINCT tag) AS tags FROM items GROUP BY category; +SELECT bool_and(active), bool_or(active), every(active) FROM users; +SELECT * FROM sales TABLESAMPLE SYSTEM (10); diff --git a/testdata/realworld/postgresql/03_ddl_migrations.sql b/testdata/realworld/postgresql/03_ddl_migrations.sql new file mode 100644 index 00000000..ab4a03c1 --- /dev/null +++ b/testdata/realworld/postgresql/03_ddl_migrations.sql @@ -0,0 +1,67 @@ +-- Schema migrations as generated by Rails, Alembic, Flyway and hand-written +CREATE TABLE users (id BIGSERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, name TEXT, role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')), settings JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS orders (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, total_cents INTEGER NOT NULL CHECK (total_cents >= 0), status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE order_items (order_id BIGINT NOT NULL, line_no INT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, PRIMARY KEY (order_id, line_no), CONSTRAINT fk_order FOREIGN KEY (order_id) REFERENCES orders (id) ON UPDATE CASCADE ON DELETE RESTRICT, CONSTRAINT qty_positive CHECK (quantity > 0)); +CREATE TABLE products (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), sku text UNIQUE NOT NULL, price numeric(12, 2) NOT NULL, tags text[] DEFAULT '{}', dims int[][], attrs jsonb, search tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(sku, ''))) STORED); +CREATE TEMP TABLE tmp_ids (id bigint) ON COMMIT DROP; +CREATE UNLOGGED TABLE staging_events (payload jsonb, received_at timestamptz DEFAULT now()); +CREATE TABLE audit_log (LIKE users INCLUDING ALL); +CREATE TABLE new_orders AS SELECT * FROM orders WHERE created_at > now() - interval '30 days'; +CREATE TABLE measurements (city_id int NOT NULL, logdate date NOT NULL, peaktemp int, unitsales int) PARTITION BY RANGE (logdate); +CREATE TABLE measurements_y2024m01 PARTITION OF measurements FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); +CREATE TABLE measurements_default PARTITION OF measurements DEFAULT; +CREATE TABLE regions_list (id int, region text) PARTITION BY LIST (region); +CREATE TABLE regions_eu PARTITION OF regions_list FOR VALUES IN ('DE', 'FR', 'ES'); +CREATE TABLE hash_part (id int) PARTITION BY HASH (id); +CREATE TABLE hash_part_0 PARTITION OF hash_part FOR VALUES WITH (MODULUS 4, REMAINDER 0); +CREATE INDEX idx_users_email ON users (email); +CREATE UNIQUE INDEX idx_users_lower_email ON users (lower(email)); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_created ON orders (user_id, created_at DESC); +CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending'; +CREATE INDEX idx_orders_cover ON orders (user_id) INCLUDE (total_cents, status); +CREATE INDEX idx_products_attrs ON products USING gin (attrs); +CREATE INDEX idx_products_attrs_path ON products USING gin (attrs jsonb_path_ops); +CREATE INDEX idx_products_tags ON products USING gin (tags); +CREATE INDEX idx_products_search ON products USING gist (search); +CREATE INDEX idx_events_brin ON events USING brin (created_at) WITH (pages_per_range = 32); +CREATE INDEX idx_nulls ON orders (shipped_at NULLS FIRST); +CREATE INDEX idx_hash ON sessions USING hash (token); +ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NOT NULL DEFAULT ''; +ALTER TABLE users DROP COLUMN IF EXISTS phone; +ALTER TABLE users ALTER COLUMN name SET NOT NULL; +ALTER TABLE users ALTER COLUMN name DROP NOT NULL; +ALTER TABLE users ALTER COLUMN role SET DEFAULT 'guest'; +ALTER TABLE users ALTER COLUMN role DROP DEFAULT; +ALTER TABLE orders ALTER COLUMN total_cents TYPE BIGINT USING total_cents::bigint; +ALTER TABLE users RENAME COLUMN name TO full_name; +ALTER TABLE users RENAME TO app_users; +ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id) NOT VALID; +ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk; +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_user_fk; +ALTER TABLE orders ADD CONSTRAINT chk_total CHECK (total_cents >= 0); +ALTER TABLE orders ADD CONSTRAINT uq_orders UNIQUE (user_id, created_at); +ALTER TABLE orders ADD PRIMARY KEY (id); +ALTER TABLE orders ADD COLUMN a int, ADD COLUMN b int; +ALTER TABLE orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE measurements ATTACH PARTITION measurements_y2024m01 FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); +ALTER TABLE measurements DETACH PARTITION measurements_y2024m01; +ALTER TABLE orders SET (fillfactor = 70); +ALTER TABLE orders OWNER TO app_owner; +ALTER INDEX idx_users_email RENAME TO idx_users_email_v2; +DROP INDEX CONCURRENTLY IF EXISTS idx_users_email_v2; +DROP TABLE IF EXISTS tmp_ids, staging_events CASCADE; +DROP TABLE audit_log; +TRUNCATE TABLE sessions, tokens RESTART IDENTITY CASCADE; +CREATE VIEW active_users AS SELECT id, email FROM users WHERE deleted_at IS NULL; +CREATE OR REPLACE VIEW order_totals AS SELECT user_id, SUM(total_cents) AS total FROM orders GROUP BY user_id; +CREATE MATERIALIZED VIEW daily_revenue AS SELECT date_trunc('day', created_at) AS day, SUM(total_cents) AS revenue FROM orders GROUP BY 1 WITH DATA; +CREATE UNIQUE INDEX idx_daily_revenue_day ON daily_revenue (day); +REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue; +CREATE SEQUENCE order_seq START WITH 1000 INCREMENT BY 1 CACHE 10; +CREATE SCHEMA IF NOT EXISTS analytics; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); +CREATE DOMAIN positive_int AS integer CHECK (VALUE > 0); +COMMENT ON TABLE users IS 'Application users'; +COMMENT ON COLUMN users.email IS 'Login email'; diff --git a/testdata/realworld/postgresql/04_window_analytics.sql b/testdata/realworld/postgresql/04_window_analytics.sql new file mode 100644 index 00000000..3691e66c --- /dev/null +++ b/testdata/realworld/postgresql/04_window_analytics.sql @@ -0,0 +1,23 @@ +-- Window function analytics: frames, GROUPS, EXCLUDE, named windows +SELECT id, SUM(amount) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running FROM payments; +SELECT id, AVG(amount) OVER (ORDER BY created_at ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM payments; +SELECT id, SUM(amount) OVER (ORDER BY created_at RANGE BETWEEN INTERVAL '1 day' PRECEDING AND CURRENT ROW) FROM payments; +SELECT id, SUM(amount) OVER (ORDER BY grp GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING) FROM t; +SELECT id, SUM(amount) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW) FROM t; +SELECT id, SUM(amount) OVER (ORDER BY id RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) FROM t; +SELECT id, SUM(amount) OVER (ORDER BY id ROWS UNBOUNDED PRECEDING EXCLUDE NO OTHERS) FROM t; +SELECT id, ROW_NUMBER() OVER w AS rn, RANK() OVER w AS rk, DENSE_RANK() OVER w AS drk FROM t WINDOW w AS (PARTITION BY dept ORDER BY salary DESC); +SELECT id, SUM(x) OVER (w ORDER BY id) FROM t WINDOW w AS (PARTITION BY grp); +SELECT id, LAG(price, 1, 0) OVER (PARTITION BY product_id ORDER BY ts) AS prev_price, LEAD(price) OVER (PARTITION BY product_id ORDER BY ts) AS next_price FROM prices; +SELECT id, FIRST_VALUE(price) OVER (PARTITION BY product_id ORDER BY ts) AS first_p, LAST_VALUE(price) OVER (PARTITION BY product_id ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_p, NTH_VALUE(price, 2) OVER (PARTITION BY product_id ORDER BY ts) AS second_p FROM prices; +SELECT id, NTILE(4) OVER (ORDER BY score) AS quartile, PERCENT_RANK() OVER (ORDER BY score) AS pr, CUME_DIST() OVER (ORDER BY score) AS cd FROM scores; +SELECT id, COUNT(*) FILTER (WHERE status = 'ok') OVER (PARTITION BY host ORDER BY ts ROWS BETWEEN 9 PRECEDING AND CURRENT ROW) FROM checks; +SELECT user_id, ts, ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) AS gap FROM events; +SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM sessions) s WHERE rn = 1; +WITH ranked AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM users) DELETE FROM users WHERE id IN (SELECT id FROM ranked WHERE rn > 1); +SELECT DISTINCT ON (user_id) user_id, id, created_at FROM orders ORDER BY user_id, created_at DESC; +SELECT DISTINCT ON (customer_id, date_trunc('day', created_at)) customer_id, created_at, total_cents FROM orders ORDER BY customer_id, date_trunc('day', created_at), total_cents DESC; +SELECT SUM(x) OVER (PARTITION BY a, b ORDER BY c DESC NULLS LAST) FROM t; +SELECT id, SUM(amount) OVER (ORDER BY id RANGE BETWEEN 5 PRECEDING AND 5 FOLLOWING) FROM t; +SELECT array_agg(x ORDER BY y) OVER (PARTITION BY z) FROM t; +SELECT id, SUM(amount) OVER (PARTITION BY dept) / SUM(amount) OVER () AS share FROM t; diff --git a/testdata/realworld/postgresql/05_dialect_specific.sql b/testdata/realworld/postgresql/05_dialect_specific.sql new file mode 100644 index 00000000..13b8307f --- /dev/null +++ b/testdata/realworld/postgresql/05_dialect_specific.sql @@ -0,0 +1,58 @@ +-- PostgreSQL specific: JSONB, arrays, LATERAL, regex, casts, intervals, generate_series +SELECT data->'user'->>'name' AS name, data #>> '{address,city}' AS city FROM events; +SELECT * FROM events WHERE data @> '{"type": "click"}'::jsonb; +SELECT * FROM events WHERE data ? 'user_id' AND data ?| ARRAY['a', 'b'] AND data ?& ARRAY['c', 'd']; +SELECT * FROM events WHERE (data->>'amount')::numeric > 100; +SELECT * FROM events WHERE data #> '{a,b}' = '1'::jsonb; +SELECT * FROM events WHERE '{"a": 1}'::jsonb <@ data; +SELECT data - 'password' AS safe, data || '{"x": 1}'::jsonb AS merged, data #- '{a,b}' AS removed FROM users; +SELECT jsonb_set(data, '{profile,age}', '31'::jsonb, true) FROM users; +SELECT jsonb_build_object('id', id, 'email', email) FROM users; +SELECT jsonb_agg(jsonb_build_object('sku', sku, 'qty', quantity)) FROM order_items GROUP BY order_id; +SELECT e.id, elem->>'name' FROM events e, jsonb_array_elements(e.data->'items') AS elem; +SELECT * FROM jsonb_each_text('{"a": "1", "b": "2"}'::jsonb); +SELECT * FROM events WHERE data @@ '$.items[*].price > 10'; +SELECT jsonb_path_query(data, '$.items[*] ? (@.price > 10)') FROM events; +SELECT * FROM jsonb_to_recordset('[{"a": 1, "b": "x"}]'::jsonb) AS t(a int, b text); +SELECT ARRAY[1, 2, 3] || 4, ARRAY[1, 2] @> ARRAY[1], ARRAY[1, 2] && ARRAY[2, 3]; +SELECT tags[1], tags[1:2], array_length(tags, 1), cardinality(tags) FROM products; +SELECT * FROM products WHERE 'sale' = ANY(tags) AND NOT ('old' = ALL(tags)); +SELECT unnest(tags) AS tag, COUNT(*) FROM products GROUP BY 1; +SELECT * FROM unnest(ARRAY[1, 2, 3], ARRAY['a', 'b', 'c']) AS t(n, l); +SELECT * FROM unnest(ARRAY[10, 20]) WITH ORDINALITY AS t(val, idx); +SELECT u.id, o.* FROM users u CROSS JOIN LATERAL (SELECT * FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 3) o; +SELECT u.id, o.total FROM users u LEFT JOIN LATERAL (SELECT SUM(total_cents) AS total FROM orders WHERE user_id = u.id) o ON true; +SELECT * FROM users, LATERAL jsonb_array_elements_text(u_tags) AS tag; +SELECT * FROM generate_series(1, 10) AS g(n); +SELECT d::date FROM generate_series('2024-01-01'::date, '2024-12-31'::date, INTERVAL '1 day') AS d; +SELECT g.day, COALESCE(COUNT(o.id), 0) FROM generate_series(CURRENT_DATE - 6, CURRENT_DATE, '1 day') AS g(day) LEFT JOIN orders o ON o.created_at::date = g.day GROUP BY g.day; +SELECT * FROM users WHERE name ~ '^A.*z$' AND name !~ 'test' AND email ~* '@EXAMPLE\.com$' AND email !~* 'spam'; +SELECT * FROM users WHERE name SIMILAR TO '(a|b)%'; +SELECT regexp_replace(phone, '[^0-9]', '', 'g'), regexp_matches(body, '(\d+)', 'g'), split_part(email, '@', 2) FROM users; +SELECT '2024-01-01'::date + INTERVAL '1 month', now() - interval '90 minutes', INTERVAL '1 year 2 months 3 days', CURRENT_TIMESTAMP AT TIME ZONE 'UTC'; +SELECT created_at AT TIME ZONE 'America/New_York' FROM orders; +SELECT 1::int, '1'::text::int, CAST(price AS numeric(10, 2)), price::float8, now()::date, '{1,2}'::int[], 'abc'::varchar(2); +SELECT CAST('2024-01-01' AS timestamptz), TIMESTAMP '2024-01-01 00:00:00', DATE '2024-01-01', TIME '12:00'; +SELECT EXTRACT(EPOCH FROM now()), date_part('dow', now()), age(now(), created_at), make_interval(days => 3) FROM users; +SELECT $$dollar quoted$$, $tag$ body 'with' quotes $tag$, E'tab\there', 'it''s'; +SELECT u.* FROM users u WHERE u.id IS DISTINCT FROM 5 AND u.name IS NOT DISTINCT FROM NULL; +SELECT * FROM tbl WHERE (a, b) IN ((1, 2), (3, 4)) AND (c, d) > (1, 2); +SELECT ROW(1, 2) = ROW(1, 2); +SELECT * FROM users WHERE tsv @@ to_tsquery('english', 'foo & bar') ORDER BY ts_rank(tsv, to_tsquery('foo & bar')) DESC; +SELECT 'a' || 'b', 5 % 3, 2 ^ 10, |/ 25, @ -5, 7 / 2, 5 & 3, 5 | 3, 5 # 3, ~5, 1 << 3, 16 >> 2; +SELECT a <-> b FROM pts; +SELECT * FROM events WHERE ts <@ tstzrange('2024-01-01', '2024-02-01') AND during && daterange('2024-01-01', '2024-01-10'); +SELECT inet '192.168.0.1' << inet '192.168.0.0/24', '10.0.0.1'::inet, macaddr '08:00:2b:01:02:03'; +SELECT gen_random_uuid(), uuid_generate_v4(), md5('x'), encode(digest('x', 'sha256'), 'hex'); +SELECT current_user, session_user, current_schema(), current_setting('search_path'), pg_typeof(1), version(); +SELECT col COLLATE "C" FROM t ORDER BY name COLLATE "en_US"; +SELECT * FROM ONLY parent_tbl; +SELECT * FROM t WHERE x = ANY (SELECT y FROM u) AND z <> ALL (SELECT w FROM v); +SELECT count(*) OVER () FROM t LIMIT ALL OFFSET 5 ROWS; +SELECT * FROM t ORDER BY id FETCH FIRST 5 ROWS WITH TIES; +SELECT * FROM t OFFSET 10 LIMIT 5; +VALUES (1, 'a'), (2, 'b'); +SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS v(id, name); +TABLE users; +SELECT 1 WHERE EXISTS (SELECT 1) AND NOT EXISTS (SELECT 2); +SELECT * INTO new_tbl FROM old_tbl WHERE x > 1; diff --git a/testdata/realworld/postgresql/06_cte_dml_etl.sql b/testdata/realworld/postgresql/06_cte_dml_etl.sql new file mode 100644 index 00000000..8d04efd0 --- /dev/null +++ b/testdata/realworld/postgresql/06_cte_dml_etl.sql @@ -0,0 +1,28 @@ +-- ETL and data modification CTEs, MERGE, MATERIALIZED hints +WITH moved AS (DELETE FROM queue WHERE created_at < now() - interval '1 day' RETURNING *) INSERT INTO queue_archive SELECT * FROM moved; +WITH ins AS (INSERT INTO users (email) VALUES ('x@y.z') RETURNING id) INSERT INTO profiles (user_id) SELECT id FROM ins; +WITH upd AS (UPDATE accounts SET balance = balance - 100 WHERE id = 1 RETURNING id) UPDATE accounts SET balance = balance + 100 WHERE id = 2 AND EXISTS (SELECT 1 FROM upd); +WITH a AS MATERIALIZED (SELECT * FROM big_table WHERE x > 1), b AS NOT MATERIALIZED (SELECT * FROM a WHERE y < 5) SELECT * FROM b JOIN a USING (id); +WITH s AS (SELECT id, ROW_NUMBER() OVER () AS rn FROM t) SELECT * FROM s WHERE rn BETWEEN 10 AND 20; +WITH RECURSIVE r AS (SELECT 1 AS n UNION SELECT n + 1 FROM r WHERE n < 5) CYCLE n SET is_cycle USING path SELECT * FROM r; +WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 5) SEARCH DEPTH FIRST BY n SET ordercol SELECT * FROM r ORDER BY ordercol; +INSERT INTO target (id, name) SELECT id, name FROM source WHERE NOT EXISTS (SELECT 1 FROM target WHERE target.id = source.id); +INSERT INTO target DEFAULT VALUES; +INSERT INTO target (id, name) OVERRIDING SYSTEM VALUE VALUES (1, 'x'); +INSERT INTO t (a) SELECT x FROM generate_series(1, 1000) AS x ON CONFLICT DO NOTHING; +UPDATE t SET (a, b) = (1, 2) WHERE id = 5; +UPDATE t SET (a, b) = (SELECT x, y FROM u WHERE u.id = t.id) WHERE EXISTS (SELECT 1 FROM u WHERE u.id = t.id); +UPDATE t SET data = jsonb_set(data, '{count}', ((data->>'count')::int + 1)::text::jsonb) WHERE id = 7; +UPDATE t AS x SET val = y.val FROM (SELECT id, val FROM src) AS y WHERE x.id = y.id; +UPDATE t SET tags = array_append(tags, 'new'), n = n + 1, updated_at = DEFAULT WHERE 'old' <> ALL(tags); +UPDATE ONLY t SET x = 1 WHERE CURRENT_DATE > d; +DELETE FROM t WHERE id IN (SELECT id FROM t ORDER BY created_at LIMIT 1000); +DELETE FROM t AS x WHERE x.created_at < now() - interval '1 year' RETURNING x.id AS deleted_id; +MERGE INTO inventory AS i USING shipments AS s ON i.sku = s.sku WHEN MATCHED THEN UPDATE SET qty = i.qty + s.qty WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty); +MERGE INTO customers c USING staging s ON c.id = s.id WHEN MATCHED AND s.deleted THEN DELETE WHEN MATCHED THEN UPDATE SET name = s.name WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.name); +MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY SOURCE THEN DELETE; +MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DO NOTHING WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id) RETURNING merge_action(), t.*; +SELECT * FROM t1 WHERE id IN (WITH x AS (SELECT 1 AS id) SELECT id FROM x); +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT id FROM t) a) b) c; +WITH a AS (SELECT 1 AS x), b AS (SELECT x + 1 AS x FROM a), c AS (SELECT x + 1 AS x FROM b), d AS (SELECT x + 1 AS x FROM c), e AS (SELECT x FROM d UNION ALL SELECT x FROM c) SELECT e.x, a.x FROM e CROSS JOIN a; +WITH t AS (SELECT * FROM generate_series(1, 5) AS n) SELECT n, (SELECT SUM(n) FROM t t2 WHERE t2.n <= t.n) FROM t; diff --git a/testdata/realworld/postgresql/07_admin_ops.sql b/testdata/realworld/postgresql/07_admin_ops.sql new file mode 100644 index 00000000..659aab43 --- /dev/null +++ b/testdata/realworld/postgresql/07_admin_ops.sql @@ -0,0 +1,46 @@ +-- Admin, catalog, transaction control and session statements +BEGIN; +BEGIN ISOLATION LEVEL SERIALIZABLE; +BEGIN TRANSACTION READ ONLY; +COMMIT; +ROLLBACK; +SAVEPOINT sp1; +ROLLBACK TO SAVEPOINT sp1; +RELEASE SAVEPOINT sp1; +SET search_path TO public, analytics; +SET LOCAL statement_timeout = '5s'; +SET SESSION TIME ZONE 'UTC'; +SET lock_timeout = 0; +RESET ALL; +SHOW server_version; +EXPLAIN SELECT * FROM users WHERE id = 1; +EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE user_id = 5; +EXPLAIN ANALYZE UPDATE t SET x = 1; +GRANT SELECT, INSERT ON users TO app_rw; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO admin_role; +REVOKE UPDATE ON orders FROM readonly; +CREATE ROLE app_user LOGIN PASSWORD 'secret'; +SELECT pg_size_pretty(pg_total_relation_size('orders')), pg_relation_size('orders'); +SELECT schemaname, relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20; +SELECT pid, usename, state, now() - query_start AS runtime, query FROM pg_stat_activity WHERE state <> 'idle' AND pid <> pg_backend_pid() ORDER BY runtime DESC; +SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'mydb' AND state = 'idle in transaction'; +SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod) FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND a.attnum > 0 AND NOT a.attisdropped; +SELECT table_name, column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position; +SELECT indexrelid::regclass, indisunique FROM pg_index WHERE indrelid = 'orders'::regclass; +SELECT nspname || '.' || relname AS name FROM pg_class JOIN pg_namespace ON pg_namespace.oid = relnamespace WHERE relkind = 'r'; +SELECT pg_advisory_lock(12345), pg_try_advisory_xact_lock(1, 2); +SELECT nextval('order_seq'), currval('order_seq'), setval('order_seq', 1000); +SELECT set_config('app.current_user', '42', true); +SELECT pg_notify('channel', 'payload'); +LISTEN channel; +NOTIFY channel, 'hello'; +ANALYZE users; +LOCK TABLE users IN ACCESS EXCLUSIVE MODE; +PREPARE stmt (int) AS SELECT * FROM users WHERE id = $1; +EXECUTE stmt(5); +DEALLOCATE stmt; +DO $$ BEGIN RAISE NOTICE 'hi'; END $$; +CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.tenant')::int); +CREATE OR REPLACE FUNCTION touch() RETURNS trigger AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; +CREATE TRIGGER trg_touch BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION touch(); +CREATE FUNCTION add(a int, b int) RETURNS int LANGUAGE sql IMMUTABLE AS $$ SELECT a + b $$; diff --git a/testdata/realworld/postgresql/08_supabase_rls_misc.sql b/testdata/realworld/postgresql/08_supabase_rls_misc.sql new file mode 100644 index 00000000..1ceb521f --- /dev/null +++ b/testdata/realworld/postgresql/08_supabase_rls_misc.sql @@ -0,0 +1,30 @@ +-- Supabase-flavoured and multi-tenant SaaS queries +SELECT * FROM profiles WHERE id = auth.uid(); +SELECT * FROM public.posts p WHERE p.org_id = (current_setting('request.jwt.claims', true)::json->>'org_id')::uuid; +SELECT p.id, p.title, jsonb_build_object('id', a.id, 'name', a.name) AS author FROM posts p JOIN authors a ON a.id = p.author_id WHERE p.published = true ORDER BY p.created_at DESC LIMIT 20; +SELECT id, title, ts_headline('english', body, q) FROM posts, websearch_to_tsquery('english', 'postgres tips') AS q WHERE fts @@ q ORDER BY ts_rank_cd(fts, q) DESC; +SELECT id, embedding <=> '[0.1, 0.2, 0.3]'::vector AS distance FROM documents ORDER BY embedding <=> '[0.1, 0.2, 0.3]' LIMIT 5; +INSERT INTO storage.objects (bucket_id, name, owner) VALUES ('avatars', 'a.png', auth.uid()) RETURNING *; +UPDATE public.profiles SET metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('seen', now()) WHERE id = auth.uid(); +SELECT t.id, t.name, COALESCE(json_agg(m.*) FILTER (WHERE m.id IS NOT NULL), '[]') AS members FROM teams t LEFT JOIN members m ON m.team_id = t.id GROUP BY t.id; +SELECT tenant_id, count(*) AS n, sum(amount) AS total FROM invoices WHERE issued_at >= date_trunc('quarter', current_date) GROUP BY tenant_id HAVING sum(amount) > 0 ORDER BY total DESC; +SELECT to_char(created_at, 'YYYY-MM-DD HH24:MI:SS'), to_char(total, 'FM999,999.00'), to_date('2024-01-02', 'YYYY-MM-DD'), to_timestamp(1700000000) FROM t; +SELECT lpad(id::text, 8, '0'), upper(name), left(name, 3), right(name, 2), substring(name FROM 2 FOR 3), substring(name, '[a-z]+'), position('a' IN name), overlay(name PLACING 'x' FROM 2 FOR 1), trim(BOTH ' ' FROM name), length(name), char_length(name) FROM t; +SELECT round(avg(x)::numeric, 2), ceil(x), floor(x), abs(x), mod(x, 3), power(x, 2), sqrt(x), trunc(x, 1), sign(x) FROM t; +SELECT CURRENT_DATE, CURRENT_TIME, LOCALTIMESTAMP, clock_timestamp(), statement_timestamp(), transaction_timestamp(); +SELECT id FROM t WHERE name IS NULL OR name = '' OR name IN ('a', 'b') OR id NOT IN (1, 2, 3) OR created_at NOT BETWEEN '2024-01-01' AND '2024-12-31'; +SELECT id, name FROM t WHERE NOT (a AND b) OR c AND NOT d; +SELECT a + b * c - d / e % f FROM t; +SELECT -x, +x, NOT flag, x IS TRUE, y IS NOT FALSE, z IS UNKNOWN FROM t; +SELECT * FROM t WHERE a = 1 AND (b = 2 OR c = 3) AND d LIKE 'x%' ESCAPE '\'; +SELECT t.* FROM t JOIN u ON t.id = u.id AND u.kind = 'x' JOIN v ON v.uid = u.id WHERE t.a > 1; +SELECT t.id FROM t JOIN (SELECT id, MAX(ts) AS mx FROM log GROUP BY id) l ON l.id = t.id AND l.mx > t.updated_at; +SELECT * FROM t1 JOIN t2 USING (id) JOIN t3 USING (id, other); +SELECT COUNT(DISTINCT (a, b)) FROM t; +SELECT x FROM t GROUP BY x HAVING COUNT(*) > 1 AND MAX(y) < 10 ORDER BY x LIMIT 3; +SELECT 1 AS "select", 2 AS "order", "user", "group" FROM "table"; +SELECT s.*, (s.a).field FROM s; +SELECT * FROM t WHERE x::text LIKE 'a%' AND (y)::int > 3 AND z::jsonb->>'k' = 'v'; +SELECT array(SELECT id FROM t), array_to_string(ARRAY[1, 2], ','), string_to_array('a,b', ','), ARRAY(SELECT 1 UNION SELECT 2); +SELECT CASE status WHEN 'a' THEN 1 WHEN 'b' THEN 2 ELSE 0 END FROM t; +SELECT ARRAY[[1, 2], [3, 4]], '{{1,2},{3,4}}'::int[][]; diff --git a/testdata/realworld/snowflake/01_app_crud.sql b/testdata/realworld/snowflake/01_app_crud.sql new file mode 100644 index 00000000..18d32784 --- /dev/null +++ b/testdata/realworld/snowflake/01_app_crud.sql @@ -0,0 +1,23 @@ +-- ORM-generated CRUD for an e-commerce app on Snowflake +SELECT id, email, first_name, last_name, created_at FROM users WHERE id = 42; +SELECT "id", "email", "created_at" FROM "PUBLIC"."USERS" WHERE "email" = 'a@example.com' LIMIT 1; +SELECT * FROM orders WHERE user_id = 42 AND status IN ('pending', 'paid') ORDER BY created_at DESC LIMIT 20 OFFSET 40; +INSERT INTO users (id, email, first_name, last_name, created_at) VALUES (1001, 'jane@example.com', 'Jane', 'Doe', CURRENT_TIMESTAMP()); +INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (1, 10, 2, 19.99), (1, 11, 1, 5.50), (1, 12, 3, 7.25); +INSERT INTO audit_log (event, payload, created_at) SELECT 'login', OBJECT_CONSTRUCT('user', 42, 'ip', '10.0.0.1'), CURRENT_TIMESTAMP(); +UPDATE users SET last_login_at = CURRENT_TIMESTAMP(), login_count = login_count + 1 WHERE id = 42; +UPDATE orders SET status = 'shipped', shipped_at = CURRENT_TIMESTAMP() WHERE id IN (SELECT order_id FROM shipments WHERE carrier = 'UPS'); +UPDATE products SET price = products.price * 1.05 FROM categories c WHERE products.category_id = c.id AND c.name = 'Books'; +DELETE FROM sessions WHERE expires_at < DATEADD(day, -7, CURRENT_TIMESTAMP()); +DELETE FROM cart_items USING carts WHERE cart_items.cart_id = carts.id AND carts.abandoned = TRUE; +SELECT u.id, u.email, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email HAVING COUNT(o.id) > 5 ORDER BY order_count DESC; +SELECT p.id, p.name, c.name AS category FROM products p INNER JOIN categories c ON c.id = p.category_id WHERE p.active AND p.price BETWEEN 10 AND 100; +SELECT * FROM users WHERE email ILIKE '%@example.com' AND deleted_at IS NULL; +SELECT COUNT(*) FROM orders WHERE created_at >= '2024-01-01'::date AND created_at < '2025-01-01'::date; +SELECT id, COALESCE(nickname, first_name, 'anonymous') AS display_name FROM users; +SELECT id, CASE WHEN total > 1000 THEN 'large' WHEN total > 100 THEN 'medium' ELSE 'small' END AS size_bucket FROM orders; +SELECT DISTINCT country FROM addresses WHERE country IS NOT NULL ORDER BY 1; +SELECT TOP 10 id, email FROM users ORDER BY created_at DESC; +INSERT OVERWRITE INTO daily_snapshot SELECT * FROM staging_snapshot; +TRUNCATE TABLE staging_events; +SELECT * FROM products WHERE NOT (price < 5 OR stock = 0) AND name NOT LIKE 'tmp%'; diff --git a/testdata/realworld/snowflake/02_reporting.sql b/testdata/realworld/snowflake/02_reporting.sql new file mode 100644 index 00000000..0c130e4a --- /dev/null +++ b/testdata/realworld/snowflake/02_reporting.sql @@ -0,0 +1,28 @@ +-- BI dashboards and reports: revenue, cohorts, funnels +SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue, COUNT(DISTINCT customer_id) AS customers FROM orders GROUP BY 1 ORDER BY 1; +SELECT region, product_line, SUM(revenue) AS revenue FROM sales GROUP BY ROLLUP (region, product_line); +SELECT region, product_line, SUM(revenue) AS revenue FROM sales GROUP BY CUBE (region, product_line); +SELECT region, product_line, SUM(revenue) FROM sales GROUP BY GROUPING SETS ((region, product_line), (region), ()); +SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY ALL; +SELECT customer_id, SUM(amount) AS total FROM payments GROUP BY customer_id HAVING SUM(amount) > 10000 ORDER BY total DESC LIMIT 100; +WITH monthly AS (SELECT DATE_TRUNC('month', created_at) AS m, SUM(amount) AS amt FROM payments GROUP BY 1) SELECT m, amt, amt - LAG(amt) OVER (ORDER BY m) AS delta FROM monthly ORDER BY m; +WITH first_orders AS (SELECT customer_id, MIN(order_date) AS first_date FROM orders GROUP BY customer_id), cohorts AS (SELECT customer_id, DATE_TRUNC('month', first_date) AS cohort_month FROM first_orders), activity AS (SELECT o.customer_id, c.cohort_month, DATEDIFF('month', c.cohort_month, DATE_TRUNC('month', o.order_date)) AS month_number FROM orders o JOIN cohorts c ON c.customer_id = o.customer_id) SELECT cohort_month, month_number, COUNT(DISTINCT customer_id) AS active_customers FROM activity GROUP BY 1, 2 ORDER BY 1, 2; +SELECT COUNT_IF(status = 'completed') AS completed, COUNT_IF(status = 'failed') AS failed, COUNT(*) AS total FROM jobs; +SELECT user_id, LISTAGG(DISTINCT tag, ', ') WITHIN GROUP (ORDER BY tag) AS tags FROM user_tags GROUP BY user_id; +SELECT ARRAY_AGG(product_id) WITHIN GROUP (ORDER BY product_id) AS products FROM order_items WHERE order_id = 7; +SELECT APPROX_COUNT_DISTINCT(user_id) AS dau FROM events WHERE event_date = CURRENT_DATE(); +SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY latency_ms) AS p50, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95 FROM requests; +SELECT MEDIAN(amount) AS median_amount, AVG(amount) AS avg_amount, STDDEV(amount) AS sd FROM payments; +SELECT d.dept_name, e.name, e.salary FROM employees e JOIN departments d ON d.id = e.dept_id WHERE e.salary > (SELECT AVG(salary) FROM employees WHERE dept_id = e.dept_id); +SELECT a.name, b.name FROM a FULL OUTER JOIN b ON a.key = b.key WHERE a.key IS NULL OR b.key IS NULL; +SELECT c.name, o.order_date FROM customers c CROSS JOIN (SELECT DISTINCT order_date FROM orders) o; +SELECT * FROM orders o LEFT JOIN customers c USING (customer_id); +SELECT * FROM t1 NATURAL JOIN t2; +SELECT order_id FROM orders UNION ALL SELECT order_id FROM archived_orders; +SELECT id FROM a INTERSECT SELECT id FROM b; +SELECT id FROM a MINUS SELECT id FROM b; +SELECT id FROM a EXCEPT SELECT id FROM b; +SELECT TO_CHAR(order_date, 'YYYY-MM') AS ym, SUM(total) FROM orders GROUP BY 1; +SELECT DAYNAME(order_date) AS dow, COUNT(*) FROM orders GROUP BY 1 ORDER BY 2 DESC; +SELECT TIMESTAMPDIFF(minute, started_at, ended_at) AS duration_min FROM sessions; +SELECT year, quarter, revenue, SUM(revenue) OVER (PARTITION BY year ORDER BY quarter ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ytd FROM quarterly; diff --git a/testdata/realworld/snowflake/03_ddl_migrations.sql b/testdata/realworld/snowflake/03_ddl_migrations.sql new file mode 100644 index 00000000..e9d5db27 --- /dev/null +++ b/testdata/realworld/snowflake/03_ddl_migrations.sql @@ -0,0 +1,38 @@ +-- Schema migrations and DDL as run by migration tools +CREATE TABLE users (id NUMBER(38,0) NOT NULL, email VARCHAR(255) NOT NULL, first_name VARCHAR, created_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(), CONSTRAINT pk_users PRIMARY KEY (id), CONSTRAINT uq_users_email UNIQUE (email)); +CREATE TABLE IF NOT EXISTS orders (id NUMBER AUTOINCREMENT START 1 INCREMENT 1, user_id NUMBER NOT NULL, total NUMBER(12,2), status VARCHAR(20) DEFAULT 'pending', placed_at TIMESTAMP_TZ, FOREIGN KEY (user_id) REFERENCES users (id)); +CREATE OR REPLACE TABLE events (event_id VARCHAR, payload VARIANT, tags ARRAY, props OBJECT, event_ts TIMESTAMP_LTZ) CLUSTER BY (event_ts); +CREATE TRANSIENT TABLE staging_events (raw VARIANT); +CREATE TEMPORARY TABLE tmp_ids (id NUMBER); +CREATE TABLE sales_clone CLONE sales; +CREATE OR REPLACE TABLE sales_backup CLONE sales AT (TIMESTAMP => '2024-05-01 00:00:00'::timestamp_ltz); +CREATE TABLE sales_before CLONE sales BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726'); +CREATE TABLE order_summary AS SELECT user_id, COUNT(*) AS n, SUM(total) AS total FROM orders GROUP BY user_id; +CREATE OR REPLACE TABLE dim_date AS SELECT DATEADD(day, SEQ4(), '2020-01-01'::date) AS d FROM TABLE(GENERATOR(ROWCOUNT => 3650)); +CREATE TABLE like_users LIKE users; +CREATE OR REPLACE VIEW v_active_users AS SELECT id, email FROM users WHERE deleted_at IS NULL; +CREATE OR REPLACE SECURE VIEW v_orders_secure AS SELECT id, user_id, total FROM orders; +CREATE MATERIALIZED VIEW mv_daily_sales AS SELECT order_date, SUM(total) AS total FROM orders GROUP BY order_date; +CREATE VIEW v_with_cols (a, b) AS SELECT 1, 2; +ALTER TABLE users ADD COLUMN phone VARCHAR(32); +ALTER TABLE users ADD COLUMN IF NOT EXISTS locale VARCHAR(10) DEFAULT 'en'; +ALTER TABLE users DROP COLUMN phone; +ALTER TABLE users RENAME COLUMN first_name TO given_name; +ALTER TABLE users RENAME TO app_users; +ALTER TABLE orders ALTER COLUMN id SET DEFAULT order_seq.NEXTVAL; +ALTER TABLE orders ALTER COLUMN total SET NOT NULL; +ALTER TABLE orders ALTER COLUMN total DROP NOT NULL; +ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id); +ALTER TABLE events CLUSTER BY (event_ts, event_id); +ALTER TABLE events SET DATA_RETENTION_TIME_IN_DAYS = 7; +ALTER TABLE sales SWAP WITH sales_clone; +DROP TABLE IF EXISTS tmp_ids; +DROP VIEW IF EXISTS v_active_users; +DROP TABLE users CASCADE; +UNDROP TABLE users; +CREATE SCHEMA IF NOT EXISTS analytics; +CREATE DATABASE dev_db CLONE prod_db; +CREATE OR REPLACE SEQUENCE order_seq START = 1 INCREMENT = 1; +CREATE FILE FORMAT my_csv TYPE = 'CSV' FIELD_DELIMITER = ',' SKIP_HEADER = 1; +CREATE STAGE my_stage URL = 's3://bucket/path/' FILE_FORMAT = (TYPE = 'PARQUET'); +COMMENT ON TABLE users IS 'Application users'; diff --git a/testdata/realworld/snowflake/04_window_analytics.sql b/testdata/realworld/snowflake/04_window_analytics.sql new file mode 100644 index 00000000..308c1a9a --- /dev/null +++ b/testdata/realworld/snowflake/04_window_analytics.sql @@ -0,0 +1,19 @@ +-- Window function analytics, QUALIFY, and ranking +SELECT id, customer_id, total, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rn FROM orders; +SELECT * FROM orders QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) = 1; +SELECT customer_id, order_date, total FROM orders QUALIFY RANK() OVER (PARTITION BY customer_id ORDER BY total DESC) <= 3; +SELECT * FROM events QUALIFY ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY loaded_at DESC) = 1 ORDER BY event_id; +SELECT a, b, SUM(c) AS s FROM t GROUP BY a, b QUALIFY DENSE_RANK() OVER (PARTITION BY a ORDER BY SUM(c) DESC) = 1; +SELECT day, revenue, AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM daily_revenue; +SELECT day, revenue, LAG(revenue, 7) OVER (ORDER BY day) AS last_week, LEAD(revenue) OVER (ORDER BY day) AS next_day FROM daily_revenue; +SELECT id, FIRST_VALUE(price) OVER (PARTITION BY sku ORDER BY ts) AS first_price, LAST_VALUE(price) OVER (PARTITION BY sku ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_price FROM prices; +SELECT id, NTILE(4) OVER (ORDER BY score) AS quartile, PERCENT_RANK() OVER (ORDER BY score) AS pr, CUME_DIST() OVER (ORDER BY score) AS cd FROM scores; +SELECT id, NTH_VALUE(amount, 2) OVER (PARTITION BY grp ORDER BY ts) AS second_amount FROM t; +SELECT id, RATIO_TO_REPORT(amount) OVER (PARTITION BY grp) AS share FROM t; +SELECT id, LAG(amount) IGNORE NULLS OVER (PARTITION BY grp ORDER BY ts) AS prev_non_null FROM t; +SELECT id, FIRST_VALUE(amount) IGNORE NULLS OVER (ORDER BY ts) AS fv FROM t; +WITH ranked AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn FROM employees) SELECT * FROM ranked WHERE rn <= 3; +WITH dedup AS (SELECT * FROM raw_events QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) = 1), enriched AS (SELECT d.*, u.email FROM dedup d LEFT JOIN users u ON u.id = d.user_id) SELECT * FROM enriched WHERE email IS NOT NULL; +SELECT *, SUM(x) OVER () AS total, x / NULLIF(SUM(x) OVER (), 0) AS pct FROM t; +SELECT MIN_BY(name, score) AS worst, MAX_BY(name, score) AS best FROM players; +SELECT ANY_VALUE(name) AS n, MODE(country) AS mode_country FROM customers GROUP BY segment; diff --git a/testdata/realworld/snowflake/05_dialect_specific.sql b/testdata/realworld/snowflake/05_dialect_specific.sql new file mode 100644 index 00000000..bbc7fea4 --- /dev/null +++ b/testdata/realworld/snowflake/05_dialect_specific.sql @@ -0,0 +1,53 @@ +-- Snowflake-specific: VARIANT paths, FLATTEN, IFF, TRY_*, ILIKE ANY, SAMPLE, time travel +SELECT payload:user.id::number AS user_id, payload:user.name::string AS name FROM raw_events; +SELECT payload:items[0].sku::string AS first_sku, payload:items[1]:qty::int AS second_qty FROM raw_orders; +SELECT src:"customer id"::string AS cid, src['order']['total']::float AS total FROM raw_json; +SELECT GET_PATH(payload, 'a.b.c') AS deep, payload:a.b.c AS deep2 FROM t; +SELECT e.payload:id::string AS id, f.value:sku::string AS sku, f.index AS idx FROM raw_orders e, LATERAL FLATTEN(input => e.payload:items) f; +SELECT o.id, f.value:sku::string AS sku, f.value:qty::number AS qty FROM orders o, LATERAL FLATTEN(input => o.items, outer => TRUE) f; +SELECT f.key, f.value FROM TABLE(FLATTEN(input => PARSE_JSON('{"a":1,"b":2}'))) f; +SELECT t.id, f.value::string AS tag FROM tbl t, TABLE(FLATTEN(t.tags)) f; +SELECT id, f.path, f.value FROM events, LATERAL FLATTEN(input => payload, recursive => TRUE) f WHERE f.key = 'email'; +SELECT IFF(score >= 60, 'pass', 'fail') AS result, ZEROIFNULL(bonus) AS bonus, NULLIFZERO(qty) AS qty FROM exam; +SELECT TRY_TO_NUMBER(s) AS n, TRY_TO_DATE(d, 'YYYY-MM-DD') AS dt, TRY_TO_TIMESTAMP(ts) AS t, TRY_TO_BOOLEAN(b) AS bl, TRY_PARSE_JSON(j) AS js FROM raw_strings; +SELECT TO_VARIANT(x), TO_VARCHAR(y), TO_TIMESTAMP_NTZ(z), TO_DECIMAL(q, 10, 2) FROM t; +SELECT * FROM products WHERE name ILIKE ANY ('%shoe%', '%boot%'); +SELECT * FROM products WHERE name LIKE ANY ('a%', 'b%') AND sku NOT ILIKE ALL ('x%', 'y%'); +SELECT * FROM users WHERE email RLIKE '.*@example\\.com'; +SELECT * FROM users WHERE REGEXP_LIKE(email, '^[a-z]+@example\\.com$', 'i'); +SELECT * FROM orders AT (TIMESTAMP => '2024-06-01 12:00:00'::timestamp_tz); +SELECT * FROM orders AT (OFFSET => -60*5); +SELECT * FROM orders BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726'); +SELECT * FROM orders AT (TIMESTAMP => DATEADD(hour, -1, CURRENT_TIMESTAMP())) o JOIN customers c ON c.id = o.customer_id; +SELECT * FROM big_table SAMPLE (10); +SELECT * FROM big_table TABLESAMPLE BERNOULLI (5); +SELECT * FROM big_table SAMPLE SYSTEM (1) SEED (42); +SELECT * FROM big_table SAMPLE (1000 ROWS); +SELECT $1, $2, $3 FROM @my_stage/data.csv (FILE_FORMAT => 'my_csv'); +SELECT t.$1 AS id, t.$2 AS name FROM @my_stage/users/ (FILE_FORMAT => 'my_csv', PATTERN => '.*[.]csv') t; +SELECT $1:id::number AS id, $1:name::string AS name FROM @json_stage (FILE_FORMAT => 'my_json'); +SELECT METADATA$FILENAME, METADATA$FILE_ROW_NUMBER, $1 FROM @my_stage; +SELECT * FROM TABLE(INFORMATION_SCHEMA.QUERY_HISTORY(RESULT_LIMIT => 100)); +SELECT * FROM TABLE(RESULT_SCAN(LAST_QUERY_ID())); +SELECT * FROM TABLE(GENERATOR(ROWCOUNT => 100)); +SELECT SEQ4() AS n, UNIFORM(1, 100, RANDOM()) AS r FROM TABLE(GENERATOR(ROWCOUNT => 10)); +SELECT OBJECT_CONSTRUCT('a', 1, 'b', 'two') AS o, ARRAY_CONSTRUCT(1, 2, 3) AS a, OBJECT_INSERT(OBJECT_CONSTRUCT(), 'k', 'v') AS o2; +SELECT ARRAY_SIZE(payload:items) AS n, ARRAY_CONTAINS('x'::variant, tags) AS has_x, ARRAY_TO_STRING(tags, ',') AS s FROM t; +SELECT OBJECT_KEYS(payload) AS ks, TYPEOF(payload:id) AS ty, IS_NULL_VALUE(payload:x) AS nv FROM t; +SELECT CURRENT_ACCOUNT(), CURRENT_REGION(), CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA(); +SELECT DATEADD(day, 7, CURRENT_DATE()), DATEDIFF(day, '2024-01-01', '2024-02-01'), LAST_DAY(CURRENT_DATE(), 'month'), DATE_FROM_PARTS(2024, 2, 29); +SELECT CONVERT_TIMEZONE('UTC', 'America/Los_Angeles', ts) AS la_time FROM events; +SELECT ts::date AS d, ts::time AS t, '123'::number(10,2) AS n, x::variant AS v FROM t; +SELECT EDITDISTANCE(a, b), JAROWINKLER_SIMILARITY(a, b), SOUNDEX(a), SPLIT_PART(email, '@', 2), STRTOK(path, '/', 2) FROM t; +SELECT HASH(a, b), MD5(a), SHA2(a, 256), BITAND(a, b), BOOLAND_AGG(flag), BITOR_AGG(mask) FROM t GROUP BY g; +SELECT * FROM t WHERE col IN (SELECT value FROM TABLE(FLATTEN(input => SPLIT('a,b,c', ',')))); +SELECT $$multi +line string$$ AS s; +SELECT 'it''s' AS q, 1e3 AS sci, .5 AS frac; +SELECT * FROM "My Table" WHERE "Mixed Case Col" = 1; +SELECT a.* EXCLUDE (secret), b.* RENAME (id AS b_id) FROM a JOIN b ON a.id = b.a_id; +SELECT * EXCLUDE (ssn, dob) FROM customers; +SELECT * ILIKE '%name%' FROM customers; +SELECT * REPLACE (UPPER(name) AS name) FROM customers; +SELECT {'a': 1, 'b': 'two'} AS obj, [1, 2, 3] AS arr; +SELECT * FROM t1 ASOF JOIN t2 MATCH_CONDITION (t1.ts >= t2.ts) ON t1.sym = t2.sym; diff --git a/testdata/realworld/snowflake/06_etl_merge.sql b/testdata/realworld/snowflake/06_etl_merge.sql new file mode 100644 index 00000000..ce71c83f --- /dev/null +++ b/testdata/realworld/snowflake/06_etl_merge.sql @@ -0,0 +1,37 @@ +-- ETL / ELT: MERGE upserts, COPY INTO, staging, streams and tasks +MERGE INTO dim_customer t USING stg_customer s ON t.customer_id = s.customer_id WHEN MATCHED THEN UPDATE SET name = s.name, email = s.email, updated_at = CURRENT_TIMESTAMP() WHEN NOT MATCHED THEN INSERT (customer_id, name, email, updated_at) VALUES (s.customer_id, s.name, s.email, CURRENT_TIMESTAMP()); +MERGE INTO target t USING source s ON t.id = s.id WHEN MATCHED AND s.deleted THEN DELETE WHEN MATCHED THEN UPDATE SET val = s.val WHEN NOT MATCHED THEN INSERT (id, val) VALUES (s.id, s.val); +MERGE INTO inventory i USING (SELECT sku, SUM(qty) AS qty FROM shipments GROUP BY sku) s ON i.sku = s.sku WHEN MATCHED THEN UPDATE SET qty = i.qty + s.qty WHEN NOT MATCHED AND s.qty > 0 THEN INSERT VALUES (s.sku, s.qty); +MERGE INTO t USING s ON t.k = s.k WHEN NOT MATCHED THEN INSERT (k, v) VALUES (s.k, s.v); +COPY INTO raw_events FROM @my_stage/events/ FILE_FORMAT = (TYPE = 'JSON') ON_ERROR = 'CONTINUE'; +COPY INTO raw_users (id, name) FROM (SELECT $1, $2 FROM @my_stage/users.csv) FILE_FORMAT = (FORMAT_NAME = 'my_csv'); +COPY INTO @export_stage/out/ FROM (SELECT * FROM orders WHERE order_date = CURRENT_DATE()) FILE_FORMAT = (TYPE = 'PARQUET') HEADER = TRUE OVERWRITE = TRUE; +COPY INTO mytable FROM @mystage PATTERN = '.*[.]csv' FORCE = TRUE; +INSERT INTO fact_orders SELECT o.id, o.customer_id, o.total, d.date_key FROM stg_orders o JOIN dim_date d ON d.d = o.order_date::date; +INSERT INTO t (a, b) SELECT a, b FROM s WHERE NOT EXISTS (SELECT 1 FROM t WHERE t.a = s.a); +INSERT OVERWRITE INTO report_daily SELECT CURRENT_DATE(), COUNT(*) FROM orders; +INSERT INTO events_json SELECT PARSE_JSON('{"a": 1}'); +INSERT INTO t SELECT * FROM TABLE(FLATTEN(input => ARRAY_CONSTRUCT(1, 2, 3))); +CREATE OR REPLACE STREAM orders_stream ON TABLE orders; +CREATE OR REPLACE STREAM s2 ON TABLE orders APPEND_ONLY = TRUE; +SELECT * FROM orders_stream WHERE METADATA$ACTION = 'INSERT'; +CREATE OR REPLACE TASK refresh_summary WAREHOUSE = etl_wh SCHEDULE = 'USING CRON 0 * * * * UTC' AS INSERT OVERWRITE INTO summary SELECT region, SUM(amount) FROM sales GROUP BY region; +CREATE TASK t2 WAREHOUSE = w AFTER refresh_summary AS CALL my_proc(); +ALTER TASK refresh_summary RESUME; +ALTER TASK refresh_summary SUSPEND; +CREATE OR REPLACE PIPE my_pipe AUTO_INGEST = TRUE AS COPY INTO raw_events FROM @my_stage; +CREATE OR REPLACE DYNAMIC TABLE dt_sales TARGET_LAG = '5 minutes' WAREHOUSE = etl_wh AS SELECT region, SUM(amount) AS total FROM sales GROUP BY region; +CREATE OR REPLACE TABLE dedup_events AS SELECT * FROM raw_events QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) = 1; +UPDATE target SET status = s.status FROM source s WHERE target.id = s.id AND target.status <> s.status; +DELETE FROM target WHERE id IN (SELECT id FROM source WHERE op = 'D'); +LIST @my_stage; +REMOVE @my_stage/old/; +PUT file:///tmp/data.csv @my_stage AUTO_COMPRESS = TRUE; +GET @my_stage/out.csv file:///tmp/; +CALL my_proc(1, 'a'); +CALL db.schema.sp_load('2024-01-01'::date); +EXECUTE IMMEDIATE 'SELECT 1'; +BEGIN; +INSERT INTO t VALUES (1); +COMMIT; +ROLLBACK; diff --git a/testdata/realworld/snowflake/07_admin.sql b/testdata/realworld/snowflake/07_admin.sql new file mode 100644 index 00000000..502f9d93 --- /dev/null +++ b/testdata/realworld/snowflake/07_admin.sql @@ -0,0 +1,54 @@ +-- Admin / ops queries: grants, warehouses, session, metadata +USE ROLE SYSADMIN; +USE WAREHOUSE compute_wh; +USE DATABASE analytics; +USE SCHEMA analytics.public; +USE analytics.public; +SHOW TABLES; +SHOW TABLES IN SCHEMA analytics.public; +SHOW TABLES LIKE 'ORD%' IN DATABASE analytics; +SHOW SCHEMAS IN DATABASE analytics; +SHOW COLUMNS IN TABLE orders; +SHOW GRANTS TO ROLE analyst; +SHOW GRANTS ON TABLE orders; +SHOW WAREHOUSES; +SHOW USERS; +SHOW PARAMETERS LIKE 'TIMEZONE' IN SESSION; +SHOW VIEWS; +SHOW TASKS; +SHOW STAGES; +DESCRIBE TABLE orders; +DESC VIEW v_orders; +DESCRIBE STAGE my_stage; +CREATE WAREHOUSE IF NOT EXISTS etl_wh WITH WAREHOUSE_SIZE = 'XSMALL' AUTO_SUSPEND = 60 AUTO_RESUME = TRUE INITIALLY_SUSPENDED = TRUE; +ALTER WAREHOUSE etl_wh SET WAREHOUSE_SIZE = 'LARGE'; +ALTER WAREHOUSE etl_wh SUSPEND; +CREATE ROLE analyst; +GRANT ROLE analyst TO USER jdoe; +GRANT USAGE ON WAREHOUSE compute_wh TO ROLE analyst; +GRANT USAGE ON DATABASE analytics TO ROLE analyst; +GRANT SELECT ON ALL TABLES IN SCHEMA analytics.public TO ROLE analyst; +GRANT SELECT ON FUTURE TABLES IN SCHEMA analytics.public TO ROLE analyst; +GRANT ALL PRIVILEGES ON TABLE orders TO ROLE etl_role; +REVOKE SELECT ON TABLE orders FROM ROLE analyst; +CREATE USER jdoe PASSWORD = 'Temp#1234' DEFAULT_ROLE = analyst MUST_CHANGE_PASSWORD = TRUE; +ALTER USER jdoe SET DISABLED = TRUE; +ALTER SESSION SET TIMEZONE = 'UTC'; +ALTER SESSION SET QUERY_TAG = 'nightly_etl'; +ALTER SESSION SET TIMESTAMP_OUTPUT_FORMAT = 'YYYY-MM-DD HH24:MI:SS'; +SET my_var = 10; +SELECT $my_var + 1; +SELECT * FROM IDENTIFIER('orders') LIMIT 5; +SELECT * FROM snowflake.account_usage.query_history WHERE start_time > DATEADD(day, -1, CURRENT_TIMESTAMP()) ORDER BY total_elapsed_time DESC LIMIT 50; +SELECT warehouse_name, SUM(credits_used) AS credits FROM snowflake.account_usage.warehouse_metering_history WHERE start_time >= DATEADD(month, -1, CURRENT_DATE()) GROUP BY 1 ORDER BY 2 DESC; +SELECT table_schema, table_name, row_count, bytes / POWER(1024, 3) AS gb FROM information_schema.tables WHERE table_type = 'BASE TABLE' ORDER BY bytes DESC; +SELECT SYSTEM$CLUSTERING_INFORMATION('orders', '(order_date)'); +SELECT SYSTEM$TYPEOF(1); +SELECT GET_DDL('TABLE', 'orders'); +ALTER ACCOUNT SET TIMEZONE = 'UTC'; +ALTER TABLE orders ADD ROW ACCESS POLICY rap ON (region); +CREATE OR REPLACE MASKING POLICY email_mask AS (val STRING) RETURNS STRING -> CASE WHEN CURRENT_ROLE() IN ('ADMIN') THEN val ELSE '***' END; +ALTER TABLE users MODIFY COLUMN email SET MASKING POLICY email_mask; +CREATE TAG pii_tag; +ALTER TABLE users SET TAG pii_tag = 'true'; +EXPLAIN SELECT * FROM orders; diff --git a/testdata/realworld/snowflake/08_pivot_match.sql b/testdata/realworld/snowflake/08_pivot_match.sql new file mode 100644 index 00000000..73ce3b56 --- /dev/null +++ b/testdata/realworld/snowflake/08_pivot_match.sql @@ -0,0 +1,24 @@ +-- PIVOT, UNPIVOT, MATCH_RECOGNIZE, set-returning and semi-structured reshaping +SELECT * FROM monthly_sales PIVOT (SUM(amount) FOR month IN ('JAN', 'FEB', 'MAR')) AS p; +SELECT * FROM monthly_sales PIVOT (SUM(amount) FOR month IN ('JAN', 'FEB', 'MAR', 'APR')) AS p (emp_id, jan, feb, mar, apr); +SELECT * FROM quarterly_sales PIVOT (AVG(amount) FOR quarter IN (ANY ORDER BY quarter)); +SELECT * FROM (SELECT empid, month, amount FROM monthly_sales) PIVOT (SUM(amount) FOR month IN ('JAN', 'FEB')) ORDER BY empid; +SELECT * FROM monthly_sales UNPIVOT (sales FOR month IN (jan, feb, mar)) ORDER BY empid; +SELECT * FROM monthly_sales UNPIVOT INCLUDE NULLS (sales FOR month IN (jan, feb)) AS u; +SELECT * FROM stock_price MATCH_RECOGNIZE (PARTITION BY symbol ORDER BY ts MEASURES MATCH_NUMBER() AS match_num, FIRST(ts) AS start_ts, LAST(ts) AS end_ts ONE ROW PER MATCH AFTER MATCH SKIP PAST LAST ROW PATTERN (down+ up+) DEFINE down AS price < PREV(price), up AS price > PREV(price)); +SELECT * FROM events MATCH_RECOGNIZE (PARTITION BY user_id ORDER BY ts MEASURES COUNT(*) AS n ALL ROWS PER MATCH PATTERN (a b* c) DEFINE a AS action = 'view', b AS action = 'click', c AS action = 'buy') ORDER BY user_id; +SELECT region, SUM(CASE WHEN product = 'A' THEN amount ELSE 0 END) AS a_total, SUM(CASE WHEN product = 'B' THEN amount ELSE 0 END) AS b_total FROM sales GROUP BY region; +SELECT * FROM TABLE(SPLIT_TO_TABLE('a b c', ' ')); +SELECT s.seq, s.index, s.value FROM TABLE(SPLIT_TO_TABLE('x,y,z', ',')) s; +SELECT t.id, s.value FROM t, LATERAL SPLIT_TO_TABLE(t.csv, ',') s; +SELECT * FROM TABLE(FLATTEN(INPUT => ARRAY_CONSTRUCT('a', 'b'))) WHERE value::string <> 'a'; +SELECT * FROM VALUES (1, 'a'), (2, 'b') AS v (id, name); +SELECT column1, column2 FROM (VALUES (1, 'x'), (2, 'y')); +SELECT * FROM TABLE(my_udtf(1, 'a')); +SELECT * FROM my_table, LATERAL (SELECT * FROM other WHERE other.k = my_table.k) l; +SELECT OBJECT_AGG(k, v) AS obj FROM kv GROUP BY grp; +SELECT ARRAY_AGG(OBJECT_CONSTRUCT('id', id, 'name', name)) AS arr FROM users; +SELECT ARRAY_SORT(ARRAY_CONSTRUCT(3, 1, 2)), ARRAY_SLICE(ARRAY_CONSTRUCT(1, 2, 3, 4), 1, 3), ARRAY_DISTINCT(tags) FROM t; +SELECT t.v:a[0]:b::string AS x, ARRAY_SIZE(t.v:a) AS n FROM t; +SELECT PARSE_JSON(raw):meta.ts::timestamp_ntz AS ts FROM landing; +SELECT payload:data[0].attributes."first-name"::string AS fname FROM t; diff --git a/testdata/realworld/snowflake/09_dbt_models.sql b/testdata/realworld/snowflake/09_dbt_models.sql new file mode 100644 index 00000000..2052877d --- /dev/null +++ b/testdata/realworld/snowflake/09_dbt_models.sql @@ -0,0 +1,19 @@ +-- dbt-style models (Jinja already rendered): staging, intermediate, marts +WITH source AS (SELECT * FROM raw.stripe.payments), renamed AS (SELECT id AS payment_id, orderid AS order_id, paymentmethod AS payment_method, status, amount / 100 AS amount, created AS created_at FROM source) SELECT * FROM renamed; +WITH customers AS (SELECT * FROM analytics.staging.stg_customers), orders AS (SELECT * FROM analytics.staging.stg_orders), customer_orders AS (SELECT customer_id, MIN(order_date) AS first_order, MAX(order_date) AS most_recent_order, COUNT(order_id) AS number_of_orders FROM orders GROUP BY customer_id), final AS (SELECT customers.customer_id, customers.first_name, customers.last_name, customer_orders.first_order, customer_orders.most_recent_order, COALESCE(customer_orders.number_of_orders, 0) AS number_of_orders FROM customers LEFT JOIN customer_orders USING (customer_id)) SELECT * FROM final; +CREATE OR REPLACE TABLE analytics.marts.fct_orders AS WITH orders AS (SELECT * FROM analytics.staging.stg_orders), payments AS (SELECT * FROM analytics.staging.stg_payments), order_payments AS (SELECT order_id, SUM(CASE WHEN payment_method = 'credit_card' THEN amount ELSE 0 END) AS credit_card_amount, SUM(CASE WHEN payment_method = 'bank_transfer' THEN amount ELSE 0 END) AS bank_transfer_amount, SUM(amount) AS total_amount FROM payments WHERE status = 'success' GROUP BY order_id) SELECT o.order_id, o.customer_id, o.order_date, op.credit_card_amount, op.bank_transfer_amount, op.total_amount AS amount FROM orders o LEFT JOIN order_payments op ON o.order_id = op.order_id; +CREATE OR REPLACE VIEW analytics.staging.stg_customers AS SELECT id AS customer_id, first_name, last_name FROM raw.jaffle_shop.customers; +SELECT date_day, COUNT(*) AS n FROM (SELECT DATEADD(day, ROW_NUMBER() OVER (ORDER BY SEQ4()) - 1, '2020-01-01'::date) AS date_day FROM TABLE(GENERATOR(ROWCOUNT => 1000))) GROUP BY 1; +SELECT MD5(CAST(COALESCE(CAST(customer_id AS VARCHAR), '') || '-' || COALESCE(CAST(order_id AS VARCHAR), '') AS VARCHAR)) AS surrogate_key, * FROM analytics.staging.stg_orders; +SELECT * FROM analytics.marts.fct_orders WHERE order_date > (SELECT MAX(order_date) FROM analytics.marts.fct_orders_incremental); +DELETE FROM analytics.marts.fct_orders_incremental WHERE (order_id) IN (SELECT order_id FROM analytics.marts.fct_orders_incremental__tmp); +INSERT INTO analytics.marts.fct_orders_incremental (order_id, customer_id, order_date) SELECT order_id, customer_id, order_date FROM analytics.marts.fct_orders_incremental__tmp; +SELECT COUNT(*) AS failures, COUNT(*) != 0 AS should_warn FROM (SELECT customer_id FROM analytics.marts.dim_customers WHERE customer_id IS NULL) dbt_internal_test; +SELECT customer_id AS unique_field, COUNT(*) AS n_records FROM analytics.marts.dim_customers WHERE customer_id IS NOT NULL GROUP BY customer_id HAVING COUNT(*) > 1; +SELECT a.*, b.* FROM (SELECT 1 AS id) a LEFT JOIN (SELECT 1 AS id) b ON a.id = b.id WHERE b.id IS NULL; +CREATE OR REPLACE TRANSIENT TABLE analytics.snapshots.orders_snapshot AS SELECT *, CURRENT_TIMESTAMP() AS dbt_valid_from, NULL::timestamp_ntz AS dbt_valid_to FROM analytics.staging.stg_orders; +SELECT order_id, status, LAG(status) OVER (PARTITION BY order_id ORDER BY updated_at) AS prev_status FROM analytics.staging.stg_order_events QUALIFY status != prev_status; +SELECT DATE_TRUNC('week', order_date)::date AS week_start, SUM(amount) AS weekly_amount, SUM(SUM(amount)) OVER (ORDER BY DATE_TRUNC('week', order_date)) AS cumulative FROM analytics.marts.fct_orders GROUP BY 1; +ALTER TABLE analytics.marts.fct_orders SWAP WITH analytics.marts.fct_orders__dbt_tmp; +DROP TABLE IF EXISTS analytics.marts.fct_orders__dbt_tmp CASCADE; +GRANT SELECT ON TABLE analytics.marts.fct_orders TO ROLE reporter; diff --git a/testdata/realworld/snowflake/10_complex_nested.sql b/testdata/realworld/snowflake/10_complex_nested.sql new file mode 100644 index 00000000..ec2464c9 --- /dev/null +++ b/testdata/realworld/snowflake/10_complex_nested.sql @@ -0,0 +1,41 @@ +-- Deeply nested subqueries, long CTE chains, many joins, expression-heavy queries +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT id, name FROM users WHERE active) a WHERE id > 10) b WHERE id < 1000) c ORDER BY id) d LIMIT 10; +SELECT o.id, c.name, p.name, s.name, w.name, r.name FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id JOIN suppliers s ON s.id = p.supplier_id JOIN warehouses w ON w.id = oi.warehouse_id LEFT JOIN regions r ON r.id = w.region_id LEFT JOIN promotions pr ON pr.id = o.promo_id WHERE o.status <> 'cancelled' AND (pr.id IS NULL OR pr.expires_at > o.created_at); +WITH a AS (SELECT 1 AS x), b AS (SELECT x + 1 AS x FROM a), c AS (SELECT x + 1 AS x FROM b), d AS (SELECT x + 1 AS x FROM c), e AS (SELECT x + 1 AS x FROM d), f AS (SELECT x + 1 AS x FROM e), g AS (SELECT x + 1 AS x FROM f), h AS (SELECT x + 1 AS x FROM g) SELECT * FROM h; +WITH RECURSIVE org AS (SELECT id, manager_id, name, 1 AS lvl FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.manager_id, e.name, o.lvl + 1 FROM employees e JOIN org o ON e.manager_id = o.id) SELECT * FROM org ORDER BY lvl, name; +WITH RECURSIVE nums (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 10) SELECT n FROM nums; +SELECT id, (SELECT MAX(amount) FROM payments p WHERE p.order_id = o.id) AS max_payment, (SELECT COUNT(*) FROM order_items i WHERE i.order_id = o.id) AS n_items FROM orders o WHERE o.id IN (SELECT order_id FROM payments WHERE amount > (SELECT AVG(amount) FROM payments)); +SELECT * FROM t WHERE (a, b) IN (SELECT x, y FROM s) AND c NOT IN (SELECT z FROM u WHERE z IS NOT NULL); +SELECT * FROM t WHERE a = ANY (SELECT x FROM s) AND b > ALL (SELECT y FROM u); +SELECT * FROM t WHERE EXISTS (SELECT 1 FROM s WHERE s.k = t.k AND NOT EXISTS (SELECT 1 FROM u WHERE u.k = s.k)); +SELECT CASE WHEN a > 0 THEN CASE WHEN b > 0 THEN 'pp' ELSE 'pn' END WHEN a < 0 THEN CASE WHEN b > 0 THEN 'np' ELSE 'nn' END ELSE 'z' END AS quadrant FROM t; +SELECT a + b * c - d / e % f AS arith, (a + b) * (c - d) AS paren, -a AS neg, NOT a AS notv, a || b || c AS cat FROM t; +SELECT * FROM t WHERE a = 1 OR b = 2 AND c = 3 OR NOT d = 4 AND (e = 5 OR f = 6); +SELECT * FROM t WHERE x BETWEEN 1 AND 10 AND y NOT BETWEEN 5 AND 6 AND z IS DISTINCT FROM w AND q IS NOT DISTINCT FROM r; +SELECT a, b FROM t1 UNION SELECT a, b FROM t2 UNION ALL SELECT a, b FROM t3 ORDER BY a LIMIT 5; +(SELECT a FROM t1 ORDER BY a LIMIT 3) UNION ALL (SELECT a FROM t2 ORDER BY a DESC LIMIT 3); +SELECT a FROM t1 WHERE a IN (SELECT a FROM t2 UNION SELECT a FROM t3); +SELECT x.* FROM (SELECT a, b FROM t1 UNION ALL SELECT a, b FROM t2) x JOIN (SELECT a FROM t3 INTERSECT SELECT a FROM t4) y ON x.a = y.a; +WITH base AS (SELECT user_id, event_ts, event_name, payload FROM raw.events WHERE event_ts >= DATEADD(day, -30, CURRENT_DATE())), sessions AS (SELECT user_id, event_ts, SUM(is_new) OVER (PARTITION BY user_id ORDER BY event_ts) AS session_id FROM (SELECT user_id, event_ts, IFF(DATEDIFF(minute, LAG(event_ts) OVER (PARTITION BY user_id ORDER BY event_ts), event_ts) > 30, 1, 0) AS is_new FROM base)), agg AS (SELECT user_id, session_id, MIN(event_ts) AS s_start, MAX(event_ts) AS s_end, COUNT(*) AS events FROM sessions GROUP BY 1, 2) SELECT user_id, COUNT(*) AS sessions, AVG(DATEDIFF(second, s_start, s_end)) AS avg_secs FROM agg GROUP BY user_id HAVING COUNT(*) > 1; +SELECT t.id, t.payload:a::string AS a, f1.value:b::string AS b, f2.value::number AS c FROM tbl t, LATERAL FLATTEN(input => t.payload:list) f1, LATERAL FLATTEN(input => f1.value:nums) f2 WHERE f2.value::number > 0; +SELECT u.id, ARRAY_AGG(DISTINCT p.name) WITHIN GROUP (ORDER BY p.name) AS products, SUM(oi.qty * oi.price) AS spend FROM users u JOIN orders o ON o.user_id = u.id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id GROUP BY u.id QUALIFY RANK() OVER (ORDER BY SUM(oi.qty * oi.price) DESC) <= 10; +SELECT COALESCE(a.id, b.id) AS id, a.v AS a_v, b.v AS b_v FROM a FULL JOIN b ON a.id = b.id WHERE COALESCE(a.v, '') <> COALESCE(b.v, ''); +SELECT CAST(x AS NUMBER(10, 2)), x::varchar(10), TRY_CAST(y AS INT), CAST('2024-01-01' AS DATE), EXTRACT(year FROM d), EXTRACT(epoch_second FROM ts), DATE_PART(dayofweek, d) FROM t; +SELECT SUBSTR(s, 2, 3), LEFT(s, 2), RIGHT(s, 2), TRIM(s), LTRIM(s, ' '), REPLACE(s, 'a', 'b'), POSITION('a' IN s), CHARINDEX('a', s), LENGTH(s), UPPER(s), INITCAP(s), LPAD(s, 5, '0'), REGEXP_REPLACE(s, '[0-9]+', ''), REGEXP_SUBSTR(s, '[a-z]+', 1, 1, 'i'), CONTAINS(s, 'x'), STARTSWITH(s, 'a') FROM t; +SELECT INTERVAL '1 day' + CURRENT_TIMESTAMP(), CURRENT_DATE() - INTERVAL '3 months', ts + INTERVAL '2 hours 30 minutes' FROM t; +SELECT ROUND(x, 2), CEIL(x), FLOOR(x), ABS(x), MOD(x, 3), POWER(x, 2), SQRT(x), LN(x), GREATEST(a, b, c), LEAST(a, b), DIV0(a, b), SIGN(x), TRUNC(x, 1) FROM t; +SELECT * FROM a, b, c WHERE a.id = b.id AND b.id = c.id; +SELECT * FROM (SELECT 1 AS a) AS x(a) JOIN (SELECT 2 AS b) AS y(b) ON TRUE; +SELECT 1 AS "select", 2 AS "from", 3 AS "Group By"; +SELECT t."ID", t."Name" FROM "DB"."SCH"."T" AS t; +SELECT a FROM t ORDER BY a NULLS LAST, b DESC NULLS FIRST; +SELECT a FROM t LIMIT 10 OFFSET 5; +SELECT a FROM t FETCH FIRST 5 ROWS ONLY; +SELECT a FROM t OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY; +SELECT * FROM t1 JOIN t2 ON t1.id = t2.id AND t1.ts BETWEEN t2.start_ts AND t2.end_ts; +SELECT * FROM t1 ASOF JOIN t2 MATCH_CONDITION (t1.ts >= t2.ts) ON t1.sym = t2.sym; +SELECT * FROM t1 LEFT JOIN LATERAL (SELECT * FROM t2 WHERE t2.k = t1.k ORDER BY ts DESC LIMIT 1) l ON TRUE; +SELECT ARRAY_AGG(x) WITHIN GROUP (ORDER BY y DESC) FROM t; +SELECT SUM(x) OVER (PARTITION BY a, b ORDER BY c DESC, d ASC ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) FROM t; +SELECT SUM(x) OVER (ORDER BY ts RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t; +SELECT a FROM t WHERE b = 'x' AND c IN (1, 2, 3) AND d LIKE 'a%' ESCAPE '\\' AND e IS NULL; diff --git a/testdata/realworld/sqlite/01_app_crud.sql b/testdata/realworld/sqlite/01_app_crud.sql new file mode 100644 index 00000000..b78664ff --- /dev/null +++ b/testdata/realworld/sqlite/01_app_crud.sql @@ -0,0 +1,37 @@ +-- Scenario: ORM-style CRUD for a notes/todo mobile app +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + is_active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO users (email, display_name) VALUES ('a@example.com', 'Alice'); +INSERT INTO users (email, display_name) VALUES (?, ?); +INSERT INTO users (email, display_name) VALUES (:email, :name); +INSERT INTO notes (user_id, title, body) VALUES (1, 'Groceries', 'milk, eggs'), (1, 'Todo', 'ship it'); +SELECT id, email, display_name FROM users WHERE id = ?; +SELECT * FROM notes WHERE user_id = ?1 ORDER BY pinned DESC, updated_at DESC LIMIT 20 OFFSET 40; +SELECT * FROM notes WHERE title LIKE '%' || ? || '%' ESCAPE '\' ORDER BY id; +SELECT count(*) FROM notes WHERE user_id = ? AND pinned = 1; +UPDATE notes SET title = ?, body = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?; +UPDATE users SET is_active = 0 WHERE id IN (SELECT user_id FROM notes GROUP BY user_id HAVING count(*) = 0); +DELETE FROM notes WHERE id = ? AND user_id = ?; +DELETE FROM notes WHERE updated_at < datetime('now', '-90 days'); +INSERT INTO users (email, display_name) VALUES ('b@example.com', 'Bob') RETURNING id; +UPDATE notes SET pinned = 1 WHERE id = 5 RETURNING id, title, pinned; +DELETE FROM notes WHERE user_id = 9 RETURNING *; +SELECT n.id, n.title, u.display_name FROM notes AS n JOIN users AS u ON u.id = n.user_id WHERE u.is_active = 1; +SELECT n.id, u.email FROM notes n LEFT JOIN users u ON u.id = n.user_id WHERE u.id IS NULL; +SELECT EXISTS (SELECT 1 FROM users WHERE email = ?); +SELECT last_insert_rowid(); +SELECT changes(); +INSERT INTO notes (user_id, title) SELECT id, 'Welcome' FROM users WHERE id NOT IN (SELECT user_id FROM notes); diff --git a/testdata/realworld/sqlite/02_upsert_replace.sql b/testdata/realworld/sqlite/02_upsert_replace.sql new file mode 100644 index 00000000..48a6a606 --- /dev/null +++ b/testdata/realworld/sqlite/02_upsert_replace.sql @@ -0,0 +1,32 @@ +-- Scenario: sync layer using SQLite conflict handling clauses +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS counters ( + name TEXT PRIMARY KEY, + hits INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE +); +INSERT OR REPLACE INTO settings (key, value) VALUES ('theme', 'dark'); +INSERT OR IGNORE INTO tags (name) VALUES ('work'); +INSERT OR ABORT INTO tags (name) VALUES ('home'); +INSERT OR FAIL INTO tags (name) VALUES ('misc'); +INSERT OR ROLLBACK INTO tags (name) VALUES ('later'); +REPLACE INTO settings (key, value) VALUES ('lang', 'en'); +INSERT INTO counters (name, hits) VALUES ('home', 1) ON CONFLICT (name) DO UPDATE SET hits = hits + 1; +INSERT INTO counters (name, hits) VALUES ('home', 1) ON CONFLICT(name) DO UPDATE SET hits = counters.hits + excluded.hits; +INSERT INTO tags (name) VALUES ('x') ON CONFLICT DO NOTHING; +INSERT INTO tags (name) VALUES ('x') ON CONFLICT (name) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('a', '1') ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = strftime('%s','now') WHERE settings.value <> excluded.value; +INSERT INTO counters (name, hits) VALUES ('x', 1) ON CONFLICT (name) DO UPDATE SET hits = hits + 1 RETURNING name, hits; +INSERT INTO counters (name, hits) SELECT name, 1 FROM tags WHERE true ON CONFLICT (name) DO UPDATE SET hits = hits + 1; +UPDATE OR IGNORE tags SET name = 'new' WHERE id = 3; +UPDATE OR REPLACE settings SET key = 'k2' WHERE key = 'k1'; +INSERT INTO settings DEFAULT VALUES; +INSERT INTO settings (key, value) VALUES ('p', 'q') ON CONFLICT (key) WHERE value IS NOT NULL DO NOTHING; +INSERT INTO counters (name, hits) VALUES ('c', 1), ('d', 2) ON CONFLICT (name) DO UPDATE SET hits = excluded.hits RETURNING *; diff --git a/testdata/realworld/sqlite/03_ddl_migrations.sql b/testdata/realworld/sqlite/03_ddl_migrations.sql new file mode 100644 index 00000000..54beb834 --- /dev/null +++ b/testdata/realworld/sqlite/03_ddl_migrations.sql @@ -0,0 +1,74 @@ +-- Scenario: schema migrations for an Electron app +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + balance REAL NOT NULL DEFAULT 0.0 CHECK (balance >= 0), + currency TEXT NOT NULL DEFAULT 'USD' CHECK (length(currency) = 3), + meta BLOB +); +CREATE TABLE transactions ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL, + amount NUMERIC NOT NULL, + memo TEXT, + posted_at TEXT NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts (id) ON UPDATE CASCADE ON DELETE RESTRICT +); +CREATE TABLE strict_events ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + payload TEXT, + ts INTEGER NOT NULL +) STRICT; +CREATE TABLE strict_wr ( + k TEXT PRIMARY KEY, + v ANY +) STRICT, WITHOUT ROWID; +CREATE TABLE typeless (a, b, c); +CREATE TABLE gen ( + a INTEGER, + b INTEGER, + c INTEGER GENERATED ALWAYS AS (a + b) VIRTUAL, + d INTEGER AS (a * b) STORED +); +CREATE TEMP TABLE scratch (id INTEGER, v TEXT); +CREATE TEMPORARY TABLE IF NOT EXISTS scratch2 (id INTEGER); +CREATE TABLE copy_of_accounts AS SELECT * FROM accounts WHERE balance > 0; +CREATE INDEX IF NOT EXISTS idx_tx_account ON transactions (account_id, posted_at DESC); +CREATE UNIQUE INDEX uq_accounts_name ON accounts (name COLLATE NOCASE); +CREATE INDEX idx_tx_big ON transactions (amount) WHERE amount > 1000; +CREATE INDEX idx_tx_month ON transactions (strftime('%Y-%m', posted_at)); +CREATE VIEW IF NOT EXISTS v_account_totals AS + SELECT a.id, a.name, coalesce(sum(t.amount), 0) AS total FROM accounts a LEFT JOIN transactions t ON t.account_id = a.id GROUP BY a.id, a.name; +CREATE TEMP VIEW v_tmp AS SELECT 1 AS one; +ALTER TABLE accounts ADD COLUMN archived INTEGER NOT NULL DEFAULT 0; +ALTER TABLE accounts RENAME COLUMN name TO title; +ALTER TABLE accounts RENAME TO accounts_old; +ALTER TABLE transactions DROP COLUMN memo; +DROP TABLE IF EXISTS scratch; +DROP INDEX IF EXISTS idx_tx_big; +DROP VIEW IF EXISTS v_tmp; +CREATE TRIGGER trg_tx_after_insert AFTER INSERT ON transactions +BEGIN + UPDATE accounts SET balance = balance + NEW.amount WHERE id = NEW.account_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_notes_touch BEFORE UPDATE OF title, body ON notes FOR EACH ROW WHEN NEW.title <> OLD.title +BEGIN + UPDATE notes SET updated_at = datetime('now') WHERE id = NEW.id; +END; +DROP TRIGGER IF EXISTS trg_notes_touch; +CREATE VIRTUAL TABLE docs_fts USING fts5(title, body); +BEGIN; +BEGIN IMMEDIATE; +COMMIT; +BEGIN TRANSACTION; +ROLLBACK; +SAVEPOINT sp1; +RELEASE SAVEPOINT sp1; +REINDEX idx_tx_account; +ANALYZE; +VACUUM; diff --git a/testdata/realworld/sqlite/04_window_analytics.sql b/testdata/realworld/sqlite/04_window_analytics.sql new file mode 100644 index 00000000..7f509faf --- /dev/null +++ b/testdata/realworld/sqlite/04_window_analytics.sql @@ -0,0 +1,26 @@ +-- Scenario: analytics dashboard with window functions +SELECT id, amount, sum(amount) OVER (ORDER BY posted_at) AS running FROM transactions; +SELECT account_id, posted_at, amount, row_number() OVER (PARTITION BY account_id ORDER BY posted_at DESC) AS rn FROM transactions; +SELECT *, rank() OVER w AS r, dense_rank() OVER w AS dr FROM transactions WINDOW w AS (PARTITION BY account_id ORDER BY amount DESC); +SELECT id, lag(amount, 1, 0) OVER (PARTITION BY account_id ORDER BY posted_at) AS prev, lead(amount) OVER (PARTITION BY account_id ORDER BY posted_at) AS nxt FROM transactions; +SELECT id, avg(amount) OVER (ORDER BY posted_at ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM transactions; +SELECT id, sum(amount) OVER (ORDER BY id RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM transactions; +SELECT id, sum(amount) OVER (ORDER BY id GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING) FROM transactions; +SELECT id, first_value(amount) OVER (PARTITION BY account_id ORDER BY posted_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) FROM transactions; +SELECT id, last_value(amount) OVER (PARTITION BY account_id ORDER BY posted_at) FROM transactions; +SELECT id, nth_value(amount, 2) OVER (ORDER BY posted_at) FROM transactions; +SELECT id, ntile(4) OVER (ORDER BY amount) AS quartile, percent_rank() OVER (ORDER BY amount) AS pr, cume_dist() OVER (ORDER BY amount) AS cd FROM transactions; +SELECT id, sum(amount) FILTER (WHERE amount > 0) OVER (PARTITION BY account_id) AS pos_total FROM transactions; +SELECT count(*) FILTER (WHERE amount < 0) AS refunds, count(*) AS total FROM transactions; +SELECT id, sum(amount) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW) FROM transactions; +SELECT id, sum(amount) OVER (w1 ROWS 2 PRECEDING) FROM transactions WINDOW w1 AS (PARTITION BY account_id ORDER BY id); +WITH ranked AS ( + SELECT account_id, amount, row_number() OVER (PARTITION BY account_id ORDER BY amount DESC) AS rn FROM transactions +) +SELECT account_id, amount FROM ranked WHERE rn <= 3; +SELECT strftime('%Y-%m', posted_at) AS month, sum(amount) AS total, sum(sum(amount)) OVER (ORDER BY strftime('%Y-%m', posted_at)) AS cumulative FROM transactions GROUP BY month; +SELECT group_concat(name, ', ') FROM accounts; +SELECT account_id, group_concat(DISTINCT memo) FROM transactions GROUP BY account_id; +SELECT account_id, group_concat(memo, '; ' ) FROM transactions GROUP BY account_id HAVING count(*) > 2 ORDER BY account_id; +SELECT total(amount), min(amount), max(amount), avg(amount) FROM transactions; +SELECT string_agg(name, ',') FROM accounts; diff --git a/testdata/realworld/sqlite/05_dialect_specific.sql b/testdata/realworld/sqlite/05_dialect_specific.sql new file mode 100644 index 00000000..08c6d09c --- /dev/null +++ b/testdata/realworld/sqlite/05_dialect_specific.sql @@ -0,0 +1,70 @@ +-- Scenario: SQLite-specific syntax and functions +SELECT * FROM files WHERE path GLOB '*.txt'; +SELECT * FROM files WHERE path NOT GLOB '[a-c]*'; +SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'; +SELECT name, sql FROM sqlite_schema WHERE type IN ('table', 'index'); +SELECT typeof(x), quote(x), hex(x), zeroblob(16), randomblob(8) FROM t; +SELECT ifnull(a, 0), nullif(a, ''), coalesce(a, b, 'n/a') FROM t; +SELECT iif(a > 1, 'big', 'small') FROM t; +SELECT CASE WHEN a > 1 THEN 'x' WHEN a = 1 THEN 'y' ELSE 'z' END FROM t; +SELECT CASE a WHEN 1 THEN 'one' WHEN 2 THEN 'two' END FROM t; +SELECT a FROM t WHERE a IS NOT b; +SELECT a FROM t WHERE a IS b; +SELECT a FROM t WHERE a IS DISTINCT FROM b; +SELECT a FROM t WHERE a IS NOT DISTINCT FROM b; +SELECT a FROM t WHERE a BETWEEN 1 AND 10 AND b NOT BETWEEN 2 AND 3; +SELECT a FROM t WHERE a ISNULL; +SELECT a FROM t WHERE a NOTNULL; +SELECT a FROM t WHERE a == 1 OR a != 2 OR a <> 3; +SELECT 'it''s' AS s, x'DEADBEEF' AS b, 0xFF AS h, 1e3 AS f; +SELECT "quoted col", [bracket col], `backtick col` FROM "my table"; +SELECT a || b || c FROM t; +SELECT a & b, a | b, ~a, a << 2, a >> 1, a % 3 FROM t; +SELECT CAST(a AS INTEGER), CAST(b AS TEXT), CAST(c AS REAL), CAST(d AS NUMERIC), CAST(e AS BLOB) FROM t; +SELECT a REGEXP '^foo' FROM t; +SELECT a COLLATE NOCASE FROM t ORDER BY a COLLATE BINARY; +SELECT a FROM t ORDER BY a NULLS LAST; +SELECT a FROM t ORDER BY a DESC NULLS FIRST, b ASC; +SELECT a FROM t LIMIT 10, 20; +SELECT a FROM t LIMIT -1 OFFSET 5; +SELECT DISTINCT a FROM t; +SELECT ALL a FROM t; +SELECT a FROM t INDEXED BY idx_a WHERE a = 1; +SELECT a FROM t NOT INDEXED; +SELECT * FROM t1 NATURAL JOIN t2; +SELECT * FROM t1 JOIN t2 USING (id); +SELECT * FROM t1 CROSS JOIN t2; +SELECT * FROM t1, t2 WHERE t1.id = t2.id; +SELECT * FROM t1 LEFT OUTER JOIN t2 ON t1.id = t2.id; +SELECT * FROM t1 NATURAL LEFT JOIN t2; +SELECT * FROM t1 RIGHT JOIN t2 ON t1.id = t2.id; +SELECT * FROM t1 FULL OUTER JOIN t2 ON t1.id = t2.id; +SELECT * FROM main.t1; +SELECT * FROM (SELECT 1 AS a UNION ALL SELECT 2 UNION SELECT 3 INTERSECT SELECT 3 EXCEPT SELECT 2); +VALUES (1, 'a'), (2, 'b'); +SELECT * FROM pragma_table_info('users'); +SELECT * FROM generate_series(1, 10); +SELECT sqlite_version(), sqlite_source_id(); +SELECT unixepoch(), unixepoch('now', 'start of day'); +SELECT date('now'), time('now'), datetime('now', 'localtime'), julianday('now'); +SELECT datetime('now', '+1 day', 'start of month', '-1 second'); +SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +SELECT datetime(created_at, 'unixepoch') FROM events; +SELECT timediff('2024-01-02', '2024-01-01'); +SELECT abs(-1), round(3.14159, 2), max(1, 2, 3), min(1, 2), length('abc'), substr('hello', 2, 3), upper('a'), lower('A'), trim(' x '), ltrim('x', 'x'), replace('abc', 'b', 'X'), instr('abc', 'b'), printf('%05.1f', 3.14159), format('%d', 5); +SELECT unhex('4142'), likely(a), unlikely(a), likelihood(a, 0.5) FROM t; +SELECT random(), abs(random()) % 100; +SELECT a FROM t WHERE rowid = 5; +SELECT rowid, _rowid_, oid, * FROM t; +SELECT a FROM t WHERE (a, b) IN (SELECT a, b FROM u); +SELECT (1, 2) = (1, 2); +SELECT a FROM t WHERE a IN (); +SELECT a FROM t WHERE a IN (1, 2, 3) AND b NOT IN (SELECT id FROM u); +SELECT a FROM t WHERE a IN u; +SELECT * FROM t WHERE NOT EXISTS (SELECT 1 FROM u WHERE u.id = t.id); +SELECT (SELECT max(id) FROM u) AS mx, a FROM t; +SELECT a FROM t WHERE a LIKE 'x%' OR a NOT LIKE 'y_'; +SELECT a FROM t WHERE a MATCH 'foo'; +SELECT -a, +a, NOT a FROM t; +SELECT a FROM t GROUP BY a HAVING count(*) > 1; +SELECT a, count(*) AS c FROM t GROUP BY 1 ORDER BY 2 DESC; diff --git a/testdata/realworld/sqlite/06_json.sql b/testdata/realworld/sqlite/06_json.sql new file mode 100644 index 00000000..309ff13d --- /dev/null +++ b/testdata/realworld/sqlite/06_json.sql @@ -0,0 +1,29 @@ +-- Scenario: JSON-heavy document storage app +CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, payload TEXT NOT NULL CHECK (json_valid(payload))); +INSERT INTO events (payload) VALUES ('{"type":"click","user":{"id":7,"name":"Zed"},"tags":["a","b"]}'); +INSERT INTO events (payload) VALUES (json_object('type', 'view', 'n', 3)); +SELECT json_extract(payload, '$.type') FROM events; +SELECT json_extract(payload, '$.user.id', '$.user.name') FROM events; +SELECT payload -> 'type' FROM events; +SELECT payload ->> 'type' FROM events; +SELECT payload -> '$.user' ->> '$.name' FROM events; +SELECT payload ->> '$.tags[0]' AS first_tag FROM events; +SELECT id FROM events WHERE payload ->> 'type' = 'click'; +SELECT id FROM events WHERE json_extract(payload, '$.user.id') = 7 AND payload ->> '$.n' > 2; +SELECT json_array(1, 2, 'x'), json_array_length('[1,2,3]'), json_type(payload, '$.user') FROM events; +SELECT json_set(payload, '$.seen', 1), json_insert(payload, '$.a', 2), json_replace(payload, '$.type', 'x'), json_remove(payload, '$.tags') FROM events; +SELECT json_patch(payload, '{"a":null}') FROM events; +SELECT json_group_array(payload ->> 'type') FROM events; +SELECT json_group_object(id, payload ->> 'type') FROM events; +SELECT e.id, j.value FROM events e, json_each(e.payload, '$.tags') AS j; +SELECT e.id, j.key, j.value, j.type FROM events AS e JOIN json_each(e.payload) AS j ON 1 = 1; +SELECT j.fullkey, j.atom FROM events e, json_tree(e.payload) j WHERE j.atom IS NOT NULL; +SELECT jsonb(payload), jsonb_extract(payload, '$.type'), json(jsonb(payload)) FROM events; +SELECT jsonb_set(jsonb(payload), '$.k', 'v') FROM events; +CREATE INDEX idx_events_type ON events (json_extract(payload, '$.type')); +CREATE INDEX idx_events_type2 ON events ((payload ->> '$.type')); +UPDATE events SET payload = json_set(payload, '$.processed', json('true')) WHERE payload ->> 'type' = 'click'; +UPDATE events SET payload = json_remove(payload, '$.user.name') WHERE id = 1 RETURNING id, payload; +SELECT json_quote('x'), json_valid(payload) FROM events; +WITH t AS (SELECT payload ->> 'type' AS type, count(*) AS n FROM events GROUP BY 1) +SELECT type, n, round(100.0 * n / sum(n) OVER (), 1) AS pct FROM t ORDER BY n DESC; diff --git a/testdata/realworld/sqlite/07_ctes_recursive.sql b/testdata/realworld/sqlite/07_ctes_recursive.sql new file mode 100644 index 00000000..e59dc810 --- /dev/null +++ b/testdata/realworld/sqlite/07_ctes_recursive.sql @@ -0,0 +1,36 @@ +-- Scenario: CTE chains, recursive trees and calendars +WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM cnt WHERE x < 100) SELECT x FROM cnt; +WITH RECURSIVE tree(id, parent_id, name, depth) AS ( + SELECT id, parent_id, name, 0 FROM categories WHERE parent_id IS NULL + UNION ALL + SELECT c.id, c.parent_id, c.name, t.depth + 1 FROM categories c JOIN tree t ON c.parent_id = t.id +) +SELECT printf('%.*c%s', depth * 2, ' ', name) AS label FROM tree ORDER BY depth, name; +WITH RECURSIVE dates(d) AS ( + VALUES (date('2024-01-01')) + UNION ALL + SELECT date(d, '+1 day') FROM dates WHERE d < date('2024-01-31') +) +SELECT d, strftime('%w', d) AS dow FROM dates; +WITH RECURSIVE path(id, trail) AS ( + SELECT id, name FROM categories WHERE parent_id IS NULL + UNION + SELECT c.id, p.trail || ' > ' || c.name FROM categories c JOIN path p ON c.parent_id = p.id +) SELECT * FROM path ORDER BY trail LIMIT 50; +WITH a AS (SELECT 1 AS n), b AS (SELECT n + 1 AS n FROM a), c AS (SELECT n + 1 AS n FROM b), d AS (SELECT n + 1 AS n FROM c) SELECT * FROM d; +WITH monthly AS ( + SELECT strftime('%Y-%m', posted_at) AS ym, account_id, sum(amount) AS total FROM transactions GROUP BY ym, account_id +), ranked AS ( + SELECT ym, account_id, total, rank() OVER (PARTITION BY ym ORDER BY total DESC) AS rk FROM monthly +), top3 AS ( + SELECT * FROM ranked WHERE rk <= 3 +) +SELECT t.ym, a.name, t.total FROM top3 t JOIN accounts a ON a.id = t.account_id ORDER BY t.ym, t.rk; +WITH cte(a, b) AS (SELECT 1, 2) SELECT a + b FROM cte; +WITH cte AS MATERIALIZED (SELECT * FROM transactions WHERE amount > 100) SELECT count(*) FROM cte; +WITH cte AS NOT MATERIALIZED (SELECT * FROM transactions) SELECT * FROM cte WHERE id = 1; +WITH del AS (SELECT id FROM notes WHERE pinned = 0) DELETE FROM notes WHERE id IN (SELECT id FROM del); +WITH src(k, v) AS (VALUES ('a', 1), ('b', 2)) INSERT INTO settings (key, value) SELECT k, v FROM src ON CONFLICT (key) DO UPDATE SET value = excluded.value; +WITH t AS (SELECT account_id, max(posted_at) AS last FROM transactions GROUP BY account_id) UPDATE accounts SET archived = 1 WHERE id IN (SELECT account_id FROM t WHERE last < date('now', '-1 year')); +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT id, amount FROM transactions WHERE amount > 0) WHERE amount < 1000) ORDER BY amount) LIMIT 5; +SELECT a.id FROM accounts a WHERE a.balance > (SELECT avg(balance) FROM accounts WHERE currency = a.currency) AND EXISTS (SELECT 1 FROM transactions t WHERE t.account_id = a.id AND t.amount > (SELECT avg(amount) FROM transactions)); diff --git a/testdata/realworld/sqlite/08_reporting.sql b/testdata/realworld/sqlite/08_reporting.sql new file mode 100644 index 00000000..174f4849 --- /dev/null +++ b/testdata/realworld/sqlite/08_reporting.sql @@ -0,0 +1,32 @@ +-- Scenario: BI / reporting queries over an orders schema +SELECT c.region, strftime('%Y', o.ordered_at) AS yr, count(DISTINCT o.id) AS orders, sum(oi.qty * oi.unit_price) AS revenue +FROM orders o +JOIN customers c ON c.id = o.customer_id +JOIN order_items oi ON oi.order_id = o.id +JOIN products p ON p.id = oi.product_id +LEFT JOIN refunds r ON r.order_id = o.id +WHERE o.status NOT IN ('cancelled', 'draft') AND r.id IS NULL +GROUP BY c.region, yr +HAVING revenue > 1000 +ORDER BY yr DESC, revenue DESC +LIMIT 100; +SELECT p.category, p.name, sum(oi.qty) AS units FROM products p LEFT JOIN order_items oi ON oi.product_id = p.id GROUP BY p.id ORDER BY units DESC NULLS LAST LIMIT 10; +SELECT date(ordered_at) AS day, count(*) FROM orders WHERE ordered_at >= date('now', '-30 days') GROUP BY day ORDER BY day; +SELECT customer_id, min(ordered_at) AS first_order, max(ordered_at) AS last_order, julianday(max(ordered_at)) - julianday(min(ordered_at)) AS span_days FROM orders GROUP BY customer_id; +SELECT CASE WHEN total < 50 THEN 'small' WHEN total < 500 THEN 'medium' ELSE 'large' END AS bucket, count(*) AS n, round(avg(total), 2) AS avg_total FROM orders GROUP BY bucket; +SELECT c.id, c.name, (SELECT count(*) FROM orders o WHERE o.customer_id = c.id) AS n_orders, (SELECT max(total) FROM orders o WHERE o.customer_id = c.id) AS biggest FROM customers c WHERE c.created_at BETWEEN '2024-01-01' AND '2024-12-31'; +SELECT product_id FROM order_items GROUP BY product_id HAVING sum(qty) > (SELECT avg(q) FROM (SELECT sum(qty) AS q FROM order_items GROUP BY product_id)); +SELECT a.customer_id, b.customer_id FROM orders a JOIN orders b ON a.id < b.id AND a.customer_id = b.customer_id AND abs(julianday(a.ordered_at) - julianday(b.ordered_at)) < 1; +SELECT o.id FROM orders o WHERE o.id IN (SELECT order_id FROM order_items WHERE product_id IN (SELECT id FROM products WHERE category = 'books')); +SELECT customer_id FROM orders WHERE status = 'paid' EXCEPT SELECT customer_id FROM refunds; +SELECT customer_id FROM orders WHERE total > 100 INTERSECT SELECT customer_id FROM orders WHERE status = 'paid' ORDER BY 1; +SELECT id, name FROM customers UNION ALL SELECT id, name FROM archived_customers ORDER BY name LIMIT 10 OFFSET 5; +SELECT 'orders' AS t, count(*) FROM orders UNION ALL SELECT 'customers', count(*) FROM customers UNION ALL SELECT 'products', count(*) FROM products; +SELECT coalesce(sum(total), 0) AS rev, count(*) FILTER (WHERE status = 'paid') AS paid FROM orders WHERE ordered_at >= :from AND ordered_at < :to; +SELECT * FROM orders WHERE customer_id = $cid AND status = @status; +SELECT * FROM orders WHERE id = ?1 OR id = ?2; +SELECT p.name, round(100.0 * sum(oi.qty * oi.unit_price) / (SELECT sum(qty * unit_price) FROM order_items), 2) AS share FROM products p JOIN order_items oi ON oi.product_id = p.id GROUP BY p.id ORDER BY share DESC; +SELECT cast(strftime('%m', ordered_at) AS INTEGER) AS m, sum(total) FROM orders GROUP BY m; +SELECT o.* FROM orders AS o INNER JOIN (SELECT customer_id, max(ordered_at) AS mx FROM orders GROUP BY customer_id) AS latest ON latest.customer_id = o.customer_id AND latest.mx = o.ordered_at; +SELECT count(*) AS "Total Orders", sum(total) AS "Total Revenue" FROM orders; +SELECT t1.a AS "x.y" FROM t t1 WHERE t1.a <> ''; diff --git a/testdata/realworld/sqlite/09_etl_admin.sql b/testdata/realworld/sqlite/09_etl_admin.sql new file mode 100644 index 00000000..877940c1 --- /dev/null +++ b/testdata/realworld/sqlite/09_etl_admin.sql @@ -0,0 +1,42 @@ +-- Scenario: ETL, bulk loads, admin and maintenance statements +CREATE TABLE IF NOT EXISTS staging_orders (raw_id TEXT, raw_total TEXT, raw_date TEXT); +CREATE TABLE IF NOT EXISTS etl_log (id INTEGER PRIMARY KEY AUTOINCREMENT, step TEXT, rows_affected INTEGER, ran_at TEXT DEFAULT CURRENT_TIMESTAMP); +INSERT INTO orders (id, total, ordered_at) SELECT CAST(raw_id AS INTEGER), CAST(raw_total AS REAL), date(raw_date) FROM staging_orders WHERE raw_id GLOB '[0-9]*'; +INSERT INTO etl_log (step, rows_affected) VALUES ('load_orders', changes()); +INSERT INTO orders SELECT * FROM orders_backup WHERE id NOT IN (SELECT id FROM orders); +DELETE FROM staging_orders; +DELETE FROM orders WHERE rowid NOT IN (SELECT min(rowid) FROM orders GROUP BY id); +UPDATE orders SET total = round(total, 2), status = lower(trim(status)); +UPDATE orders SET (total, status) = (SELECT sum(qty * unit_price), 'recalc' FROM order_items WHERE order_id = orders.id) WHERE id = 1; +UPDATE orders SET total = t.sum FROM (SELECT order_id, sum(qty * unit_price) AS sum FROM order_items GROUP BY order_id) AS t WHERE orders.id = t.order_id; +UPDATE orders AS o SET status = 'vip' FROM customers AS c WHERE c.id = o.customer_id AND c.tier = 'gold'; +DROP TABLE IF EXISTS staging_orders; +ALTER TABLE orders ADD COLUMN source TEXT DEFAULT 'import'; +CREATE TABLE orders_new (id INTEGER PRIMARY KEY, total REAL NOT NULL, ordered_at TEXT); +INSERT INTO orders_new SELECT id, total, ordered_at FROM orders; +DROP TABLE orders; +ALTER TABLE orders_new RENAME TO orders; +ATTACH DATABASE 'archive.db' AS archive; +ATTACH 'other.db' AS other; +DETACH DATABASE archive; +INSERT INTO archive.orders SELECT * FROM main.orders WHERE ordered_at < '2020-01-01'; +SELECT * FROM archive.orders UNION ALL SELECT * FROM main.orders; +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA table_info(orders); +PRAGMA user_version; +PRAGMA user_version = 5; +PRAGMA integrity_check; +PRAGMA main.synchronous = NORMAL; +VACUUM INTO 'backup.db'; +VACUUM main; +EXPLAIN SELECT * FROM orders WHERE id = 1; +EXPLAIN QUERY PLAN SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id; +REINDEX; +ANALYZE main; +END TRANSACTION; +BEGIN DEFERRED TRANSACTION; +BEGIN EXCLUSIVE; +COMMIT TRANSACTION; +ROLLBACK TO SAVEPOINT sp1; +ROLLBACK TRANSACTION TO sp2; diff --git a/testdata/realworld/sqlite/10_mobile_offline_sync.sql b/testdata/realworld/sqlite/10_mobile_offline_sync.sql new file mode 100644 index 00000000..ce838840 --- /dev/null +++ b/testdata/realworld/sqlite/10_mobile_offline_sync.sql @@ -0,0 +1,46 @@ +-- Scenario: mobile offline-first sync, outbox pattern and FTS +CREATE TABLE IF NOT EXISTS outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL CHECK (op IN ('insert', 'update', 'delete')), + payload TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE (entity, entity_id, op) +); +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + thread_id TEXT NOT NULL, + sender TEXT NOT NULL, + body TEXT, + sent_at INTEGER NOT NULL, + read_at INTEGER, + deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)) +); +CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, sent_at DESC) WHERE deleted = 0; +CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages (thread_id) WHERE read_at IS NULL AND deleted = 0; +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(body, content='messages', content_rowid='rowid'); +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts (rowid, body) VALUES (new.rowid, new.body); +END; +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts (messages_fts, rowid, body) VALUES ('delete', old.rowid, old.body); +END; +INSERT INTO outbox (entity, entity_id, op, payload) VALUES ('message', ?, 'insert', ?) ON CONFLICT (entity, entity_id, op) DO UPDATE SET payload = excluded.payload, attempts = 0; +SELECT * FROM outbox ORDER BY id LIMIT 50; +UPDATE outbox SET attempts = attempts + 1 WHERE id IN (?, ?, ?); +DELETE FROM outbox WHERE id <= ? AND attempts < 5; +SELECT thread_id, count(*) AS unread FROM messages WHERE read_at IS NULL AND deleted = 0 GROUP BY thread_id; +SELECT m.* FROM messages m WHERE m.thread_id = ? AND m.deleted = 0 AND m.sent_at < ? ORDER BY m.sent_at DESC LIMIT 30; +SELECT m.id, snippet(messages_fts, 0, '', '', '...', 8) FROM messages_fts JOIN messages m ON m.rowid = messages_fts.rowid WHERE messages_fts MATCH ? ORDER BY rank LIMIT 20; +SELECT * FROM messages_fts WHERE messages_fts MATCH 'hello AND world'; +UPDATE messages SET read_at = unixepoch() WHERE thread_id = ? AND read_at IS NULL; +INSERT OR REPLACE INTO messages (id, thread_id, sender, body, sent_at) VALUES (?, ?, ?, ?, ?); +SELECT t.thread_id, t.last_at, m.body FROM (SELECT thread_id, max(sent_at) AS last_at FROM messages WHERE deleted = 0 GROUP BY thread_id) t JOIN messages m ON m.thread_id = t.thread_id AND m.sent_at = t.last_at ORDER BY t.last_at DESC; +CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v BLOB) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS sync_state ( + entity TEXT PRIMARY KEY, + cursor TEXT, + last_sync INTEGER +) STRICT; diff --git a/testdata/realworld/sqlite/11_deep_nesting.sql b/testdata/realworld/sqlite/11_deep_nesting.sql new file mode 100644 index 00000000..04f818e7 --- /dev/null +++ b/testdata/realworld/sqlite/11_deep_nesting.sql @@ -0,0 +1,35 @@ +-- Scenario: stress with deep nesting and long expressions +SELECT ((((1 + 2) * 3) - 4) / 5) AS x; +SELECT a FROM t WHERE ((a > 1 AND b < 2) OR (c = 3 AND (d = 4 OR (e = 5 AND f = 6)))) AND NOT (g = 7 OR h = 8); +SELECT 1 + 2 * 3 - 4 / 2 % 3; +SELECT -(-1), - -1, +-+1; +SELECT a FROM t WHERE a = 1 OR b = 2 AND c = 3; +SELECT a FROM t WHERE NOT a = 1 AND b = 2; +SELECT a || 'x' = b || 'y' FROM t; +SELECT a IN (1, 2) = 1, b BETWEEN 1 AND 2 = 1 FROM t; +SELECT CASE WHEN a THEN CASE WHEN b THEN 1 ELSE 2 END ELSE CASE WHEN c THEN 3 ELSE 4 END END FROM t; +SELECT coalesce(nullif(trim(lower(replace(substr(name, 1, 10), ' ', '_'))), ''), 'unknown') FROM t; +SELECT (SELECT (SELECT (SELECT 1))); +SELECT * FROM t WHERE a IN (SELECT b FROM u WHERE c IN (SELECT d FROM v WHERE e IN (SELECT f FROM w))); +SELECT * FROM a JOIN b ON a.id = b.a_id JOIN c ON c.id = b.c_id JOIN d ON d.id = c.d_id LEFT JOIN e ON e.id = d.e_id LEFT JOIN f ON f.id = e.f_id JOIN g USING (id) WHERE a.x > 0; +SELECT * FROM (a JOIN b ON a.id = b.id) JOIN c ON c.id = a.id; +SELECT * FROM a LEFT JOIN (b JOIN c ON b.id = c.id) ON a.id = b.id; +SELECT x FROM (SELECT x FROM (SELECT x FROM (SELECT 1 AS x) AS t1) AS t2) AS t3; +SELECT * FROM t WHERE a = (SELECT max(a) FROM t WHERE b < (SELECT avg(b) FROM t)); +SELECT a, b FROM t1 UNION SELECT a, b FROM (SELECT a, b FROM t2 UNION ALL SELECT a, b FROM t3) ORDER BY a; +SELECT count(*), sum(CASE WHEN status = 'a' THEN 1 ELSE 0 END), sum(CASE WHEN status = 'b' THEN 1 ELSE 0 END) FROM t GROUP BY (a), (b); +SELECT * FROM t WHERE a = 'a''b' AND b = "c" AND c LIKE '%\_%' ESCAPE '\'; +SELECT max(a, b) AS m, min(a, b) AS n FROM t WHERE a IS NOT NULL; +SELECT a.*, b.* FROM a, b; +SELECT t.* FROM t; +SELECT ABS(x) AS Abs, Count(*) FROM T GROUP BY ABS(x); +SELECT a /* inline */ FROM t -- trailing +WHERE b = 1; +SELECT CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP; +SELECT TRUE, FALSE, NULL; +SELECT * FROM t WHERE flag = TRUE; +SELECT a FROM t WHERE (a) = (1); +SELECT 5 BETWEEN 1 AND 10 AND 6 NOT IN (1, 2); +SELECT a FROM t WHERE b = x'00' OR c = X'ff'; +SELECT 9223372036854775807, -9223372036854775808, 0.5, .5, 5., 1E-3; +SELECT a FROM t EXCEPT SELECT a FROM u UNION SELECT a FROM v; diff --git a/testdata/realworld/sqlite/12_orm_generated.sql b/testdata/realworld/sqlite/12_orm_generated.sql new file mode 100644 index 00000000..2ed6267a --- /dev/null +++ b/testdata/realworld/sqlite/12_orm_generated.sql @@ -0,0 +1,21 @@ +-- Scenario: typical ORM (Django/Rails/SQLAlchemy/Prisma-like) generated SQL against SQLite +SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."username" FROM "auth_user" WHERE "auth_user"."username" = 'bob' LIMIT 21; +SELECT COUNT(*) AS "__count" FROM "blog_post" WHERE "blog_post"."published" = 1; +SELECT "blog_post"."id", "blog_post"."title" FROM "blog_post" INNER JOIN "auth_user" ON ("blog_post"."author_id" = "auth_user"."id") WHERE ("auth_user"."is_staff" AND NOT ("blog_post"."draft")) ORDER BY "blog_post"."created" DESC LIMIT 10; +INSERT INTO "blog_post" ("title", "body", "author_id", "created") VALUES ('t', 'b', 1, '2024-01-01 00:00:00') RETURNING "id"; +UPDATE "blog_post" SET "title" = 'new', "modified" = '2024-02-02 00:00:00' WHERE "blog_post"."id" = 1; +DELETE FROM "blog_post" WHERE "blog_post"."id" IN (1, 2, 3); +CREATE TABLE "django_session" ("session_key" varchar(40) NOT NULL PRIMARY KEY, "session_data" text NOT NULL, "expire_date" datetime NOT NULL); +CREATE INDEX "django_session_expire_date_a5c62663" ON "django_session" ("expire_date"); +CREATE TABLE "blog_post" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" varchar(200) NOT NULL, "author_id" integer NOT NULL REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED); +CREATE TABLE "new__blog_post" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" varchar(100) NOT NULL); +CREATE TABLE "t_dec" ("price" decimal NOT NULL, "n" bigint NOT NULL, "f" real NULL, "b" bool NOT NULL, "d" date NULL, "ts" datetime NOT NULL, "bl" blob NULL, "ch" char(3) NOT NULL, "sm" smallint unsigned NOT NULL CHECK ("sm" >= 0)); +CREATE TABLE knex_migrations (id integer not null primary key autoincrement, name varchar(255), batch integer, migration_time datetime); +CREATE TABLE `users` (`id` integer not null primary key autoincrement, `name` varchar(255)); +SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?; +SELECT "t0"."id", "t0"."name", "t1"."id" AS "t1_id" FROM "users" AS "t0" LEFT JOIN "profiles" AS "t1" ON "t1"."user_id" = "t0"."id" WHERE "t0"."id" IN (?, ?, ?) ORDER BY "t0"."id" ASC LIMIT ? OFFSET ?; +SELECT `User`.`id` AS `User.id`, `User`.`name` FROM `Users` AS `User` WHERE (`User`.`deletedAt` IS NULL AND `User`.`id` = 1); +INSERT INTO `Users` (`id`, `name`, `createdAt`) VALUES (NULL, ?1, ?2); +SELECT count(*) AS "count" FROM (SELECT 1 FROM "orders" GROUP BY "customer_id") AS "sub"; +SELECT * FROM "users" WHERE "id" = ? AND "deleted_at" IS NULL; +PRAGMA foreign_key_check; diff --git a/testdata/realworld/sqlserver/01_app_crud.sql b/testdata/realworld/sqlserver/01_app_crud.sql new file mode 100644 index 00000000..cbe4229e --- /dev/null +++ b/testdata/realworld/sqlserver/01_app_crud.sql @@ -0,0 +1,27 @@ +-- ORM-style CRUD for an order management application (Dapper / EF Core generated shapes) +SELECT [Id], [Name], [Email], [CreatedAt] FROM [dbo].[Customers] WHERE [Id] = 42; +SELECT TOP (1) c.[Id], c.[Name] FROM [dbo].[Customers] AS c WHERE c.[Email] = N'jose@example.com' ORDER BY c.[CreatedAt] DESC; +SELECT [Id], [Name] FROM dbo.Customers WITH (NOLOCK) WHERE IsActive = 1 AND Country IN (N'US', N'CA', N'MX'); +INSERT INTO [dbo].[Customers] ([Name], [Email], [CreatedAt], [IsActive]) VALUES (N'Zoë Müller', N'zoe@example.com', SYSUTCDATETIME(), 1); +INSERT INTO dbo.Customers (Name, Email) VALUES (N'A', N'a@x.com'), (N'B', N'b@x.com'), (N'C', N'c@x.com'); +INSERT INTO [dbo].[Orders] ([CustomerId], [Total], [Status]) OUTPUT INSERTED.[Id] VALUES (42, 199.99, N'New'); +INSERT INTO dbo.AuditLog (EventType, Payload, CreatedAt) OUTPUT INSERTED.Id, INSERTED.CreatedAt VALUES (N'login', N'{"user":1}', GETUTCDATE()); +UPDATE [dbo].[Customers] SET [Name] = N'Updated', [ModifiedAt] = GETUTCDATE() WHERE [Id] = 42; +UPDATE dbo.Orders SET Status = N'Shipped', ShippedAt = SYSDATETIME() OUTPUT DELETED.Status AS OldStatus, INSERTED.Status AS NewStatus WHERE Id = 1001; +UPDATE o SET o.Total = o.Total * 1.1 FROM dbo.Orders AS o INNER JOIN dbo.Customers AS c ON c.Id = o.CustomerId WHERE c.Country = N'DE'; +UPDATE TOP (500) dbo.Queue SET Status = 1 WHERE Status = 0; +DELETE FROM [dbo].[Orders] WHERE [Id] = 1001 AND [Status] = N'Cancelled'; +DELETE TOP (1000) FROM dbo.AuditLog WHERE CreatedAt < DATEADD(DAY, -90, GETUTCDATE()); +DELETE FROM dbo.Sessions OUTPUT DELETED.* WHERE ExpiresAt < GETUTCDATE(); +DELETE o FROM dbo.Orders o LEFT JOIN dbo.Customers c ON c.Id = o.CustomerId WHERE c.Id IS NULL; +SELECT COUNT(*) FROM dbo.Orders WHERE CustomerId = 42; +SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.Customers WHERE Email = N'x@y.com') THEN 1 ELSE 0 END; +SELECT c.Id, c.Name, (SELECT COUNT(*) FROM dbo.Orders o WHERE o.CustomerId = c.Id) AS OrderCount FROM dbo.Customers c; +SELECT * FROM dbo.Products WHERE Name LIKE N'%widget%' AND Price BETWEEN 10 AND 100 AND Discontinued IS NULL; +SELECT p.Id, p.Name FROM dbo.Products p WHERE p.CategoryId IN (SELECT Id FROM dbo.Categories WHERE ParentId = 5) ORDER BY p.Name OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY; +SELECT Id, Name FROM dbo.Products ORDER BY Id OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY; +SELECT SCOPE_IDENTITY(); +SELECT @@ROWCOUNT; +SELECT @@IDENTITY AS LastId; +SELECT TOP 10 * FROM dbo.Orders ORDER BY CreatedAt DESC; +SELECT DISTINCT TOP (5) Country FROM dbo.Customers ORDER BY Country; diff --git a/testdata/realworld/sqlserver/02_reporting.sql b/testdata/realworld/sqlserver/02_reporting.sql new file mode 100644 index 00000000..a1ac7f61 --- /dev/null +++ b/testdata/realworld/sqlserver/02_reporting.sql @@ -0,0 +1,27 @@ +-- Dashboards and ERP style reporting queries +SELECT YEAR(o.OrderDate) AS Yr, MONTH(o.OrderDate) AS Mo, SUM(od.Quantity * od.UnitPrice) AS Revenue FROM Sales.Orders o JOIN Sales.OrderDetails od ON od.OrderId = o.OrderId GROUP BY YEAR(o.OrderDate), MONTH(o.OrderDate) ORDER BY Yr, Mo; +SELECT c.CustomerName, COUNT(DISTINCT o.OrderId) AS Orders, SUM(od.Quantity * od.UnitPrice * (1 - od.Discount)) AS NetRevenue FROM Sales.Customers c LEFT JOIN Sales.Orders o ON o.CustomerId = c.CustomerId LEFT JOIN Sales.OrderDetails od ON od.OrderId = o.OrderId GROUP BY c.CustomerName HAVING SUM(od.Quantity * od.UnitPrice) > 1000 ORDER BY NetRevenue DESC; +SELECT TOP (10) PERCENT p.ProductName, p.ListPrice FROM Production.Product p ORDER BY p.ListPrice DESC; +SELECT TOP (3) WITH TIES e.Name, e.Salary FROM HR.Employees e ORDER BY e.Salary DESC; +SELECT TOP 5 PERCENT WITH TIES Name, Score FROM dbo.Players ORDER BY Score DESC; +SELECT Region, SUM(Amount) AS Total FROM dbo.Sales GROUP BY ROLLUP (Region); +SELECT Region, Product, SUM(Amount) AS Total FROM dbo.Sales GROUP BY CUBE (Region, Product); +SELECT Region, Product, SUM(Amount) AS Total, GROUPING(Region) AS gr FROM dbo.Sales GROUP BY GROUPING SETS ((Region, Product), (Region), ()); +SELECT Region, SUM(Amount) FROM dbo.Sales GROUP BY Region WITH ROLLUP; +SELECT d.DepartmentName, STRING_AGG(e.LastName, N', ') WITHIN GROUP (ORDER BY e.LastName) AS Employees FROM HR.Departments d JOIN HR.Employees e ON e.DepartmentId = d.DepartmentId GROUP BY d.DepartmentName; +SELECT STRING_AGG(CAST(Id AS NVARCHAR(20)), ',') FROM dbo.Orders WHERE Status = N'Open'; +SELECT CONVERT(VARCHAR(10), OrderDate, 120) AS OrderDay, COUNT(*) AS Cnt FROM dbo.Orders GROUP BY CONVERT(VARCHAR(10), OrderDate, 120); +SELECT FORMAT(OrderDate, 'yyyy-MM') AS Period, SUM(Total) AS Total FROM dbo.Orders GROUP BY FORMAT(OrderDate, 'yyyy-MM'); +SELECT DATEDIFF(DAY, OrderDate, ShippedDate) AS DaysToShip, AVG(Total) FROM dbo.Orders WHERE ShippedDate IS NOT NULL GROUP BY DATEDIFF(DAY, OrderDate, ShippedDate); +SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, OrderDate), 0) AS MonthStart, COUNT(*) FROM dbo.Orders GROUP BY DATEADD(MONTH, DATEDIFF(MONTH, 0, OrderDate), 0); +SELECT EOMONTH(GETDATE()) AS MonthEnd, EOMONTH(GETDATE(), -1) AS PrevMonthEnd; +SELECT DATEPART(QUARTER, OrderDate) AS Q, DATENAME(MONTH, OrderDate) AS MonthName FROM dbo.Orders; +SELECT IIF(Total > 1000, N'Large', N'Small') AS Size, COUNT(*) FROM dbo.Orders GROUP BY IIF(Total > 1000, N'Large', N'Small'); +SELECT ISNULL(Region, N'Unknown') AS Region, COALESCE(Manager, Supervisor, N'None') AS Boss, NULLIF(Qty, 0) AS Q FROM dbo.Sales; +SELECT TRY_CAST(Amount AS DECIMAL(18, 2)) AS Amt, TRY_CONVERT(DATE, DateText, 103) AS D, TRY_PARSE(N'2024-01-01' AS DATE) AS P FROM dbo.Import; +SELECT CHOOSE(MONTH(GETDATE()), N'Jan', N'Feb', N'Mar', N'Apr', N'May', N'Jun', N'Jul', N'Aug', N'Sep', N'Oct', N'Nov', N'Dec'); +SELECT * FROM (SELECT Region, Quarter, Amount FROM dbo.Sales) AS src PIVOT (SUM(Amount) FOR Quarter IN ([Q1], [Q2], [Q3], [Q4])) AS pvt; +SELECT CustomerId, Metric, Value FROM dbo.CustomerStats UNPIVOT (Value FOR Metric IN (Orders, Returns, Visits)) AS u; +SELECT s.Name FROM sys.schemas s WHERE s.name <> N'sys'; +SELECT SUM(CASE WHEN Status = N'Open' THEN 1 ELSE 0 END) AS OpenCnt, SUM(CASE WHEN Status = N'Closed' THEN 1 ELSE 0 END) AS ClosedCnt FROM dbo.Tickets; +SELECT a.AccountId, a.Balance, b.Balance AS Prior FROM dbo.Accounts a INNER JOIN dbo.AccountSnapshots b ON a.AccountId = b.AccountId AND b.SnapshotDate = CAST(GETDATE() - 1 AS DATE) WHERE a.Balance <> b.Balance; diff --git a/testdata/realworld/sqlserver/03_ddl_migrations.sql b/testdata/realworld/sqlserver/03_ddl_migrations.sql new file mode 100644 index 00000000..8bbc4d26 --- /dev/null +++ b/testdata/realworld/sqlserver/03_ddl_migrations.sql @@ -0,0 +1,33 @@ +-- Schema migrations and DDL as generated by EF Core / Flyway / hand written scripts +CREATE TABLE [dbo].[Customers] ([Id] INT IDENTITY(1,1) NOT NULL, [Name] NVARCHAR(200) NOT NULL, [Email] NVARCHAR(320) NULL, [CreatedAt] DATETIME2(7) NOT NULL CONSTRAINT [DF_Customers_CreatedAt] DEFAULT (SYSUTCDATETIME()), [IsActive] BIT NOT NULL DEFAULT 1, CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED ([Id] ASC)); +CREATE TABLE dbo.Orders (Id BIGINT IDENTITY(1, 1) PRIMARY KEY, CustomerId INT NOT NULL, Total DECIMAL(18, 2) NOT NULL DEFAULT 0, Status NVARCHAR(20) NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(), CONSTRAINT FK_Orders_Customers FOREIGN KEY (CustomerId) REFERENCES dbo.Customers (Id) ON DELETE CASCADE); +CREATE TABLE dbo.Products (Id INT IDENTITY PRIMARY KEY, Sku VARCHAR(50) NOT NULL UNIQUE, Name NVARCHAR(MAX) NULL, Price MONEY NOT NULL, RowVer ROWVERSION, Guid UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(), CHECK (Price >= 0)); +CREATE TABLE #staging (Id INT, Payload NVARCHAR(MAX)); +CREATE TABLE dbo.Computed (A INT, B INT, Total AS (A + B), Doubled AS A * 2 PERSISTED); +ALTER TABLE [dbo].[Customers] ADD [Phone] NVARCHAR(30) NULL; +ALTER TABLE dbo.Customers ADD Region VARCHAR(10) NULL, Tier TINYINT NOT NULL CONSTRAINT DF_Cust_Tier DEFAULT 0; +ALTER TABLE dbo.Customers ALTER COLUMN Name NVARCHAR(400) NOT NULL; +ALTER TABLE dbo.Customers DROP COLUMN Phone; +ALTER TABLE dbo.Customers DROP CONSTRAINT DF_Cust_Tier; +ALTER TABLE dbo.Orders ADD CONSTRAINT CK_Orders_Total CHECK (Total >= 0); +ALTER TABLE dbo.Orders WITH CHECK ADD CONSTRAINT FK_Orders_Cust FOREIGN KEY (CustomerId) REFERENCES dbo.Customers (Id); +ALTER TABLE dbo.Orders NOCHECK CONSTRAINT FK_Orders_Cust; +ALTER TABLE dbo.Orders ADD CONSTRAINT UQ_Orders_Ref UNIQUE NONCLUSTERED (CustomerId, CreatedAt); +CREATE INDEX IX_Orders_CustomerId ON dbo.Orders (CustomerId); +CREATE NONCLUSTERED INDEX [IX_Orders_Status] ON [dbo].[Orders] ([Status] ASC) INCLUDE ([Total], [CreatedAt]) WHERE [Status] <> N'Closed' WITH (FILLFACTOR = 90, ONLINE = ON); +CREATE UNIQUE CLUSTERED INDEX UX_Products_Sku ON dbo.Products (Sku); +CREATE INDEX IX_Cust_Email ON dbo.Customers (Email DESC, Name); +DROP INDEX IX_Orders_CustomerId ON dbo.Orders; +DROP TABLE dbo.OldStuff; +DROP TABLE IF EXISTS dbo.Temp1; +DROP TABLE IF EXISTS #staging; +CREATE VIEW dbo.vw_ActiveCustomers AS SELECT Id, Name, Email FROM dbo.Customers WHERE IsActive = 1; +CREATE OR ALTER VIEW dbo.vw_OrderTotals AS SELECT CustomerId, SUM(Total) AS Total FROM dbo.Orders GROUP BY CustomerId; +DROP VIEW IF EXISTS dbo.vw_Old; +CREATE SCHEMA Reporting; +CREATE SEQUENCE dbo.OrderNumbers AS BIGINT START WITH 1000 INCREMENT BY 1; +SELECT NEXT VALUE FOR dbo.OrderNumbers; +TRUNCATE TABLE dbo.Staging; +CREATE TYPE dbo.IdList AS TABLE (Id INT NOT NULL PRIMARY KEY); +SELECT * INTO #recent FROM dbo.Orders WHERE CreatedAt > DATEADD(DAY, -7, GETDATE()); +SELECT Id, Name INTO dbo.CustomersBackup FROM dbo.Customers; diff --git a/testdata/realworld/sqlserver/04_window_analytics.sql b/testdata/realworld/sqlserver/04_window_analytics.sql new file mode 100644 index 00000000..a2bfc94e --- /dev/null +++ b/testdata/realworld/sqlserver/04_window_analytics.sql @@ -0,0 +1,18 @@ +-- Window functions and analytics +SELECT OrderId, CustomerId, Total, ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderDate DESC) AS rn FROM dbo.Orders; +SELECT Name, Dept, Salary, RANK() OVER (PARTITION BY Dept ORDER BY Salary DESC) AS rk, DENSE_RANK() OVER (ORDER BY Salary DESC) AS drk, NTILE(4) OVER (ORDER BY Salary) AS quartile FROM HR.Employees; +SELECT OrderDate, Total, SUM(Total) OVER (ORDER BY OrderDate ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningTotal FROM dbo.Orders; +SELECT OrderDate, Total, AVG(Total) OVER (ORDER BY OrderDate ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS MovAvg7 FROM dbo.Orders; +SELECT OrderDate, Total, LAG(Total, 1, 0) OVER (ORDER BY OrderDate) AS Prev, LEAD(Total) OVER (ORDER BY OrderDate) AS NextTotal FROM dbo.Orders; +SELECT Dept, Name, FIRST_VALUE(Name) OVER (PARTITION BY Dept ORDER BY Salary DESC) AS TopEarner, LAST_VALUE(Name) OVER (PARTITION BY Dept ORDER BY Salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS Lowest FROM HR.Employees; +SELECT Name, Salary, PERCENT_RANK() OVER (ORDER BY Salary) AS pr, CUME_DIST() OVER (ORDER BY Salary) AS cd FROM HR.Employees; +SELECT DISTINCT Dept, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY Salary) OVER (PARTITION BY Dept) AS MedianSalary FROM HR.Employees; +SELECT Id, COUNT(*) OVER () AS TotalRows, COUNT(*) OVER (PARTITION BY Status) AS StatusRows FROM dbo.Tickets; +SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY Email ORDER BY CreatedAt) AS rn FROM dbo.Customers) d WHERE d.rn > 1; +SELECT SUM(Amount) OVER w AS S, AVG(Amount) OVER w AS A FROM dbo.Sales WINDOW w AS (PARTITION BY Region ORDER BY SaleDate); +WITH Ranked AS (SELECT ProductId, CategoryId, Price, ROW_NUMBER() OVER (PARTITION BY CategoryId ORDER BY Price DESC) AS rn FROM dbo.Products) SELECT ProductId, CategoryId, Price FROM Ranked WHERE rn <= 3; +WITH Daily AS (SELECT CAST(CreatedAt AS DATE) AS D, COUNT(*) AS C FROM dbo.Orders GROUP BY CAST(CreatedAt AS DATE)) SELECT D, C, SUM(C) OVER (ORDER BY D) AS Cumulative, C - LAG(C) OVER (ORDER BY D) AS Delta FROM Daily; +WITH gaps AS (SELECT Id, Id - ROW_NUMBER() OVER (ORDER BY Id) AS grp FROM dbo.Seq) SELECT MIN(Id) AS StartId, MAX(Id) AS EndId FROM gaps GROUP BY grp; +SELECT CustomerId, MAX(CASE WHEN rn = 1 THEN OrderId END) AS LastOrder FROM (SELECT CustomerId, OrderId, ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderDate DESC) AS rn FROM dbo.Orders) t GROUP BY CustomerId; +SELECT Region, Amount, Amount * 100.0 / SUM(Amount) OVER (PARTITION BY Region) AS PctOfRegion FROM dbo.Sales; +SELECT OrderId, Total, SUM(Total) OVER (PARTITION BY CustomerId ORDER BY OrderDate RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS CustRunning FROM dbo.Orders; diff --git a/testdata/realworld/sqlserver/05_dialect_specific.sql b/testdata/realworld/sqlserver/05_dialect_specific.sql new file mode 100644 index 00000000..ec0025d2 --- /dev/null +++ b/testdata/realworld/sqlserver/05_dialect_specific.sql @@ -0,0 +1,36 @@ +-- T-SQL specific syntax: APPLY, MERGE, OUTPUT, FOR JSON/XML, hints, temp tables, variables +SELECT c.Id, c.Name, t.OrderId, t.Total FROM dbo.Customers c CROSS APPLY (SELECT TOP (3) o.OrderId, o.Total FROM dbo.Orders o WHERE o.CustomerId = c.Id ORDER BY o.Total DESC) AS t; +SELECT c.Id, c.Name, t.OrderId FROM dbo.Customers c OUTER APPLY (SELECT TOP (1) o.OrderId FROM dbo.Orders o WHERE o.CustomerId = c.Id ORDER BY o.OrderDate DESC) t; +SELECT o.OrderId, j.value FROM dbo.Orders o CROSS APPLY STRING_SPLIT(o.Tags, N',') AS j; +SELECT value FROM STRING_SPLIT(N'a,b,c', N','); +SELECT Id, Name FROM dbo.Customers FOR JSON PATH; +SELECT Id, Name FROM dbo.Customers FOR JSON PATH, ROOT('customers'), INCLUDE_NULL_VALUES; +SELECT c.Id, c.Name, (SELECT o.OrderId, o.Total FROM dbo.Orders o WHERE o.CustomerId = c.Id FOR JSON PATH) AS Orders FROM dbo.Customers c FOR JSON PATH, WITHOUT_ARRAY_WRAPPER; +SELECT Id, Name FROM dbo.Customers FOR JSON AUTO; +SELECT Id, Name FROM dbo.Customers FOR XML PATH('Customer'), ROOT('Customers'); +SELECT STUFF((SELECT N',' + Name FROM dbo.Customers FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 1, N''); +SELECT Id, JSON_VALUE(Payload, '$.user.name') AS UserName, JSON_QUERY(Payload, '$.items') AS Items FROM dbo.Events WHERE ISJSON(Payload) = 1; +SELECT e.Id, j.* FROM dbo.Events e CROSS APPLY OPENJSON(e.Payload) WITH (Name NVARCHAR(100) '$.name', Qty INT '$.qty') AS j; +SELECT * FROM OPENJSON(@json) WITH (Id INT '$.id', Name NVARCHAR(50) '$.name'); +SELECT * FROM dbo.Orders WITH (NOLOCK) WHERE Status = N'Open'; +SELECT * FROM dbo.Orders o WITH (NOLOCK, INDEX(IX_Orders_Status)) JOIN dbo.Customers c WITH (NOLOCK) ON c.Id = o.CustomerId; +SELECT * FROM dbo.Orders WITH (UPDLOCK, ROWLOCK, READPAST) WHERE Status = 0; +SELECT * FROM dbo.Orders (NOLOCK); +SELECT o.Id FROM dbo.Orders o INNER HASH JOIN dbo.Customers c ON c.Id = o.CustomerId OPTION (MAXDOP 1, RECOMPILE); +SELECT * FROM dbo.Orders WHERE CustomerId = @CustomerId OPTION (OPTIMIZE FOR (@CustomerId = 42)); +SELECT * FROM dbo.Orders OPTION (RECOMPILE); +SELECT @Total = SUM(Total) FROM dbo.Orders WHERE CustomerId = @CustomerId; +SELECT @a = 1, @b = 2; +SELECT TOP (@n) * FROM dbo.Orders ORDER BY Id; +SELECT * FROM dbo.Orders WHERE CreatedAt >= @From AND CreatedAt < @To AND (@Status IS NULL OR Status = @Status); +SELECT * FROM #recent r JOIN ##globaltemp g ON g.Id = r.Id; +SELECT GETDATE() AS Now, SYSDATETIMEOFFSET() AS NowOffset, NEWID() AS G, @@VERSION AS Ver, DB_NAME() AS Db, SUSER_SNAME() AS Usr; +SELECT CAST(N'abc' AS NVARCHAR(10)) + N'def' AS Concatenated, LEN(N'abc') AS L, DATALENGTH(N'abc') AS DL, CONCAT(N'a', N'b', N'c') AS C2, CONCAT_WS(N'-', N'a', N'b') AS C3; +SELECT TRIM(Name) AS T, LTRIM(RTRIM(Name)) AS LR, REPLACE(Name, N' ', N'_') AS R, SUBSTRING(Name, 1, 3) AS S, CHARINDEX(N'@', Email) AS At, LEFT(Name, 2) AS L2, RIGHT(Name, 2) AS R2, UPPER(Name) AS U FROM dbo.Customers; +SELECT 0x1F AS HexVal, 1.5E3 AS Sci, $5.00 AS Money; +SELECT [Order Details].[Unit Price], [Order Details].[Quantity] FROM [Order Details]; +SELECT "Name" FROM "dbo"."Customers"; +SELECT * FROM [MyServer].[MyDb].[dbo].[Customers]; +SELECT * FROM MyDb.dbo.Customers; +SELECT * FROM MyDb..Customers; +SELECT a.Id FROM dbo.A a, dbo.B b WHERE a.Id = b.Id; diff --git a/testdata/realworld/sqlserver/06_merge_etl.sql b/testdata/realworld/sqlserver/06_merge_etl.sql new file mode 100644 index 00000000..4849fd0b --- /dev/null +++ b/testdata/realworld/sqlserver/06_merge_etl.sql @@ -0,0 +1,20 @@ +-- ETL upserts with MERGE and OUTPUT +MERGE dbo.Customers AS tgt USING dbo.CustomersStage AS src ON tgt.Id = src.Id WHEN MATCHED THEN UPDATE SET tgt.Name = src.Name, tgt.Email = src.Email WHEN NOT MATCHED BY TARGET THEN INSERT (Id, Name, Email) VALUES (src.Id, src.Name, src.Email); +MERGE INTO dbo.Inventory AS t USING (SELECT Sku, SUM(Qty) AS Qty FROM dbo.Receipts GROUP BY Sku) AS s ON t.Sku = s.Sku WHEN MATCHED THEN UPDATE SET t.OnHand = t.OnHand + s.Qty WHEN NOT MATCHED THEN INSERT (Sku, OnHand) VALUES (s.Sku, s.Qty); +MERGE dbo.Target AS t USING dbo.Source AS s ON t.Id = s.Id WHEN MATCHED AND s.IsDeleted = 1 THEN DELETE WHEN MATCHED THEN UPDATE SET t.Val = s.Val WHEN NOT MATCHED BY TARGET THEN INSERT (Id, Val) VALUES (s.Id, s.Val) WHEN NOT MATCHED BY SOURCE THEN DELETE; +MERGE dbo.Target AS t USING dbo.Source AS s ON t.Id = s.Id WHEN MATCHED THEN UPDATE SET t.Val = s.Val WHEN NOT MATCHED THEN INSERT (Id, Val) VALUES (s.Id, s.Val) OUTPUT $action, INSERTED.Id, DELETED.Val; +MERGE dbo.Target AS t USING (VALUES (1, N'a'), (2, N'b')) AS s (Id, Val) ON t.Id = s.Id WHEN MATCHED THEN UPDATE SET Val = s.Val WHEN NOT MATCHED THEN INSERT (Id, Val) VALUES (s.Id, s.Val); +MERGE dbo.Dim AS d USING dbo.Stg AS s ON d.BK = s.BK WHEN MATCHED AND (d.Name <> s.Name OR d.City <> s.City) THEN UPDATE SET d.Name = s.Name, d.City = s.City, d.UpdatedAt = GETUTCDATE() WHEN NOT MATCHED BY SOURCE AND d.IsCurrent = 1 THEN UPDATE SET d.IsCurrent = 0; +INSERT INTO dbo.OrdersArchive (Id, CustomerId, Total) SELECT Id, CustomerId, Total FROM dbo.Orders WHERE CreatedAt < DATEADD(YEAR, -2, GETDATE()); +INSERT INTO dbo.Orders (CustomerId, Total) SELECT CustomerId, SUM(Amount) FROM dbo.Payments GROUP BY CustomerId; +INSERT INTO dbo.Archive SELECT * FROM dbo.Live WHERE Id IN (SELECT Id FROM dbo.ToArchive); +INSERT INTO dbo.Defaults DEFAULT VALUES; +INSERT dbo.Customers (Name) VALUES (N'No INTO keyword'); +INSERT INTO dbo.Log (Msg) OUTPUT INSERTED.Id INTO @Ids (Id) VALUES (N'x'); +DELETE FROM dbo.Orders OUTPUT DELETED.Id, DELETED.Total INTO dbo.OrdersArchive (Id, Total) WHERE CreatedAt < '2020-01-01'; +UPDATE t SET t.Total = s.Total FROM dbo.Orders t JOIN (SELECT OrderId, SUM(Qty * Price) AS Total FROM dbo.Lines GROUP BY OrderId) s ON s.OrderId = t.Id; +UPDATE dbo.Counters SET @val = Value = Value + 1 WHERE Name = N'orders'; +UPDATE dbo.Products SET Price = CASE WHEN Price < 10 THEN Price * 1.2 WHEN Price < 100 THEN Price * 1.1 ELSE Price END; +UPDATE STATISTICS dbo.Orders; +WITH Dups AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY Email ORDER BY Id) AS rn FROM dbo.Customers) DELETE FROM Dups WHERE rn > 1; +WITH Src AS (SELECT TOP (100) * FROM dbo.Queue WITH (ROWLOCK, READPAST, UPDLOCK) ORDER BY Id) UPDATE Src SET Status = 1 OUTPUT INSERTED.Id, INSERTED.Payload; diff --git a/testdata/realworld/sqlserver/07_ctes_deep.sql b/testdata/realworld/sqlserver/07_ctes_deep.sql new file mode 100644 index 00000000..38182536 --- /dev/null +++ b/testdata/realworld/sqlserver/07_ctes_deep.sql @@ -0,0 +1,22 @@ +-- Complex CTE chains, recursion, deep nesting, many joins +WITH Org AS (SELECT EmployeeId, ManagerId, Name, 0 AS Lvl FROM HR.Employees WHERE ManagerId IS NULL UNION ALL SELECT e.EmployeeId, e.ManagerId, e.Name, o.Lvl + 1 FROM HR.Employees e JOIN Org o ON e.ManagerId = o.EmployeeId) SELECT * FROM Org ORDER BY Lvl OPTION (MAXRECURSION 100); +WITH Nums AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM Nums WHERE n < 1000) SELECT n FROM Nums OPTION (MAXRECURSION 0); +WITH Paths AS (SELECT Id, ParentId, CAST(Name AS NVARCHAR(MAX)) AS Path FROM dbo.Categories WHERE ParentId IS NULL UNION ALL SELECT c.Id, c.ParentId, p.Path + N' > ' + c.Name FROM dbo.Categories c INNER JOIN Paths p ON c.ParentId = p.Id) SELECT Id, Path FROM Paths; +WITH a AS (SELECT CustomerId, SUM(Total) AS T FROM dbo.Orders GROUP BY CustomerId), b AS (SELECT CustomerId, COUNT(*) AS C FROM dbo.Orders GROUP BY CustomerId), c AS (SELECT a.CustomerId, a.T, b.C FROM a JOIN b ON a.CustomerId = b.CustomerId), d AS (SELECT *, T / NULLIF(C, 0) AS Avg FROM c) SELECT * FROM d WHERE Avg > 100; +WITH Sales2024 (Region, Total) AS (SELECT Region, SUM(Amount) FROM dbo.Sales WHERE YEAR(SaleDate) = 2024 GROUP BY Region), Sales2023 (Region, Total) AS (SELECT Region, SUM(Amount) FROM dbo.Sales WHERE YEAR(SaleDate) = 2023 GROUP BY Region) SELECT a.Region, a.Total AS T24, b.Total AS T23, (a.Total - b.Total) * 100.0 / NULLIF(b.Total, 0) AS GrowthPct FROM Sales2024 a FULL OUTER JOIN Sales2023 b ON a.Region = b.Region; +SELECT o.OrderId, c.CustomerName, e.LastName, p.ProductName, cat.CategoryName, s.CompanyName, sh.CompanyName AS Shipper, r.RegionDescription FROM Sales.Orders o JOIN Sales.Customers c ON c.CustomerId = o.CustomerId JOIN HR.Employees e ON e.EmployeeId = o.EmployeeId JOIN Sales.OrderDetails od ON od.OrderId = o.OrderId JOIN Production.Products p ON p.ProductId = od.ProductId JOIN Production.Categories cat ON cat.CategoryId = p.CategoryId JOIN Production.Suppliers s ON s.SupplierId = p.SupplierId LEFT JOIN Sales.Shippers sh ON sh.ShipperId = o.ShipperId LEFT JOIN Geo.Regions r ON r.RegionId = c.RegionId WHERE o.OrderDate >= '20240101' AND o.OrderDate < '20250101'; +SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT Id, Name FROM dbo.Customers WHERE IsActive = 1) a WHERE Id > 10) b WHERE Id < 1000) c; +SELECT Id FROM dbo.A WHERE Id IN (SELECT Id FROM dbo.B WHERE Val > (SELECT AVG(Val) FROM dbo.C WHERE Grp IN (SELECT Grp FROM dbo.D WHERE EXISTS (SELECT 1 FROM dbo.E WHERE E.Id = D.Id)))); +SELECT Id FROM dbo.A WHERE NOT EXISTS (SELECT 1 FROM dbo.B WHERE B.AId = A.Id) AND Val > ALL (SELECT Val FROM dbo.C) AND Val < ANY (SELECT Val FROM dbo.D); +SELECT Id, Name FROM dbo.A UNION SELECT Id, Name FROM dbo.B UNION ALL SELECT Id, Name FROM dbo.C EXCEPT SELECT Id, Name FROM dbo.D INTERSECT SELECT Id, Name FROM dbo.E; +(SELECT Id FROM dbo.A UNION SELECT Id FROM dbo.B) INTERSECT SELECT Id FROM dbo.C ORDER BY Id; +SELECT t.* FROM (VALUES (1, N'a'), (2, N'b'), (3, N'c')) AS t (Id, Name); +SELECT a.Id, b.Id FROM dbo.A a CROSS JOIN dbo.B b WHERE a.Id <> b.Id; +SELECT a.Id FROM dbo.A a RIGHT OUTER JOIN dbo.B b ON a.Id = b.Id LEFT OUTER JOIN dbo.C c ON c.Id = b.Id FULL JOIN dbo.D d ON d.Id = c.Id; +SELECT CASE WHEN a > 1 THEN CASE WHEN b > 2 THEN N'x' ELSE N'y' END WHEN a < 0 THEN N'z' ELSE N'w' END FROM dbo.T; +SELECT (a + b) * (c - d) / NULLIF(e % 3, 0) AS Calc, -a AS Neg, ~b AS BitNot, a & b AS BitAnd, a | b AS BitOr, a ^ b AS BitXor FROM dbo.T; +SELECT * FROM dbo.T WHERE (a = 1 OR b = 2) AND NOT (c = 3 OR d IS NOT NULL) AND e NOT BETWEEN 1 AND 5 AND f NOT IN (1, 2, 3) AND g NOT LIKE N'x%' ESCAPE N'\'; +SELECT 1 + 2 * 3 - 4 / 2 AS Prec; +SELECT Name FROM dbo.Customers c WHERE c.Id = (SELECT MAX(Id) FROM dbo.Customers) ORDER BY 1; +SELECT COUNT(DISTINCT CustomerId) AS UniqueCust, COUNT_BIG(*) AS Big, APPROX_COUNT_DISTINCT(CustomerId) AS Approx FROM dbo.Orders; +SELECT a.Id, SUM(b.Qty) AS Q FROM dbo.A a JOIN dbo.B b ON b.AId = a.Id GROUP BY a.Id HAVING COUNT(*) > 1 AND SUM(b.Qty) < 100 ORDER BY Q DESC, a.Id; diff --git a/testdata/realworld/sqlserver/08_procs_batches.sql b/testdata/realworld/sqlserver/08_procs_batches.sql new file mode 100644 index 00000000..a8e1ac32 --- /dev/null +++ b/testdata/realworld/sqlserver/08_procs_batches.sql @@ -0,0 +1,33 @@ +-- Stored procedure calls, variable handling, control-flow statements +EXEC dbo.GetCustomer @Id = 42; +EXEC dbo.usp_Report @From = '2024-01-01', @To = '2024-12-31', @Region = N'EMEA'; +EXECUTE sp_executesql N'SELECT * FROM dbo.Orders WHERE Id = @Id', N'@Id INT', @Id = 5; +EXEC sp_help N'dbo.Customers'; +EXEC sp_rename N'dbo.Old', N'New'; +DECLARE @i INT = 0; +DECLARE @name NVARCHAR(100), @total DECIMAL(18, 2) = 0; +DECLARE @t TABLE (Id INT PRIMARY KEY, Name NVARCHAR(50)); +SET @i = @i + 1; +SET @name = (SELECT TOP (1) Name FROM dbo.Customers ORDER BY Id); +SET NOCOUNT ON; +SET XACT_ABORT ON; +SET TRANSACTION ISOLATION LEVEL READ COMMITTED; +SET IDENTITY_INSERT dbo.Customers ON; +USE [Northwind]; +BEGIN TRANSACTION; +COMMIT TRANSACTION; +ROLLBACK; +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = N'Customers') DROP TABLE dbo.Customers; +IF @i > 0 SELECT 1 ELSE SELECT 0; +WHILE @i < 10 BEGIN SET @i += 1; END; +BEGIN TRY SELECT 1 / 0; END TRY BEGIN CATCH SELECT ERROR_NUMBER() AS Err, ERROR_MESSAGE() AS Msg; END CATCH; +RAISERROR (N'Something failed: %s', 16, 1, N'detail'); +THROW 50001, N'Custom error', 1; +PRINT N'Done'; +CREATE PROCEDURE dbo.GetOrders @CustomerId INT, @Status NVARCHAR(20) = NULL AS BEGIN SET NOCOUNT ON; SELECT * FROM dbo.Orders WHERE CustomerId = @CustomerId AND (@Status IS NULL OR Status = @Status); END; +CREATE OR ALTER PROCEDURE dbo.UpsertCustomer @Id INT, @Name NVARCHAR(200) AS BEGIN UPDATE dbo.Customers SET Name = @Name WHERE Id = @Id; IF @@ROWCOUNT = 0 INSERT INTO dbo.Customers (Id, Name) VALUES (@Id, @Name); END; +CREATE FUNCTION dbo.fn_Tax (@amount DECIMAL(18, 2)) RETURNS DECIMAL(18, 2) AS BEGIN RETURN @amount * 0.2; END; +CREATE FUNCTION dbo.fn_OrdersFor (@id INT) RETURNS TABLE AS RETURN (SELECT * FROM dbo.Orders WHERE CustomerId = @id); +CREATE TRIGGER dbo.trg_Customers_Audit ON dbo.Customers AFTER INSERT, UPDATE AS BEGIN INSERT INTO dbo.AuditLog (EventType) SELECT N'chg' FROM inserted; END; +GRANT SELECT, INSERT ON dbo.Customers TO [AppUser]; +REVOKE DELETE ON dbo.Customers FROM [AppUser]; diff --git a/testdata/realworld/sqlserver/09_admin_dmv.sql b/testdata/realworld/sqlserver/09_admin_dmv.sql new file mode 100644 index 00000000..2f63d471 --- /dev/null +++ b/testdata/realworld/sqlserver/09_admin_dmv.sql @@ -0,0 +1,21 @@ +-- DBA / admin / monitoring queries against catalog views and DMVs +SELECT t.name AS TableName, s.name AS SchemaName, p.rows AS RowCounts FROM sys.tables t JOIN sys.schemas s ON s.schema_id = t.schema_id JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1) ORDER BY p.rows DESC; +SELECT TOP (20) qs.total_elapsed_time / qs.execution_count AS AvgElapsed, qs.execution_count, SUBSTRING(st.text, (qs.statement_start_offset / 2) + 1, ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2) + 1) AS StatementText FROM sys.dm_exec_query_stats AS qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st ORDER BY AvgElapsed DESC; +SELECT r.session_id, r.status, r.wait_type, r.blocking_session_id, DB_NAME(r.database_id) AS DbName FROM sys.dm_exec_requests r WHERE r.session_id > 50 AND r.blocking_session_id <> 0; +SELECT c.name AS ColumnName, ty.name AS TypeName, c.max_length, c.is_nullable FROM sys.columns c JOIN sys.types ty ON ty.user_type_id = c.user_type_id WHERE c.object_id = OBJECT_ID(N'dbo.Customers'); +SELECT i.name, i.type_desc, ips.avg_fragmentation_in_percent FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, N'LIMITED') ips JOIN sys.indexes i ON i.object_id = ips.object_id AND i.index_id = ips.index_id WHERE ips.avg_fragmentation_in_percent > 30; +SELECT name, database_id, create_date, compatibility_level, recovery_model_desc FROM sys.databases WHERE state = 0 ORDER BY name; +SELECT SERVERPROPERTY('ProductVersion') AS Ver, SERVERPROPERTY('Edition') AS Ed; +SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = N'dbo' AND TABLE_NAME = N'Customers' ORDER BY ORDINAL_POSITION; +SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'; +SELECT OBJECT_SCHEMA_NAME(object_id) + N'.' + OBJECT_NAME(object_id) AS FullName FROM sys.objects WHERE type IN ('U', 'V'); +SELECT login_name, COUNT(*) AS Sessions FROM sys.dm_exec_sessions WHERE is_user_process = 1 GROUP BY login_name HAVING COUNT(*) > 5; +SELECT DB_NAME(database_id) AS DatabaseName, SUM(size) * 8 / 1024 AS SizeMB FROM sys.master_files GROUP BY database_id; +SELECT fk.name, OBJECT_NAME(fk.parent_object_id) AS ParentTable, OBJECT_NAME(fk.referenced_object_id) AS RefTable FROM sys.foreign_keys fk; +SELECT TOP (50) wait_type, wait_time_ms, waiting_tasks_count FROM sys.dm_os_wait_stats WHERE wait_type NOT LIKE N'SLEEP%' ORDER BY wait_time_ms DESC; +SELECT * FROM sys.dm_db_missing_index_details; +DBCC CHECKDB (N'Northwind') WITH NO_INFOMSGS; +ALTER INDEX ALL ON dbo.Orders REBUILD WITH (ONLINE = ON); +ALTER INDEX IX_Orders_Status ON dbo.Orders REORGANIZE; +ALTER DATABASE CURRENT SET RECOVERY SIMPLE; +KILL 55; diff --git a/testdata/realworld/sqlserver/10_etl_mixed.sql b/testdata/realworld/sqlserver/10_etl_mixed.sql new file mode 100644 index 00000000..29a34b26 --- /dev/null +++ b/testdata/realworld/sqlserver/10_etl_mixed.sql @@ -0,0 +1,32 @@ +-- Misc production patterns: data cleansing, pagination, search, temporal tables, 2022 functions +SELECT Id, LTRIM(RTRIM(Name)) AS Name, TRY_CONVERT(INT, Age) AS Age FROM dbo.RawImport WHERE TRY_CONVERT(INT, Age) IS NOT NULL; +SELECT CONVERT(DATETIME, '2024-03-15 10:30:00', 120) AS D1, CONVERT(NVARCHAR(30), GETDATE(), 112) AS D2, CONVERT(VARCHAR, GETDATE(), 101) AS D3, CONVERT(DECIMAL(10, 2), N'123.45') AS D4; +SELECT CAST(GETDATE() AS DATE) AS Today, CAST(1 AS BIT) AS B, CAST(N'x' AS VARCHAR(MAX)) AS V; +SELECT DATEFROMPARTS(2024, 1, 31) AS D, DATETIMEFROMPARTS(2024, 1, 31, 12, 0, 0, 0) AS DT, EOMONTH(DATEFROMPARTS(2024, 2, 1)) AS E; +SELECT DATEDIFF_BIG(SECOND, '1970-01-01', GETUTCDATE()) AS Epoch, DATE_BUCKET(WEEK, 1, GETDATE()) AS Wk; +SELECT GREATEST(1, 2, 3) AS G, LEAST(1, 2, 3) AS L; +SELECT DATETRUNC(MONTH, GETDATE()) AS MonthStart; +SELECT IIF(a IS NULL, 0, a) + IIF(b > 0, b, 0) AS Sum2 FROM dbo.T; +SELECT p.* FROM dbo.Products p WHERE p.IsDeleted = 0 AND (p.Name LIKE N'%' + @search + N'%' OR p.Sku = @search) ORDER BY p.Name OFFSET (@page - 1) * @size ROWS FETCH NEXT @size ROWS ONLY; +SELECT COUNT(*) OVER () AS Total, Id, Name FROM dbo.Products ORDER BY Id OFFSET 0 ROWS FETCH FIRST 25 ROWS ONLY; +SELECT * FROM dbo.Employees FOR SYSTEM_TIME AS OF '2024-01-01'; +SELECT * FROM dbo.Employees FOR SYSTEM_TIME BETWEEN '2024-01-01' AND '2024-06-30' WHERE Id = 1; +SELECT * FROM dbo.Employees FOR SYSTEM_TIME ALL ORDER BY ValidFrom; +SELECT * FROM OPENQUERY(LinkedSrv, 'SELECT * FROM remote.dbo.T'); +SELECT * FROM OPENROWSET(BULK 'C:\data\file.csv', SINGLE_CLOB) AS x; +BULK INSERT dbo.Staging FROM 'C:\data\file.csv' WITH (FIELDTERMINATOR = ',', ROWTERMINATOR = '\n', FIRSTROW = 2); +SELECT HASHBYTES('SHA2_256', CONCAT(a, b)) AS H, CHECKSUM(a, b) AS CS, ABS(CHECKSUM(NEWID())) % 100 AS Rnd FROM dbo.T; +SELECT Id, Name, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS RowNum FROM dbo.T; +SELECT Id FROM dbo.T TABLESAMPLE (10 PERCENT); +SELECT Name, Email FROM dbo.Customers WHERE CONTAINS(Name, N'"smith*"'); +SELECT Name FROM dbo.Customers WHERE FREETEXT(Notes, N'late delivery'); +SELECT COALESCE(NULLIF(LTRIM(RTRIM(a)), ''), N'N/A') FROM dbo.T; +SELECT ISNULL(SUM(Total), 0) FROM dbo.Orders WHERE CustomerId = @cid; +SELECT ROUND(Price * 1.0825, 2) AS WithTax, CEILING(Qty / 10.0) AS Boxes, FLOOR(Price) AS FP, POWER(2, 10) AS P, SQRT(16) AS S FROM dbo.Products; +SELECT TOP (1) WITH TIES CustomerId, Total FROM dbo.Orders ORDER BY Total DESC; +SELECT [Group], [Order], [Select] FROM dbo.[Table]; +SELECT u.[User Name] AS [Display Name] FROM dbo.[User Accounts] AS u; +SELECT x = 1, y = Name FROM dbo.T; +SELECT 'It''s' AS S1, N'Ünïcödé ✓' AS S2, N'日本語' AS S3; +SELECT /* inline comment */ 1 AS a -- trailing comment +; From 0c005068f38a6c8842bf2065e3b1b56ee6ff7729 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 18:11:18 +0530 Subject: [PATCH 03/95] fix(parser): operators and predicates (IS forms, ANY/ALL arrays, AT TIME ZONE, COLLATE, bitwise, ESCAPE) Close the operator and predicate gaps found by the real-world corpus. Parser - IS [NOT] TRUE/FALSE/UNKNOWN, IS [NOT] DISTINCT FROM, SQLite "a IS [NOT] expr", PostgreSQL/SQLite ISNULL/NOTNULL (normalised to IS [NOT] NULL). - = ANY/ALL/SOME over arrays and placeholders (new AnyAllExpression); the subquery forms keep AnyExpression/AllExpression. - Postfix AT TIME ZONE and COLLATE (new AtTimeZoneExpression and CollateExpression), binding tighter than arithmetic; COLLATE on CREATE INDEX columns fills IndexColumn.Collate. - Binary operators @@, &&, &, |, #, <<, >>, <->, <=>, == via a precedence climbing level between comparison and ||, unary ~ and MySQL !, MySQL DIV/MOD/XOR/SOUNDS LIKE/&&/:=, SQLite GLOB/MATCH, SIMILAR TO, NOT REGEXP/ RLIKE. Word operators are gated to the dialect that defines them. <=>, <<, >>, <->, == are read from adjacent tokens until the tokenizer emits them. - LIKE/ILIKE/SIMILAR TO ... ESCAPE (BinaryExpression.Escape). - Predicates and comparisons chain left to right (a IN (1, 2) = TRUE); SQLite IN () and IN tbl; ClickHouse GLOBAL IN keeps the GLOBAL flag; ClickHouse cond ? a : b (TernaryExpression). - Data types: array dimensions (int[3][4]), schema-qualified, multi-word (DOUBLE PRECISION, CHARACTER VARYING, TIMESTAMP WITH TIME ZONE, SIGNED INTEGER) and CHAR(n) CHARACTER SET x, shared by CAST and ::. - AND and OR are stored in canonical upper case. Formatter and AST - Render the new nodes and fields; extend the operator and data-type allow-lists so they are written verbatim. - format_sql (MCP) now upper-cases AND/OR by default; tests and docs updated. Real-world corpus: 59 statements now pass, 6 move to a later failure stage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/operators_c1_render_test.go | 81 ++++ pkg/formatter/render.go | 10 + pkg/mcp/format_options_test.go | 12 +- pkg/mcp/server.go | 4 +- pkg/mcp/tools.go | 2 +- pkg/sql/ast/ast_expr_ops.go | 134 ++++++ pkg/sql/ast/ast_expressions.go | 18 +- pkg/sql/ast/pool_expression_release.go | 37 ++ pkg/sql/ast/quote_names.go | 36 +- pkg/sql/ast/sql.go | 22 +- pkg/sql/ast/sql_ops.go | 173 +++++++ pkg/sql/ast/sql_ops_test.go | 144 ++++++ pkg/sql/parser/ddl_index.go | 10 + pkg/sql/parser/expressions.go | 151 +++--- pkg/sql/parser/expressions_complex.go | 58 +-- pkg/sql/parser/expressions_datatype.go | 257 ++++++++++ pkg/sql/parser/expressions_literal.go | 16 + pkg/sql/parser/expressions_operators.go | 436 ++++++++++------- pkg/sql/parser/expressions_ops.go | 515 +++++++++++++++++++++ pkg/sql/parser/operators_c1_gating_test.go | 245 ++++++++++ pkg/sql/parser/operators_c1_test.go | 291 ++++++++++++ testdata/realworld/known_failures.txt | 67 +-- 22 files changed, 2338 insertions(+), 381 deletions(-) create mode 100644 pkg/formatter/operators_c1_render_test.go create mode 100644 pkg/sql/ast/ast_expr_ops.go create mode 100644 pkg/sql/ast/sql_ops.go create mode 100644 pkg/sql/ast/sql_ops_test.go create mode 100644 pkg/sql/parser/expressions_datatype.go create mode 100644 pkg/sql/parser/expressions_ops.go create mode 100644 pkg/sql/parser/operators_c1_gating_test.go create mode 100644 pkg/sql/parser/operators_c1_test.go diff --git a/pkg/formatter/operators_c1_render_test.go b/pkg/formatter/operators_c1_render_test.go new file mode 100644 index 00000000..3daaaa83 --- /dev/null +++ b/pkg/formatter/operators_c1_render_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter_test + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestOperatorsC1_RenderRoundTrip formats the operator and predicate +// constructs through every renderer: each output must re-parse to the same +// statement and keep the operator words (nothing is dropped or reworded). +func TestOperatorsC1_RenderRoundTrip(t *testing.T) { + tests := []struct { + d keywords.SQLDialect + in string + want string // must appear in the compact rendering + }{ + {keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE id = ANY($1)", "id = ANY ($1)"}, + {keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE id <> ALL(ARRAY[1, 2])", "id <> ALL (ARRAY[1, 2])"}, + {keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE 'x' = SOME(tags)", "'x' = SOME (tags)"}, + {keywords.DialectPostgreSQL, "SELECT u.id FROM u WHERE u.id IS DISTINCT FROM 5", "u.id IS DISTINCT FROM 5"}, + {keywords.DialectSnowflake, "SELECT u.id FROM u WHERE u.id IS NOT DISTINCT FROM 5", "u.id IS NOT DISTINCT FROM 5"}, + {keywords.DialectPostgreSQL, "SELECT x IS NOT FALSE, y IS UNKNOWN FROM t", "x IS NOT FALSE, y IS UNKNOWN"}, + {keywords.DialectSQLite, "SELECT * FROM t WHERE a IS NOT b", "a IS NOT b"}, + {keywords.DialectPostgreSQL, "SELECT created_at AT TIME ZONE 'UTC' FROM t", "created_at AT TIME ZONE 'UTC'"}, + {keywords.DialectPostgreSQL, `SELECT col COLLATE "C" FROM t ORDER BY name COLLATE "en_US"`, `col COLLATE "C"`}, + {keywords.DialectMySQL, "SELECT x FROM t WHERE x COLLATE utf8mb4_bin = 'a'", "x COLLATE utf8mb4_bin = 'a'"}, + {keywords.DialectPostgreSQL, "SELECT x FROM t WHERE tsv @@ to_tsquery('a') AND a && b", "tsv @@ to_tsquery('a') AND a && b"}, + {keywords.DialectSQLServer, "SELECT a & b, ~c FROM t", "a & b, ~c"}, + {keywords.DialectMySQL, "SELECT a DIV 3, b MOD 2 FROM t WHERE x NOT REGEXP 'a' AND y SOUNDS LIKE z", "a DIV 3, b MOD 2"}, + {keywords.DialectMySQL, "SELECT * FROM t WHERE a <=> NULL", "a <=> NULL"}, + {keywords.DialectSQLite, "SELECT * FROM t WHERE p GLOB '*.txt' AND a MATCH 'foo' AND b == 1", "p GLOB '*.txt' AND a MATCH 'foo' AND b == 1"}, + {keywords.DialectPostgreSQL, "SELECT a FROM t WHERE n SIMILAR TO 'x%' AND m NOT SIMILAR TO 'y%'", "n SIMILAR TO 'x%' AND m NOT SIMILAR TO 'y%'"}, + {keywords.DialectOracle, "SELECT a FROM t WHERE a LIKE 'x' ESCAPE 'y'", "a LIKE 'x' ESCAPE 'y'"}, + {keywords.DialectSQLite, "SELECT a IN (1, 2) = 1 FROM t WHERE a NOT IN () AND b IN u", "a IN (1, 2) = 1"}, + {keywords.DialectClickHouse, "SELECT a FROM t WHERE a GLOBAL IN (SELECT 1) AND b GLOBAL NOT IN (SELECT 2)", "a GLOBAL IN (SELECT 1) AND b GLOBAL NOT IN (SELECT 2)"}, + {keywords.DialectClickHouse, "SELECT a ? 1 : 2", "a ? 1 : 2"}, + {keywords.DialectPostgreSQL, "SELECT x::int[], CAST(a AS text[]), x::double precision FROM t", "CAST(x AS int[]), CAST(a AS text[]), CAST(x AS double precision)"}, + {keywords.DialectMySQL, "SELECT CAST(x AS SIGNED INTEGER), CAST(y AS CHAR CHARACTER SET utf8mb4) FROM t", "CAST(x AS SIGNED INTEGER), CAST(y AS CHAR CHARACTER SET utf8mb4)"}, + } + for _, tc := range tests { + t.Run(string(tc.d)+"/"+tc.in, func(t *testing.T) { + rs := assertRoundTrip(t, tc.d, tc.in) + for _, name := range []string{"SQL", "FmtCompact"} { + if !strings.Contains(rs[name], tc.want) { + t.Errorf("%s = %q, want it to contain %q", name, rs[name], tc.want) + } + } + }) + } +} + +// TestOperatorsC1_LogicalOperatorsUpperCase pins that the formatter writes +// AND and OR in upper case whatever their source spelling (the CLI used to +// leave them as typed). +func TestOperatorsC1_LogicalOperatorsUpperCase(t *testing.T) { + rs := assertRoundTrip(t, keywords.DialectGeneric, "select a from t where a=1 and b=2 or c=3") + for name, out := range rs { + if strings.Contains(out, " and ") || strings.Contains(out, " or ") { + t.Errorf("%s = %q, want AND / OR in upper case", name, out) + } + if !strings.Contains(out, " AND ") || !strings.Contains(out, " OR ") { + t.Errorf("%s = %q, want it to contain AND and OR", name, out) + } + } +} diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 27e8c32f..14de0c3d 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -1221,11 +1221,21 @@ func renderIn(i *ast.InExpression, opts ast.FormatOptions) string { sb.WriteString(FormatExpression(i.Expr, opts)) sb.WriteString(" ") + if i.Global { + sb.WriteString(f.kw("GLOBAL")) + sb.WriteString(" ") + } if i.Not { sb.WriteString(f.kw("NOT")) sb.WriteString(" ") } sb.WriteString(f.kw("IN")) + if i.Bare && i.Subquery == nil { + // SQLite "a IN tbl": no parentheses around the table reference. + sb.WriteString(" ") + sb.WriteString(exprListSQL(i.List)) + return f.result() + } sb.WriteString(" (") if i.Subquery != nil { sb.WriteString(FormatStatement(i.Subquery, nestedOpts(opts))) diff --git a/pkg/mcp/format_options_test.go b/pkg/mcp/format_options_test.go index 71ffca45..ec5d70fc 100644 --- a/pkg/mcp/format_options_test.go +++ b/pkg/mcp/format_options_test.go @@ -48,9 +48,14 @@ func TestFormatSQLInternal_Options(t *testing.T) { want string }{ { - name: "default keeps formatter casing", + name: "default upper-cases AND and OR", sql: fmtProbeSQL, indent: 2, - want: "SELECT a, b\nFROM t\nWHERE x = 1 and y IN (SELECT z FROM u WHERE w > 2) or c IS NOT NULL\nORDER BY a", + want: "SELECT a, b\nFROM t\nWHERE x = 1 AND y IN (SELECT z FROM u WHERE w > 2) OR c IS NOT NULL\nORDER BY a", + }, + { + name: "default keeps the case of LIKE", + sql: "select a from t where name like 'x%' and id = 1", indent: 2, + want: "SELECT a\nFROM t\nWHERE name like 'x%' AND id = 1", }, { name: "uppercase_keywords upper-cases every keyword", @@ -188,7 +193,8 @@ func TestHandleFormatSQL_OptionsChangeOutput(t *testing.T) { return formattedSQL(t, sc) } base := call(map[string]any{"sql": fmtProbeSQL}) - if upper := call(map[string]any{"sql": fmtProbeSQL, "uppercase_keywords": true}); upper == base { + likeSQL := "select a from t where name like 'x%'" + if upper := call(map[string]any{"sql": likeSQL, "uppercase_keywords": true}); upper == call(map[string]any{"sql": likeSQL}) { t.Error("uppercase_keywords:true did not change the output") } if one := call(map[string]any{"sql": fmtProbeSQL, "indent_size": 0}); one == base { diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go index 50fd3aaa..443102e6 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -58,7 +58,7 @@ const serverInstructions = `GoSQLX parses, validates, formats, lints, and securi Pick the narrowest tool for the request: - analyze_sql: one-shot review of a query (validity, statement types, tables/columns/functions, security findings, lint violations, formatted SQL). Prefer it when the user asks to "review", "check", or "analyze" SQL; hosts that support MCP Apps render it as an interactive report. - validate_sql: is the SQL syntactically valid, optionally for a specific dialect (postgresql, mysql, mariadb, sqlserver, oracle, sqlite, snowflake, clickhouse). -- format_sql: pretty-print SQL, one clause per line. Options: uppercase_keywords=true upper-cases all keywords, but not data type names (default false: clause keywords upper-case, other keywords such as and/or may keep their input case); indent_size=0 puts each statement on one line, larger values only indent nested blocks such as CREATE TABLE columns; add_semicolon appends ";". +- format_sql: pretty-print SQL, one clause per line. Options: uppercase_keywords=true upper-cases all keywords, but not data type names (default false: clause keywords upper-case, other keywords such as like may keep their input case); indent_size=0 puts each statement on one line, larger values only indent nested blocks such as CREATE TABLE columns; add_semicolon appends ";". - parse_sql: statement count and AST statement types. - extract_metadata: tables, columns, and functions referenced. - security_scan: SQL-injection pattern detection (tautologies, UNION-based, stacked queries, comment bypass, ...), with severities. @@ -341,7 +341,7 @@ func (s *Server) registerTools() { mcp.WithBoolean("uppercase_keywords", mcp.Description("true upper-cases all SQL keywords (AND, IN, CASE, ...), but not data type names. "+ "false keeps the formatter's casing: clause keywords (SELECT, FROM, WHERE, ...) "+ - "upper-case, other keywords (e.g. and, or) may keep their input case (default: false)"), + "upper-case, other keywords (e.g. like) may keep their input case (default: false)"), ), mcp.WithBoolean("add_semicolon", mcp.Description("Append a trailing semicolon (default: false)"), diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index b0323302..722d62ce 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -78,7 +78,7 @@ func validateSQLInternal(sql, dialect string) (map[string]any, error) { // formatSQLInternal formats a SQL string using the provided options. // // gosqlx.Format always emits clause keywords (SELECT, FROM, WHERE, ...) in -// upper case and leaves the case of other keywords (and, in, case, ...) to the +// upper case and leaves the case of other keywords (like, case, ...) to the // AST, so its UppercaseKeywords option alone changes nothing visible. When // uppercaseKeywords is true the remaining keywords are therefore upper-cased // here (data type names are left alone); when false the output is diff --git a/pkg/sql/ast/ast_expr_ops.go b/pkg/sql/ast/ast_expr_ops.go new file mode 100644 index 00000000..474d893f --- /dev/null +++ b/pkg/sql/ast/ast_expr_ops.go @@ -0,0 +1,134 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "github.com/ajitpratap0/GoSQLX/pkg/models" + +// AtTimeZoneExpression represents the postfix AT TIME ZONE operator +// (PostgreSQL, Snowflake, SQL Server AT TIME ZONE, Oracle): +// +// created_at AT TIME ZONE 'UTC' +// +// Chained uses nest to the left: a AT TIME ZONE 'UTC' AT TIME ZONE 'PST' is +// AtTimeZone(AtTimeZone(a, 'UTC'), 'PST'). +type AtTimeZoneExpression struct { + Expr Expression + Zone Expression + Pos models.Location // Source position of the AT keyword +} + +func (a *AtTimeZoneExpression) expressionNode() {} +func (a AtTimeZoneExpression) TokenLiteral() string { return "AT TIME ZONE" } +func (a AtTimeZoneExpression) Children() []Node { + var nodes []Node + if a.Expr != nil { + nodes = append(nodes, a.Expr) + } + if a.Zone != nil { + nodes = append(nodes, a.Zone) + } + return nodes +} + +// CollateExpression represents the postfix COLLATE operator: +// +// name COLLATE "C" (PostgreSQL) +// x COLLATE utf8mb4_bin (MySQL / MariaDB) +// a COLLATE Latin1_General_CI_AS (SQL Server) +// a COLLATE NOCASE (SQLite) +type CollateExpression struct { + Expr Expression + // Collation is the collation name; a schema-qualified name is dotted + // ("pg_catalog.default"). + Collation string + // Quoted records that the (last part of the) name was written as a + // double-quoted identifier. It is re-quoted on output because PostgreSQL + // folds a bare C to c, which is a different (nonexistent) collation. + Quoted bool + // Literal records a collation written as a string literal (ClickHouse + // ORDER BY y COLLATE 'en'); it is written back as one. + Literal bool + Pos models.Location // Source position of the COLLATE keyword +} + +func (c *CollateExpression) expressionNode() {} +func (c CollateExpression) TokenLiteral() string { return "COLLATE" } +func (c CollateExpression) Children() []Node { + if c.Expr == nil { + return nil + } + return []Node{c.Expr} +} + +// AnyAllExpression represents a quantified comparison whose right side is an +// array-valued expression rather than a subquery: +// +// id = ANY($1) +// 'x' = ANY(tags) +// id <> ALL(ARRAY[1, 2]) +// id = SOME(ARRAY[1, 2]) +// +// The subquery forms are AnyExpression and AllExpression. Quantifier is the +// canonical upper-case keyword: ANY, ALL or SOME. +type AnyAllExpression struct { + Expr Expression + Operator string + Quantifier string + Array Expression + Pos models.Location // Source position of the quantifier keyword +} + +func (a *AnyAllExpression) expressionNode() {} +func (a AnyAllExpression) TokenLiteral() string { + if a.Quantifier == "" { + return "ANY" + } + return a.Quantifier +} +func (a AnyAllExpression) Children() []Node { + var nodes []Node + if a.Expr != nil { + nodes = append(nodes, a.Expr) + } + if a.Array != nil { + nodes = append(nodes, a.Array) + } + return nodes +} + +// TernaryExpression represents the ClickHouse conditional operator +// cond ? then : else. +type TernaryExpression struct { + Cond Expression + Then Expression + Else Expression + Pos models.Location // Source position of the ? operator +} + +func (t *TernaryExpression) expressionNode() {} +func (t TernaryExpression) TokenLiteral() string { return "?" } +func (t TernaryExpression) Children() []Node { + var nodes []Node + if t.Cond != nil { + nodes = append(nodes, t.Cond) + } + if t.Then != nil { + nodes = append(nodes, t.Then) + } + if t.Else != nil { + nodes = append(nodes, t.Else) + } + return nodes +} diff --git a/pkg/sql/ast/ast_expressions.go b/pkg/sql/ast/ast_expressions.go index bc7ae98b..ff1b4692 100644 --- a/pkg/sql/ast/ast_expressions.go +++ b/pkg/sql/ast/ast_expressions.go @@ -253,7 +253,15 @@ type InExpression struct { List []Expression // For value list: IN (1, 2, 3) Subquery Statement // For subquery: IN (SELECT ...) Not bool - Pos models.Location // Source position of the IN keyword (1-based line and column) + // Global records the ClickHouse GLOBAL modifier (a GLOBAL IN (...)), + // which makes a distributed query evaluate the right side once on the + // initiator rather than on every shard. + Global bool + // Bare records a right side written without parentheses (SQLite + // "a IN tbl", "a IN fn(1)"). List then holds the single table or + // table-function reference. + Bare bool + Pos models.Location // Source position of the IN keyword (1-based line and column) } func (i *InExpression) expressionNode() {} @@ -514,7 +522,10 @@ type BinaryExpression struct { // concatenation must clear it first (the transpiler does). PipesOr bool CustomOp *CustomBinaryOperator // For PostgreSQL custom operators - Pos models.Location // Source position of the operator (1-based line and column) + // Escape is the ESCAPE character expression of a LIKE / ILIKE / + // SIMILAR TO pattern match (a LIKE 'x!%' ESCAPE '!'); nil when absent. + Escape Expression + Pos models.Location // Source position of the operator (1-based line and column) } func (b *BinaryExpression) expressionNode() {} @@ -534,6 +545,9 @@ func (b BinaryExpression) Children() []Node { if b.Right != nil { nodes = append(nodes, b.Right) } + if b.Escape != nil { + nodes = append(nodes, b.Escape) + } return nodes } diff --git a/pkg/sql/ast/pool_expression_release.go b/pkg/sql/ast/pool_expression_release.go index ef6afd1f..d88c4bd5 100644 --- a/pkg/sql/ast/pool_expression_release.go +++ b/pkg/sql/ast/pool_expression_release.go @@ -324,6 +324,9 @@ func putExpressionBounded(expr Expression, depth int, mode releaseMode, maxWork, if e.Right != nil { workQueue = append(workQueue, e.Right) } + if e.Escape != nil { + workQueue = append(workQueue, e.Escape) + } if mode == releaseAll { // CustomOp is not pooled; the reset drops it for the GC. *e = BinaryExpression{} @@ -431,6 +434,40 @@ func putExpressionBounded(expr Expression, depth int, mode releaseMode, maxWork, subqueryExprPool.Put(e) } + case *AtTimeZoneExpression: + // No dedicated pool; children are freed here and the node is left + // to the garbage collector. + if e.Expr != nil { + workQueue = append(workQueue, e.Expr) + } + if e.Zone != nil { + workQueue = append(workQueue, e.Zone) + } + + case *CollateExpression: + if e.Expr != nil { + workQueue = append(workQueue, e.Expr) + } + + case *AnyAllExpression: + if e.Expr != nil { + workQueue = append(workQueue, e.Expr) + } + if e.Array != nil { + workQueue = append(workQueue, e.Array) + } + + case *TernaryExpression: + if e.Cond != nil { + workQueue = append(workQueue, e.Cond) + } + if e.Then != nil { + workQueue = append(workQueue, e.Then) + } + if e.Else != nil { + workQueue = append(workQueue, e.Else) + } + case *ParenthesizedExpression: if e.Expr != nil { workQueue = append(workQueue, e.Expr) diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index 2e23398e..e6276c6c 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -527,7 +527,23 @@ func dataTypeIsPlain(typ string) bool { j++ } w := typ[i+1 : j] - if !isTypeModifierWord(w) { + if strings.EqualFold(w, "SET") || strings.EqualFold(w, "CHARSET") { + // MySQL CHARACTER SET name / CHARSET name: a bare charset + // name follows the word. + m := j + 1 + if j >= len(typ) || typ[j] != ' ' { + return false + } + for m < len(typ) && (typ[m] == '_' || typ[m] >= '0' && typ[m] <= '9' || typ[m]|0x20 >= 'a' && typ[m]|0x20 <= 'z') { + m++ + } + if m == j+1 { + return false + } + i = m - 1 + continue + } + if !isTypeModifierWord(w) && !(isSignednessInt(w) && prevWordIsSignedness(typ, i)) { return false } // The ClickHouse column CODEC the parser appends to the type is @@ -597,6 +613,23 @@ var typeModifierWords = map[string]bool{ "RAW": true, "BINARY": true, "CODEC": true, } +// isSignednessInt reports whether w is INT or INTEGER, the optional word that +// follows MySQL's SIGNED and UNSIGNED in a cast type (SIGNED INTEGER). +func isSignednessInt(w string) bool { + return strings.EqualFold(w, "INT") || strings.EqualFold(w, "INTEGER") +} + +// prevWordIsSignedness reports whether the word of typ that ends just before +// index end (the space) is SIGNED or UNSIGNED. +func prevWordIsSignedness(typ string, end int) bool { + start := end + for start > 0 && typ[start-1]|0x20 >= 'a' && typ[start-1]|0x20 <= 'z' { + start-- + } + w := typ[start:end] + return strings.EqualFold(w, "SIGNED") || strings.EqualFold(w, "UNSIGNED") +} + // maxTypeModifierLen bounds the stack buffer isTypeModifierWord upper-cases // into; it is the length of the longest word in typeModifierWords. const maxTypeModifierLen = 9 @@ -945,6 +978,7 @@ var ( "IS NULL", "IS NOT NULL", "IS", "IS NOT", "IS DISTINCT FROM", "IS NOT DISTINCT FROM", "IN", "NOT IN", "AGAINST", "DIV", "MOD", "OVERLAPS", "GLOB", + "NOT GLOB", "MATCH", "NOT MATCH", "SOUNDS LIKE", } // columnConstraintKeywords: the constraint kinds columnConstraintSQL // writes as bare keyword text (the parser stores NULL, AUTO_INCREMENT and diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 7372fb1b..7bf44223 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -403,6 +403,7 @@ func (b *BinaryExpression) writeSQL(buf *bytes.Buffer) { buf.WriteString(canon) buf.WriteByte(' ') writeExpr(buf, b.Right) + writeEscape(buf, b.Escape) return } buf.WriteString("NOT (") @@ -425,6 +426,7 @@ func (b *BinaryExpression) writeSQL(buf *bytes.Buffer) { } buf.WriteByte(' ') writeExpr(buf, b.Right) + writeEscape(buf, b.Escape) } // notQualifiedOperator reports whether op (case-insensitively) is one of the @@ -437,6 +439,16 @@ func notQualifiedOperator(op string) (string, bool) { return "ILIKE", true case strings.EqualFold(op, "SIMILAR TO"): return "SIMILAR TO", true + case strings.EqualFold(op, "REGEXP"): + return "REGEXP", true + case strings.EqualFold(op, "RLIKE"): + return "RLIKE", true + case strings.EqualFold(op, "GLOB"): + return "GLOB", true + case strings.EqualFold(op, "MATCH"): + return "MATCH", true + case strings.EqualFold(op, "SOUNDS LIKE"): + return "SOUNDS LIKE", true } return "", false } @@ -632,11 +644,13 @@ func (i *InExpression) writeSQL(buf *bytes.Buffer) { return } writeExpr(buf, i.Expr) - if i.Not { - buf.WriteString(" NOT IN (") - } else { - buf.WriteString(" IN (") + buf.WriteString(inKeywords(i.Global, i.Not)) + if i.Bare && i.Subquery == nil { + buf.WriteByte(' ') + writeExprList(buf, i.List) + return } + buf.WriteString(" (") if i.Subquery != nil { writeStmt(buf, i.Subquery) } else { diff --git a/pkg/sql/ast/sql_ops.go b/pkg/sql/ast/sql_ops.go new file mode 100644 index 00000000..18f059a1 --- /dev/null +++ b/pkg/sql/ast/sql_ops.go @@ -0,0 +1,173 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "bytes" + "strings" +) + +// SQL returns "expr AT TIME ZONE zone". +func (a *AtTimeZoneExpression) SQL() string { + if a == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + a.writeSQL(buf) + return buf.String() +} + +func (a *AtTimeZoneExpression) writeSQL(buf *bytes.Buffer) { + if a == nil { + return + } + writePostfixOperand(buf, a.Expr) + buf.WriteString(" AT TIME ZONE ") + writePostfixOperand(buf, a.Zone) +} + +// SQL returns "expr COLLATE name". +func (c *CollateExpression) SQL() string { + if c == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + c.writeSQL(buf) + return buf.String() +} + +func (c *CollateExpression) writeSQL(buf *bytes.Buffer) { + if c == nil { + return + } + writePostfixOperand(buf, c.Expr) + buf.WriteString(" COLLATE ") + if c.Literal { + writeStringLiteral(buf, c.Collation) + return + } + name := c.Collation + if dot := strings.LastIndexByte(name, '.'); dot > 0 && dot < len(name)-1 { + writeQualifiedName(buf, name[:dot]) + buf.WriteByte('.') + name = name[dot+1:] + } + if c.Quoted { + writeQuotedIdentifier(buf, name) + return + } + writeName(buf, name) +} + +// writePostfixOperand writes the operand of a postfix operator (AT TIME ZONE, +// COLLATE), which binds tighter than the arithmetic and comparison operators: +// an operand that is itself an infix expression keeps its parentheses so the +// operator still applies to all of it. +func writePostfixOperand(buf *bytes.Buffer, e Expression) { + switch e.(type) { + case *BinaryExpression, *BetweenExpression, *InExpression, + *AnyExpression, *AllExpression, *AnyAllExpression, *TernaryExpression: + buf.WriteByte('(') + writeExpr(buf, e) + buf.WriteByte(')') + default: + writeExpr(buf, e) + } +} + +// SQL returns "expr op ANY (array)" for a quantified comparison over an +// array-valued expression. +func (a *AnyAllExpression) SQL() string { + if a == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + a.writeSQL(buf) + return buf.String() +} + +func (a *AnyAllExpression) writeSQL(buf *bytes.Buffer) { + if a == nil { + return + } + writeExpr(buf, a.Expr) + buf.WriteByte(' ') + writeOperator(buf, a.Operator) + buf.WriteByte(' ') + switch { + case strings.EqualFold(a.Quantifier, "ALL"): + buf.WriteString("ALL") + case strings.EqualFold(a.Quantifier, "SOME"): + buf.WriteString("SOME") + default: + buf.WriteString("ANY") + } + buf.WriteString(" (") + if sq, ok := a.Array.(*SubqueryExpression); ok && sq != nil { + // SOME (SELECT ...): the subquery brings the parentheses. + writeStmt(buf, sq.Subquery) + } else { + writeExpr(buf, a.Array) + } + buf.WriteByte(')') +} + +// SQL returns "cond ? then : else". +func (t *TernaryExpression) SQL() string { + if t == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + t.writeSQL(buf) + return buf.String() +} + +func (t *TernaryExpression) writeSQL(buf *bytes.Buffer) { + if t == nil { + return + } + writeExpr(buf, t.Cond) + buf.WriteString(" ? ") + writeExpr(buf, t.Then) + buf.WriteString(" : ") + writeExpr(buf, t.Else) +} + +// inKeywords returns the keyword run that precedes an IN list, with the +// leading space: " IN", " NOT IN", " GLOBAL IN" or " GLOBAL NOT IN". +func inKeywords(global, not bool) string { + switch { + case global && not: + return " GLOBAL NOT IN" + case global: + return " GLOBAL IN" + case not: + return " NOT IN" + } + return " IN" +} + +// writeEscape writes the " ESCAPE expr" tail of a LIKE-style pattern match. +func writeEscape(buf *bytes.Buffer, esc Expression) { + if esc == nil { + return + } + buf.WriteString(" ESCAPE ") + writeExpr(buf, esc) +} diff --git a/pkg/sql/ast/sql_ops_test.go b/pkg/sql/ast/sql_ops_test.go new file mode 100644 index 00000000..9bf5a9d0 --- /dev/null +++ b/pkg/sql/ast/sql_ops_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "testing" + +// TestOperatorNodesSQL renders the operator and predicate nodes built by +// hand, including the quoting and parenthesization rules. +func TestOperatorNodesSQL(t *testing.T) { + id := func(n string) Expression { return &Identifier{Name: n} } + str := func(s string) Expression { return &LiteralValue{Value: s, Type: "STRING"} } + sel := &SelectStatement{Columns: []Expression{&LiteralValue{Value: "1", Type: "INTEGER"}}} + + tests := []struct { + name string + expr Expression + want string + }{ + {"at time zone", &AtTimeZoneExpression{Expr: id("t"), Zone: str("UTC")}, "t AT TIME ZONE 'UTC'"}, + {"at time zone parenthesizes infix operand", + &AtTimeZoneExpression{Expr: &BinaryExpression{Left: id("a"), Operator: "+", Right: id("b")}, Zone: str("UTC")}, + "(a + b) AT TIME ZONE 'UTC'"}, + {"collate bare", &CollateExpression{Expr: id("x"), Collation: "utf8mb4_bin"}, "x COLLATE utf8mb4_bin"}, + {"collate quoted keeps quotes", &CollateExpression{Expr: id("x"), Collation: "C", Quoted: true}, `x COLLATE "C"`}, + {"collate qualified", &CollateExpression{Expr: id("x"), Collation: "pg_catalog.default", Quoted: true}, `x COLLATE pg_catalog."default"`}, + {"collate literal", &CollateExpression{Expr: id("x"), Collation: "en", Literal: true}, "x COLLATE 'en'"}, + {"collate reserved word is quoted", &CollateExpression{Expr: id("x"), Collation: "select"}, `x COLLATE "select"`}, + {"any array", &AnyAllExpression{Expr: id("a"), Operator: "=", Quantifier: "ANY", Array: id("tags")}, "a = ANY (tags)"}, + {"all array", &AnyAllExpression{Expr: id("a"), Operator: "<>", Quantifier: "ALL", Array: id("tags")}, "a <> ALL (tags)"}, + {"some array", &AnyAllExpression{Expr: id("a"), Operator: "=", Quantifier: "some", Array: id("tags")}, "a = SOME (tags)"}, + {"empty quantifier defaults to any", &AnyAllExpression{Expr: id("a"), Operator: "=", Array: id("tags")}, "a = ANY (tags)"}, + {"some subquery has one pair of parentheses", + &AnyAllExpression{Expr: id("a"), Operator: "=", Quantifier: "SOME", Array: &SubqueryExpression{Subquery: sel}}, + "a = SOME (SELECT 1)"}, + {"hostile operator is quoted", &AnyAllExpression{Expr: id("a"), Operator: "= 1; DROP TABLE x; --", Quantifier: "ANY", Array: id("tags")}, + `a "= 1; DROP TABLE x; --" ANY (tags)`}, + {"ternary", &TernaryExpression{Cond: id("a"), Then: str("x"), Else: str("y")}, "a ? 'x' : 'y'"}, + {"in global", &InExpression{Expr: id("a"), Subquery: sel, Global: true}, "a GLOBAL IN (SELECT 1)"}, + {"in global not", &InExpression{Expr: id("a"), List: []Expression{id("b")}, Global: true, Not: true}, "a GLOBAL NOT IN (b)"}, + {"in bare", &InExpression{Expr: id("a"), List: []Expression{id("tbl")}, Bare: true}, "a IN tbl"}, + {"in bare not", &InExpression{Expr: id("a"), List: []Expression{id("tbl")}, Bare: true, Not: true}, "a NOT IN tbl"}, + {"in empty", &InExpression{Expr: id("a")}, "a IN ()"}, + {"like escape", &BinaryExpression{Left: id("a"), Operator: "LIKE", Right: str("x"), Escape: str("!")}, "a LIKE 'x' ESCAPE '!'"}, + {"not like escape", &BinaryExpression{Left: id("a"), Operator: "LIKE", Right: str("x"), Escape: str("!"), Not: true}, "a NOT LIKE 'x' ESCAPE '!'"}, + {"not regexp", &BinaryExpression{Left: id("a"), Operator: "REGEXP", Right: str("x"), Not: true}, "a NOT REGEXP 'x'"}, + {"not glob", &BinaryExpression{Left: id("a"), Operator: "GLOB", Right: str("x"), Not: true}, "a NOT GLOB 'x'"}, + {"is distinct from", &BinaryExpression{Left: id("a"), Operator: "IS DISTINCT FROM", Right: id("b")}, "a IS DISTINCT FROM b"}, + {"is not true", &BinaryExpression{Left: id("a"), Operator: "IS NOT", Right: &LiteralValue{Value: "TRUE", Type: "BOOLEAN"}}, "a IS NOT TRUE"}, + {"sounds like", &BinaryExpression{Left: id("a"), Operator: "SOUNDS LIKE", Right: id("b")}, "a SOUNDS LIKE b"}, + {"match", &BinaryExpression{Left: id("a"), Operator: "MATCH", Right: str("x")}, "a MATCH 'x'"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s, ok := tc.expr.(interface{ SQL() string }) + if !ok { + t.Fatalf("%T has no SQL()", tc.expr) + } + if got := s.SQL(); got != tc.want { + t.Errorf("SQL() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestOperatorNodesChildren checks that the visitor sees every operand. +func TestOperatorNodesChildren(t *testing.T) { + a, b, c := &Identifier{Name: "a"}, &Identifier{Name: "b"}, &Identifier{Name: "c"} + tests := []struct { + name string + node Node + want int + }{ + {"at time zone", &AtTimeZoneExpression{Expr: a, Zone: b}, 2}, + {"collate", &CollateExpression{Expr: a, Collation: "x"}, 1}, + {"any all", &AnyAllExpression{Expr: a, Array: b}, 2}, + {"ternary", &TernaryExpression{Cond: a, Then: b, Else: c}, 3}, + {"binary with escape", &BinaryExpression{Left: a, Right: b, Escape: c}, 3}, + {"nil operands", &AtTimeZoneExpression{}, 0}, + } + for _, tc := range tests { + if got := len(tc.node.Children()); got != tc.want { + t.Errorf("%s: Children() = %d nodes, want %d", tc.name, got, tc.want) + } + } +} + +// TestDataTypeCharsetAndSignedPlain checks that the multi-word cast types the +// parser produces are written verbatim and that near misses stay quoted. +func TestDataTypeCharsetAndSignedPlain(t *testing.T) { + plain := []string{ + "SIGNED INTEGER", "UNSIGNED INT", "CHAR CHARACTER SET utf8mb4", "CHAR(10) CHARACTER SET utf8mb4", + "VARCHAR(10) CHARSET latin1", "text[]", "int[3][4]", "timestamp(3) without time zone", "public.mood", + } + for _, typ := range plain { + if got := QuoteDataType(typ); got != typ { + t.Errorf("QuoteDataType(%q) = %q, want it unchanged", typ, got) + } + } + quoted := []string{" INT", "INT x", "CHAR CHARACTER SET", "CHAR CHARACTER SET ;DROP", "CHAR SET"} + for _, typ := range quoted { + if got := QuoteDataType(typ); got == typ { + t.Errorf("QuoteDataType(%q) left it bare", typ) + } + } +} + +// TestPooledOperatorFieldsAreCleared checks that the fields added for LIKE +// ESCAPE and GLOBAL / bare IN do not survive a trip through the pools. +func TestPooledOperatorFieldsAreCleared(t *testing.T) { + lit := &LiteralValue{Value: "!", Type: "STRING"} + + for i := 0; i < 50; i++ { + b := GetBinaryExpression() + b.Operator, b.Escape = "LIKE", lit + PutBinaryExpression(b) + + in := GetInExpression() + in.Global, in.Bare = true, true + PutInExpression(in) + + b2 := GetBinaryExpression() + if b2.Escape != nil || b2.Operator != "" { + t.Fatalf("pooled BinaryExpression kept Escape=%v Operator=%q", b2.Escape, b2.Operator) + } + in2 := GetInExpression() + if in2.Global || in2.Bare { + t.Fatalf("pooled InExpression kept Global=%v Bare=%v", in2.Global, in2.Bare) + } + PutBinaryExpression(b2) + PutInExpression(in2) + } +} diff --git a/pkg/sql/parser/ddl_index.go b/pkg/sql/parser/ddl_index.go index ea176b98..a4a5a911 100644 --- a/pkg/sql/parser/ddl_index.go +++ b/pkg/sql/parser/ddl_index.go @@ -87,6 +87,16 @@ func (p *Parser) parseCreateIndex(unique bool) (*ast.CreateIndexStatement, error col.Column = p.currentToken.Token.Value p.advance() + // Parse optional COLLATE (SQLite, PostgreSQL): col COLLATE NOCASE + if p.isType(models.TokenTypeCollate) { + p.advance() // Consume COLLATE + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("collation name") + } + col.Collate = p.currentToken.Token.Value + p.advance() + } + // Parse optional direction if p.isType(models.TokenTypeAsc) { col.Direction = "ASC" diff --git a/pkg/sql/parser/expressions.go b/pkg/sql/parser/expressions.go index 47560eff..d4cf25ad 100644 --- a/pkg/sql/parser/expressions.go +++ b/pkg/sql/parser/expressions.go @@ -22,7 +22,6 @@ package parser import ( - "fmt" "strings" goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" @@ -55,8 +54,8 @@ func (p *Parser) parseExpression() (ast.Expression, error) { ) } - // Start by parsing AND expressions (higher precedence) - left, err := p.parseAndExpression() + // Start by parsing XOR/AND expressions (higher precedence) + left, err := p.parseXorExpression() if err != nil { return nil, err } @@ -68,30 +67,68 @@ func (p *Parser) parseExpression() (ast.Expression, error) { // spelling so SQL() and the formatter write || back. for p.isType(models.TokenTypeOr) || (p.pipesAsOr && p.isType(models.TokenTypeStringConcat)) { opPos := p.currentLocation() - operator := p.currentToken.Token.Value + // The operator is stored as the canonical upper-case keyword whatever + // the source spelling. pipes := p.currentToken.Token.Type == models.TokenTypeStringConcat - if pipes { - operator = "OR" - } p.advance() // Consume OR / || - right, err := p.parseAndExpression() + right, err := p.parseXorExpression() if err != nil { return nil, err } left = &ast.BinaryExpression{ Left: left, - Operator: operator, + Operator: "OR", Right: right, PipesOr: pipes, Pos: opPos, } } + // ClickHouse conditional operator: cond ? then : else (right-associative). + if p.isType(models.TokenTypeQuestion) && p.IsClickHouse() { + return p.parseTernaryTail(left) + } + + // MySQL / MariaDB user-variable assignment: @a := expr (right-associative, + // the loosest operator). + if p.isType(models.TokenTypeColon) && p.isMySQLFamily() && p.peekTypeAt(1) == models.TokenTypeEq && p.nextAdjacent(1) { + opPos := p.currentLocation() + p.advance() // Consume : + p.advance() // Consume = + right, err := p.parseExpression() + if err != nil { + return nil, err + } + return &ast.BinaryExpression{Left: left, Operator: ":=", Right: right, Pos: opPos}, nil + } + return left, nil } +// parseTernaryTail parses the "? then : else" tail of the ClickHouse +// conditional operator after its condition. The current token is the ?. +func (p *Parser) parseTernaryTail(cond ast.Expression) (ast.Expression, error) { + qPos := p.currentLocation() + p.advance() // Consume ? + + then, err := p.parseExpression() + if err != nil { + return nil, err + } + if !p.isType(models.TokenTypeColon) { + return nil, p.expectedError(": in conditional expression") + } + p.advance() // Consume : + + els, err := p.parseExpression() + if err != nil { + return nil, err + } + return &ast.TernaryExpression{Cond: cond, Then: then, Else: els, Pos: qPos}, nil +} + // enterRecursion increments the recursion depth counter and returns a // RecursionDepthLimitError (E2007) when it exceeds MaxRecursionDepth. It is for // recursive parse paths that do not pass through parseExpression. The caller @@ -130,9 +167,10 @@ func (p *Parser) parseAndExpression() (ast.Expression, error) { } // Handle AND operators (middle precedence, left-associative) - for p.isType(models.TokenTypeAnd) { + // (MySQL and MariaDB also read && as AND.) The operator is stored as the + // canonical upper-case keyword whatever the source spelling. + for p.isLogicalAnd() { opPos := p.currentLocation() - operator := p.currentToken.Token.Value p.advance() // Consume AND right, err := p.parseComparisonExpression() @@ -142,7 +180,7 @@ func (p *Parser) parseAndExpression() (ast.Expression, error) { left = &ast.BinaryExpression{ Left: left, - Operator: operator, + Operator: "AND", Right: right, Pos: opPos, } @@ -192,7 +230,8 @@ func (p *Parser) parseAdditiveExpression() (ast.Expression, error) { } // Handle + and - operators (left-associative) - for p.isType(models.TokenTypePlus) || p.isType(models.TokenTypeMinus) { + // (SQL Server treats the bitwise &, | like + and -.) + for p.isType(models.TokenTypePlus) || p.isType(models.TokenTypeMinus) || p.sqlServerBitwise() { opPos := p.currentLocation() operator := p.currentToken.Token.Value p.advance() // Consume operator @@ -224,10 +263,20 @@ func (p *Parser) parseMultiplicativeExpression() (ast.Expression, error) { // Handle *, /, and % operators (left-associative) // Note: TokenTypeAsterisk is used for both * (SELECT) and multiplication // We check context: after an expression, asterisk means multiplication - for p.isType(models.TokenTypeAsterisk) || p.isType(models.TokenTypeMul) || - p.isType(models.TokenTypeDiv) || p.isType(models.TokenTypeMod) { + // MySQL and MariaDB also have the DIV and MOD keyword operators. + for { + wordOp, isWord := "", false + if !(p.isType(models.TokenTypeAsterisk) || p.isType(models.TokenTypeMul) || + p.isType(models.TokenTypeDiv) || p.isType(models.TokenTypeMod)) { + if wordOp, isWord = p.mysqlWordMulOperator(); !isWord { + break + } + } opPos := p.currentLocation() operator := p.currentToken.Token.Value + if isWord { + operator = wordOp + } p.advance() // Consume operator right, err := p.parseJSONExpression() @@ -322,71 +371,14 @@ func (p *Parser) parseJSONExpression() (ast.Expression, error) { } } - return left, nil -} - -// parseDataType parses a SQL data type for CAST or :: expressions -// Handles: simple types (INTEGER, TEXT), parameterized types (VARCHAR(100), NUMERIC(10,2)) -func (p *Parser) parseDataType() (string, error) { - // Data type can be an identifier or a keyword like INT, VARCHAR, etc. - if !p.isIdentifier() && !p.isDataTypeKeyword() { - return "", p.expectedError("data type") + // Postfix AT TIME ZONE and COLLATE bind tighter than the arithmetic + // operators. Both start on an identifier or COLLATE token, which no + // other construct puts directly after an operand. + if t := p.currentToken.Token.Type; t == models.TokenTypeCollate || t == models.TokenTypeIdentifier { + return p.parsePostfixOperators(left) } - // Use strings.Builder for efficient string concatenation - var sb strings.Builder - sb.WriteString(p.currentToken.Token.Value) - p.advance() // Consume type name - - // Check for type parameters (e.g., VARCHAR(100), DECIMAL(10,2)) - if p.isType(models.TokenTypeLParen) { - p.advance() // Consume ( - sb.WriteByte('(') - - paramCount := 0 - for !p.isType(models.TokenTypeRParen) { - if paramCount > 0 { - if !p.isType(models.TokenTypeComma) { - return "", p.expectedError(", or )") - } - sb.WriteString(p.currentToken.Token.Value) - p.advance() // Consume comma - } - - // Parse parameter (should be a number or identifier) - // Use token type constants for consistency - if !p.isType(models.TokenTypeNumber) && !p.isType(models.TokenTypeIdentifier) && !p.isNumericLiteral() { - return "", goerrors.InvalidSyntaxError( - fmt.Sprintf("expected numeric type parameter, got '%s'", p.currentToken.Token.Value), - p.currentLocation(), - "", - ).WithHint("Use TYPE(precision[, scale]) syntax") - } - - sb.WriteString(p.currentToken.Token.Value) - p.advance() - paramCount++ - } - - sb.WriteByte(')') - - if !p.isType(models.TokenTypeRParen) { - return "", p.expectedError(")") - } - p.advance() // Consume ) - } - - // Check for array type suffix (e.g., INTEGER[], TEXT[]) - if p.isType(models.TokenTypeLBracket) { - p.advance() // Consume [ - if !p.isType(models.TokenTypeRBracket) { - return "", p.expectedError("]") - } - p.advance() // Consume ] - sb.WriteString("[]") - } - - return sb.String(), nil + return left, nil } // isNumericLiteral checks if current token is a numeric literal (handles INT/NUMBER token types) @@ -426,10 +418,11 @@ func (p *Parser) isJSONOperator() bool { models.TokenTypeAtArrow, // @> models.TokenTypeArrowAt, // <@ models.TokenTypeHashMinus, // #- - models.TokenTypeQuestion, // ? models.TokenTypeQuestionPipe, // ?| models.TokenTypeQuestionAnd: // ?& return true + case models.TokenTypeQuestion: // ? (the conditional operator in ClickHouse) + return !p.IsClickHouse() } return false } diff --git a/pkg/sql/parser/expressions_complex.go b/pkg/sql/parser/expressions_complex.go index ffbad31d..0598158c 100644 --- a/pkg/sql/parser/expressions_complex.go +++ b/pkg/sql/parser/expressions_complex.go @@ -168,57 +168,13 @@ func (p *Parser) parseCastLike(try bool) (*ast.CastExpression, error) { } p.advance() // Consume AS - // Parse the target data type - // The type can be: - // - Simple type: VARCHAR, INT, DECIMAL, etc. - // - Type with precision: VARCHAR(100), DECIMAL(10,2), etc. - // - Quoted user-defined type: "my type", as :: accepts and SQL() - // renders a type name that is not a plain word - if !p.isIdentifier() { - return nil, p.expectedError("data type") - } - - dataType := p.currentToken.Token.Value - p.advance() // Consume type name - - // Check for type parameters (e.g., VARCHAR(100), DECIMAL(10,2)) - if p.isType(models.TokenTypeLParen) { - p.advance() // Consume ( - - // Build the full type string including parameters - typeParams := "(" - paramCount := 0 - - for !p.isType(models.TokenTypeRParen) { - if paramCount > 0 { - if !p.isType(models.TokenTypeComma) { - return nil, p.expectedError(", or )") - } - typeParams += p.currentToken.Token.Value - p.advance() // Consume comma - } - - // Parse parameter (should be a number) - if !p.isNumericLiteral() && !p.isType(models.TokenTypeIdentifier) { - return nil, goerrors.InvalidSyntaxError( - "expected numeric type parameter", - p.currentLocation(), - "", - ).WithHint("Use CAST(expr AS TYPE(precision[, scale]))") - } - - typeParams += p.currentToken.Token.Value - p.advance() - paramCount++ - } - - typeParams += ")" - dataType += typeParams - - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") - } - p.advance() // Consume ) + // Parse the target data type. The type can be a simple type (VARCHAR), + // a type with precision (DECIMAL(10,2)), a multi-word type (DOUBLE + // PRECISION, MySQL SIGNED INTEGER, CHAR(10) CHARACTER SET utf8mb4), an + // array type (text[]) or a quoted user-defined type ("my type"). + dataType, err := p.parseDataTypeHint("Use CAST(expr AS TYPE(precision[, scale]))") + if err != nil { + return nil, err } // Expect closing parenthesis of CAST diff --git a/pkg/sql/parser/expressions_datatype.go b/pkg/sql/parser/expressions_datatype.go new file mode 100644 index 00000000..bea93605 --- /dev/null +++ b/pkg/sql/parser/expressions_datatype.go @@ -0,0 +1,257 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - expressions_datatype.go +// Data type names for CAST(expr AS type) and expr::type. + +package parser + +import ( + "fmt" + "strings" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// parseDataType parses a SQL data type for CAST or :: expressions. +// +// It handles simple types (INTEGER, TEXT), schema-qualified types +// (public.mood), parameterized types (VARCHAR(100), NUMERIC(10,2)), +// multi-word types (DOUBLE PRECISION, CHARACTER VARYING, TIMESTAMP WITH TIME +// ZONE, MySQL SIGNED INTEGER and UNSIGNED), a MySQL CHARACTER SET suffix +// (CHAR(10) CHARACTER SET utf8mb4) and array dimensions (int[], text[][3]). +// The result is the type text with single spaces between words. +func (p *Parser) parseDataType() (string, error) { + return p.parseDataTypeHint("Use TYPE(precision[, scale]) syntax") +} + +// parseDataTypeHint is parseDataType with the hint attached to a malformed +// type parameter error. +func (p *Parser) parseDataTypeHint(paramHint string) (string, error) { + // Data type can be an identifier or a keyword like INT, VARCHAR, etc. + if !p.isIdentifier() && !p.isDataTypeKeyword() { + return "", p.expectedError("data type") + } + + first := p.currentToken.Token.Value + p.advance() // Consume type name + + // Fast path: a single plain word, as in x::int or CAST(x AS text). + switch p.currentToken.Token.Type { + case models.TokenTypeLParen, models.TokenTypeLBracket, models.TokenTypePeriod: + default: + if p.dataTypeWordClass(first) == typeWordNone { + return first, nil + } + } + + // Use strings.Builder for efficient string concatenation + var sb strings.Builder + sb.WriteString(first) + + // Schema-qualified type: public.my_enum, pg_catalog.int4. + for p.isType(models.TokenTypePeriod) && (p.peekTypeAt(1) == models.TokenTypeIdentifier || p.peekTypeAt(1) == models.TokenTypeDoubleQuotedString) { + p.advance() // Consume . + sb.WriteByte('.') + sb.WriteString(p.currentToken.Token.Value) + p.advance() + } + + class := p.dataTypeWordClass(first) + national := equalsUpper(first, "NATIONAL") + + // Words that follow the type name directly (DOUBLE PRECISION, + // CHARACTER VARYING, SIGNED INTEGER) and, for the temporal types, come + // after the precision (TIMESTAMP(3) WITH TIME ZONE). + if class != typeWordTemporal { + p.writeTypeWords(&sb, class, national) + } + + // Check for type parameters (e.g., VARCHAR(100), DECIMAL(10,2)) + if p.isType(models.TokenTypeLParen) { + p.advance() // Consume ( + sb.WriteByte('(') + + paramCount := 0 + for !p.isType(models.TokenTypeRParen) { + if paramCount > 0 { + if !p.isType(models.TokenTypeComma) { + return "", p.expectedError(", or )") + } + sb.WriteString(p.currentToken.Token.Value) + p.advance() // Consume comma + } + + // Parse parameter (should be a number or identifier) + // Use token type constants for consistency + if !p.isType(models.TokenTypeNumber) && !p.isType(models.TokenTypeIdentifier) && !p.isNumericLiteral() { + return "", goerrors.InvalidSyntaxError( + fmt.Sprintf("expected numeric type parameter, got '%s'", p.currentToken.Token.Value), + p.currentLocation(), + "", + ).WithHint(paramHint) + } + + sb.WriteString(p.currentToken.Token.Value) + p.advance() + paramCount++ + } + + sb.WriteByte(')') + + if !p.isType(models.TokenTypeRParen) { + return "", p.expectedError(")") + } + p.advance() // Consume ) + } + + if class == typeWordTemporal || class == typeWordChar { + p.writeTypeWords(&sb, class, false) + } + + // Check for array type suffixes (e.g., INTEGER[], TEXT[][], INT[3]) + for p.isType(models.TokenTypeLBracket) { + p.advance() // Consume [ + sb.WriteByte('[') + if p.isType(models.TokenTypeNumber) { + sb.WriteString(p.currentToken.Token.Value) + p.advance() + } + if !p.isType(models.TokenTypeRBracket) { + return "", p.expectedError("]") + } + p.advance() // Consume ] + sb.WriteByte(']') + } + + return sb.String(), nil +} + +// typeWordClass groups first words of a data type by the words that may +// follow them. +type typeWordClass int + +const ( + typeWordNone typeWordClass = iota + typeWordDouble // DOUBLE [PRECISION] + typeWordChar // CHAR, CHARACTER, VARCHAR, NCHAR, NATIONAL: [VARYING] [CHARACTER SET x] + typeWordTemporal // TIMESTAMP, TIME: [(p)] WITH|WITHOUT TIME ZONE + typeWordSigned // SIGNED, UNSIGNED: [INTEGER | INT] + typeWordBit // BIT [VARYING] +) + +// dataTypeWordClass classifies the first word of a data type. It allocates +// nothing and is a no-op for most words. +func (p *Parser) dataTypeWordClass(first string) typeWordClass { + switch len(first) { + case 3: + if equalsUpper(first, "BIT") { + return typeWordBit + } + case 4: + switch { + case equalsUpper(first, "CHAR"): + return typeWordChar + case equalsUpper(first, "TIME"): + return typeWordTemporal + } + case 5: + if equalsUpper(first, "NCHAR") { + return typeWordChar + } + case 6: + switch { + case equalsUpper(first, "DOUBLE"): + return typeWordDouble + case equalsUpper(first, "SIGNED"): + return typeWordSigned + } + case 7: + if equalsUpper(first, "VARCHAR") { + return typeWordChar + } + case 8: + switch { + case equalsUpper(first, "UNSIGNED"): + return typeWordSigned + case equalsUpper(first, "NATIONAL"): + return typeWordChar + } + case 9: + switch { + case equalsUpper(first, "CHARACTER"): + return typeWordChar + case equalsUpper(first, "TIMESTAMP"): + return typeWordTemporal + } + } + return typeWordNone +} + +// writeTypeWords appends the words that follow a type name of the given +// class, consuming them. Each word is matched only in the position where it +// is part of a type, so an alias or keyword after the type is left alone. +func (p *Parser) writeTypeWords(sb *strings.Builder, class typeWordClass, national bool) { + write := func() { + sb.WriteByte(' ') + sb.WriteString(p.currentToken.Token.Value) + p.advance() + } + cur := func(upper string) bool { return equalsUpper(p.currentToken.Token.Value, upper) } + + switch class { + case typeWordDouble: + if cur("PRECISION") { + write() + } + case typeWordBit: + if cur("VARYING") { + write() + } + case typeWordSigned: + if cur("INTEGER") || cur("INT") { + write() + } + case typeWordChar: + // MySQL: CHAR(10) CHARACTER SET utf8mb4 / CHARSET utf8mb4. The charset + // name follows. This runs again after the length is parsed, so it only + // fires when the next tokens really are CHARACTER SET or CHARSET. + switch { + case cur("CHARACTER") && p.peekValueIs(1, "SET") && p.peekAt(2).Token.Value != "": + write() // CHARACTER + write() // SET + write() // charset name + return + case cur("CHARSET") && p.peekAt(1).Token.Value != "": + write() // CHARSET + write() // charset name + return + } + // NATIONAL CHARACTER [VARYING], CHARACTER VARYING + if national && (cur("CHARACTER") || cur("CHAR")) { + write() + } + if cur("VARYING") { + write() + } + case typeWordTemporal: + // TIMESTAMP [(p)] WITH|WITHOUT TIME ZONE; WITH is a keyword token. + if (cur("WITH") || cur("WITHOUT")) && p.peekValueIs(1, "TIME") && p.peekValueIs(2, "ZONE") { + write() // WITH / WITHOUT + write() // TIME + write() // ZONE + } + } +} diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 63c00919..6e093ac3 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -51,6 +51,22 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { }, nil } + // Bitwise NOT (~a: MySQL, PostgreSQL, SQL Server, SQLite) and MySQL's !a, + // a synonym of NOT with a higher precedence. + if p.isType(models.TokenTypeTilde) || (p.isType(models.TokenTypeExclamationMark) && p.isMySQLFamily()) { + unaryPos := p.currentLocation() + op := ast.PGBitwiseNot + if p.isType(models.TokenTypeExclamationMark) { + op = ast.BangNot + } + p.advance() // Consume ~ / ! + expr, err := p.parseUnaryOperand() + if err != nil { + return nil, err + } + return &ast.UnaryExpression{Operator: op, Expr: expr, Pos: unaryPos}, nil + } + // CONNECT BY: PRIOR is a prefix operator that binds to its operand, so // "PRIOR a + 1" is "(PRIOR a) + 1" (#783). if p.inConnectBy && p.isConnectByPrior() { diff --git a/pkg/sql/parser/expressions_operators.go b/pkg/sql/parser/expressions_operators.go index 1e5d7def..c21852ec 100644 --- a/pkg/sql/parser/expressions_operators.go +++ b/pkg/sql/parser/expressions_operators.go @@ -28,25 +28,69 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" ) +// Kinds of comparison suffix reported by parseComparisonSuffix. +const ( + suffixNone = iota // no suffix operator follows + suffixPredicate // BETWEEN, IN, LIKE and the other predicates, IS ... + suffixComparison // =, <>, <, ... and quantified comparisons +) + // parseComparisonExpression parses an expression with comparison operators +// and the predicates (BETWEEN, IN, LIKE, IS, ...). The suffix operators are +// applied left to right, so "a IN (1, 2) = TRUE" and "a BETWEEN 1 AND 2 = TRUE" +// read as a predicate compared to a value. Comparison operators themselves do +// not chain: after "a = b" another comparison operator is left for the +// caller, which rejects it. func (p *Parser) parseComparisonExpression() (ast.Expression, error) { - // Parse the left side using string concatenation expression for arithmetic support - left, err := p.parseStringConcatExpression() + // Parse the left side with bitwise and arithmetic operators binding tighter. + left, err := p.parseBitwiseExpression(1) if err != nil { return nil, err } + afterComparison := false + for { + next, kind, err := p.parseComparisonSuffix(left, afterComparison) + if err != nil { + return nil, err + } + if kind == suffixNone { + return left, nil + } + left = next + afterComparison = kind == suffixComparison + } +} + +// notOperatorFollows reports whether the NOT at the current token negates a +// predicate operator that follows it. NOT is consumed only then, which keeps +// cases like "WHERE NOT active AND name LIKE '%'" and "DEFAULT 0 NOT NULL" +// working. +func (p *Parser) notOperatorFollows() bool { + nv := p.peekAt(1).Token.Value + switch { + case equalsUpper(nv, "BETWEEN"), equalsUpper(nv, "LIKE"), equalsUpper(nv, "ILIKE"), equalsUpper(nv, "IN"), + equalsUpper(nv, "REGEXP"), equalsUpper(nv, "RLIKE"): + return true + case equalsUpper(nv, "SIMILAR"): + return p.peekValueIs(2, "TO") + case equalsUpper(nv, "GLOB"), equalsUpper(nv, "MATCH"): + return p.IsSQLite() + } + return false +} + +// parseComparisonSuffix parses at most one comparison or predicate operator +// following left and returns the combined expression. It returns suffixNone +// (and a nil expression) when no operator follows. afterComparison is set when +// the previous suffix was a comparison operator, which may not be followed by +// another one. +func (p *Parser) parseComparisonSuffix(left ast.Expression, afterComparison bool) (ast.Expression, int, error) { // Check for NOT prefix for BETWEEN, LIKE, IN operators - // Only consume NOT if followed by BETWEEN, LIKE, ILIKE, or IN - // This prevents breaking cases like: WHERE NOT active AND name LIKE '%' notPrefix := false - if p.isType(models.TokenTypeNot) { - nextToken := p.peekToken() - nv := nextToken.Token.Value - if equalsUpper(nv, "BETWEEN") || equalsUpper(nv, "LIKE") || equalsUpper(nv, "ILIKE") || equalsUpper(nv, "IN") { - notPrefix = true - p.advance() // Consume NOT only if followed by valid operator - } + if p.isType(models.TokenTypeNot) && p.notOperatorFollows() { + notPrefix = true + p.advance() // Consume NOT only if followed by valid operator } // Check for BETWEEN operator @@ -54,11 +98,11 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { betweenPos := p.currentLocation() p.advance() // Consume BETWEEN - // Parse lower bound - use parseStringConcatExpression to support complex expressions + // Parse lower bound - use the bitwise level to support complex expressions // like: price BETWEEN price * 0.9 AND price * 1.1 - lower, err := p.parseStringConcatExpression() + lower, err := p.parseBitwiseExpression(1) if err != nil { - return nil, goerrors.InvalidSyntaxError( + return nil, suffixNone, goerrors.InvalidSyntaxError( fmt.Sprintf("failed to parse BETWEEN lower bound: %v", err), p.currentLocation(), "", @@ -67,14 +111,14 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { // Expect AND keyword if !p.isType(models.TokenTypeAnd) { - return nil, p.expectedError("AND") + return nil, suffixNone, p.expectedError("AND") } p.advance() // Consume AND - // Parse upper bound - use parseStringConcatExpression to support complex expressions - upper, err := p.parseStringConcatExpression() + // Parse upper bound + upper, err := p.parseBitwiseExpression(1) if err != nil { - return nil, goerrors.InvalidSyntaxError( + return nil, suffixNone, goerrors.InvalidSyntaxError( fmt.Sprintf("failed to parse BETWEEN upper bound: %v", err), p.currentLocation(), "", @@ -87,7 +131,7 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { Upper: upper, Not: notPrefix, Pos: betweenPos, - }, nil + }, suffixPredicate, nil } // Check for LIKE/ILIKE operator @@ -114,7 +158,7 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { string(keywords.DialectClickHouse): // supported default: - return nil, goerrors.UnsupportedFeatureError( + return nil, suffixNone, goerrors.UnsupportedFeatureError( fmt.Sprintf("ILIKE is not supported in %s; use LIKE or LOWER() for case-insensitive matching", p.dialect), p.currentLocation(), "", @@ -133,37 +177,23 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { p.advance() // Consume ANY/ALL // Expect a tuple: (pattern, pattern, ...) if !p.isType(models.TokenTypeLParen) { - return nil, p.expectedError("( after " + quantifier) + return nil, suffixNone, p.expectedError("( after " + quantifier) } tuple, err := p.parsePrimaryExpression() if err != nil { - return nil, err + return nil, suffixNone, err } return &ast.BinaryExpression{ Left: left, Operator: operator, Right: tuple, Not: notPrefix, - }, nil + }, suffixPredicate, nil } // Parse the pattern at the string-concatenation level so arithmetic // and || bind tighter than LIKE: a LIKE b || '%' (#596). - pattern, err := p.parseStringConcatExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse LIKE pattern: %v", err), - p.currentLocation(), - "", - ).WithCause(err) - } - - return &ast.BinaryExpression{ - Left: left, - Operator: operator, - Right: pattern, - Not: notPrefix, - }, nil + return p.parsePatternMatch(left, operator, notPrefix, true) } // Check for REGEXP/RLIKE operator (MySQL) @@ -175,25 +205,42 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { operator = "RLIKE" } p.advance() - pattern, err := p.parseStringConcatExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse REGEXP pattern: %v", err), - p.currentLocation(), - "", - ).WithCause(err) + return p.parsePatternMatch(left, operator, notPrefix, false) + } + + // SIMILAR TO (PostgreSQL, Redshift): the TO keyword makes it unambiguous. + if p.currentToken.Token.Type == models.TokenTypeIdentifier && equalsUpper(p.currentToken.Token.Value, "SIMILAR") && p.peekValueIs(1, "TO") { + p.advance() // Consume SIMILAR + p.advance() // Consume TO + return p.parsePatternMatch(left, "SIMILAR TO", notPrefix, true) + } + + // SQLite GLOB and MATCH pattern operators. + if p.currentToken.Token.Type == models.TokenTypeIdentifier && p.IsSQLite() { + switch { + case equalsUpper(p.currentToken.Token.Value, "GLOB"): + p.advance() + return p.parsePatternMatch(left, "GLOB", notPrefix, false) + case equalsUpper(p.currentToken.Token.Value, "MATCH"): + p.advance() + return p.parsePatternMatch(left, "MATCH", notPrefix, false) } - return &ast.BinaryExpression{ - Left: left, - Operator: operator, - Right: pattern, - Not: notPrefix, - }, nil + } + + // MySQL SOUNDS LIKE. + if p.currentToken.Token.Type == models.TokenTypeIdentifier && p.isMySQLFamily() && + equalsUpper(p.currentToken.Token.Value, "SOUNDS") && p.peekTypeAt(1) == models.TokenTypeLike { + p.advance() // Consume SOUNDS + p.advance() // Consume LIKE + return p.parsePatternMatch(left, "SOUNDS LIKE", notPrefix, false) } // ClickHouse GLOBAL IN / GLOBAL NOT IN — GLOBAL is a distributed query modifier - // before IN. Consume it and fall through to standard IN parsing below. - if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("GLOBAL") { + // before IN. It is kept on the InExpression. + global := false + if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("GLOBAL") && + (p.peekTypeAt(1) == models.TokenTypeIn || (p.peekTypeAt(1) == models.TokenTypeNot && p.peekTypeAt(2) == models.TokenTypeIn)) { + global = true p.advance() // consume GLOBAL if p.isType(models.TokenTypeNot) { notPrefix = true @@ -203,76 +250,17 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { // Check for IN operator if p.isType(models.TokenTypeIn) { - inPos := p.currentLocation() - p.advance() // Consume IN - - // Expect opening parenthesis - if !p.isType(models.TokenTypeLParen) { - return nil, p.expectedError("(") - } - p.advance() // Consume ( - - // Check if this is a subquery (starts with SELECT or WITH) - if p.isType(models.TokenTypeSelect) || p.isType(models.TokenTypeWith) { - // Parse subquery - subquery, err := p.parseSubquery() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse IN subquery: %v", err), - p.currentLocation(), - "", - ).WithCause(err) - } - - // Expect closing parenthesis - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") - } - p.advance() // Consume ) - - return &ast.InExpression{ - Expr: left, - Subquery: subquery, - Not: notPrefix, - Pos: inPos, - }, nil - } - - // Parse value list - var values []ast.Expression - for { - value, err := p.parseExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse IN value: %v", err), - p.currentLocation(), - "", - ) - } - values = append(values, value) - - if p.isType(models.TokenTypeComma) { - p.advance() // Consume comma - } else if p.isType(models.TokenTypeRParen) { - break - } else { - return nil, p.expectedError(", or )") - } + in, err := p.parseInTail(left, notPrefix, global) + if err != nil { + return nil, suffixNone, err } - p.advance() // Consume ) - - return &ast.InExpression{ - Expr: left, - List: values, - Not: notPrefix, - Pos: inPos, - }, nil + return in, suffixPredicate, nil } // If NOT was consumed but no BETWEEN/LIKE/IN follows, we need to handle this case // Put back the NOT by creating a NOT expression with left as the operand if notPrefix { - return nil, goerrors.ExpectedTokenError( + return nil, suffixNone, goerrors.ExpectedTokenError( "BETWEEN, LIKE, or IN", "NOT", p.currentLocation(), @@ -280,7 +268,7 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { ) } - // Check for IS NULL / IS NOT NULL + // Check for IS NULL / IS NOT NULL / IS [NOT] TRUE|FALSE|UNKNOWN / IS [NOT] DISTINCT FROM if p.isType(models.TokenTypeIs) { isPos := p.currentLocation() p.advance() // Consume IS @@ -291,76 +279,52 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { p.advance() // Consume NOT } - if p.isType(models.TokenTypeNull) { - p.advance() // Consume NULL - return &ast.BinaryExpression{ - Left: left, - Operator: "IS NULL", - Right: &ast.LiteralValue{Value: nil, Type: "null"}, - Not: isNot, - Pos: isPos, - }, nil + is, err := p.parseIsSuffix(left, isPos, isNot) + if err != nil { + return nil, suffixNone, err } + return is, suffixPredicate, nil + } - return nil, p.expectedError("NULL") + // PostgreSQL / SQLite postfix ISNULL and NOTNULL. + if is, ok := p.postfixNullTest(left); ok { + return is, suffixPredicate, nil + } + + // Comparison operators do not chain: "a = b = c" is left to the caller. + if afterComparison { + return nil, suffixNone, nil } // Check if this is a comparison binary expression (uses O(1) switch instead of O(n) isAnyType) - if p.isComparisonOperator() { - // Save the operator and its position + operator, nTok := p.combinedComparison() + if nTok == 0 && p.isComparisonOperator() { + operator, nTok = p.currentToken.Token.Value, 1 + } + if nTok > 0 { + // Save the operator position opPos := p.currentLocation() - operator := p.currentToken.Token.Value - p.advance() - - // Check for ANY/ALL subquery operators (uses O(1) switch instead of O(n) isAnyType) - if p.isQuantifier() { - quantifier := p.currentToken.Token.Value - p.advance() // Consume ANY/ALL - - // Expect opening parenthesis - if !p.isType(models.TokenTypeLParen) { - return nil, p.expectedError("(") - } - p.advance() // Consume ( + for i := 0; i < nTok; i++ { + p.advance() + } - // Parse subquery - subquery, err := p.parseSubquery() + // Check for ANY/ALL/SOME quantified comparisons: = ANY (subquery), + // = ANY($1), <> ALL (ARRAY[1, 2]). + if p.isQuantifier() || p.isSomeQuantifier() { + quantified, err := p.parseQuantifiedComparison(left, operator) if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse %s subquery: %v", quantifier, err), - p.currentLocation(), - "", - ) - } - - // Expect closing parenthesis - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") - } - p.advance() // Consume ) - - if quantifier == "ANY" { - return &ast.AnyExpression{ - Expr: left, - Operator: operator, - Subquery: subquery, - }, nil + return nil, suffixNone, err } - return &ast.AllExpression{ - Expr: left, - Operator: operator, - Subquery: subquery, - }, nil + return quantified, suffixComparison, nil } - // Parse the right operand at the same level as the left one (string - // concatenation, which includes the additive and multiplicative - // levels), so arithmetic binds tighter than comparison: a = 1 + 2 is - // a = (1 + 2) (#596). Comparisons stay non-chaining: a second - // comparison operator is left for the caller, which rejects it. - right, err := p.parseStringConcatExpression() + // Parse the right operand at the same level as the left one (bitwise, + // string concatenation, additive and multiplicative levels), so + // arithmetic binds tighter than comparison: a = 1 + 2 is a = (1 + 2) + // (#596). + right, err := p.parseBitwiseExpression(1) if err != nil { - return nil, err + return nil, suffixNone, err } // Create a binary expression @@ -369,10 +333,128 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) { Operator: operator, Right: right, Pos: opPos, + }, suffixComparison, nil + } + + return nil, suffixNone, nil +} + +// parsePatternMatch parses the pattern of a LIKE-style operator whose keyword +// has been consumed, with an optional ESCAPE clause when allowEscape is set. +func (p *Parser) parsePatternMatch(left ast.Expression, operator string, not, allowEscape bool) (ast.Expression, int, error) { + pattern, err := p.parseBitwiseExpression(1) + if err != nil { + return nil, suffixNone, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse %s pattern: %v", operator, err), + p.currentLocation(), + "", + ).WithCause(err) + } + var escape ast.Expression + if allowEscape { + escape, err = p.parseEscapeClause() + if err != nil { + return nil, suffixNone, err + } + } + return &ast.BinaryExpression{ + Left: left, + Operator: operator, + Right: pattern, + Not: not, + Escape: escape, + }, suffixPredicate, nil +} + +// parseInTail parses the right side of IN once left (and any NOT / GLOBAL) +// has been consumed. The current token is IN. +func (p *Parser) parseInTail(left ast.Expression, notPrefix, global bool) (ast.Expression, error) { + inPos := p.currentLocation() + p.advance() // Consume IN + + // Expect opening parenthesis + if !p.isType(models.TokenTypeLParen) { + // SQLite also takes a table or table-valued function: a IN tbl. + if p.IsSQLite() && p.isIdentifier() { + ref, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + return &ast.InExpression{ + Expr: left, + List: []ast.Expression{ref}, + Not: notPrefix, + Bare: true, + Pos: inPos, + }, nil + } + return nil, p.expectedError("(") + } + p.advance() // Consume ( + + // SQLite accepts an empty list: a IN () is always false. + if p.isType(models.TokenTypeRParen) && p.IsSQLite() { + p.advance() // Consume ) + return &ast.InExpression{Expr: left, Not: notPrefix, Pos: inPos}, nil + } + + // Check if this is a subquery (starts with SELECT or WITH) + if p.isType(models.TokenTypeSelect) || p.isType(models.TokenTypeWith) { + // Parse subquery + subquery, err := p.parseSubquery() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse IN subquery: %v", err), + p.currentLocation(), + "", + ).WithCause(err) + } + + // Expect closing parenthesis + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume ) + + return &ast.InExpression{ + Expr: left, + Subquery: subquery, + Not: notPrefix, + Global: global, + Pos: inPos, }, nil } - return left, nil + // Parse value list + var values []ast.Expression + for { + value, err := p.parseExpression() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse IN value: %v", err), + p.currentLocation(), + "", + ) + } + values = append(values, value) + + if p.isType(models.TokenTypeComma) { + p.advance() // Consume comma + } else if p.isType(models.TokenTypeRParen) { + break + } else { + return nil, p.expectedError(", or )") + } + } + p.advance() // Consume ) + + return &ast.InExpression{ + Expr: left, + List: values, + Not: notPrefix, + Global: global, + Pos: inPos, + }, nil } // parseArrayAccessExpression parses array subscript and slice expressions. diff --git a/pkg/sql/parser/expressions_ops.go b/pkg/sql/parser/expressions_ops.go new file mode 100644 index 00000000..f79501b9 --- /dev/null +++ b/pkg/sql/parser/expressions_ops.go @@ -0,0 +1,515 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - expressions_ops.go +// Operator and predicate helpers: bitwise and "other" binary operators, +// postfix AT TIME ZONE / COLLATE, IS forms, quantified ANY/ALL/SOME over +// arrays, and the dialect-specific word operators (DIV, MOD, XOR, GLOB, ...). + +package parser + +import ( + "fmt" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// isMySQLFamily reports whether the active dialect is MySQL or MariaDB. +func (p *Parser) isMySQLFamily() bool { + return p.IsMySQL() || p.IsMariaDB() +} + +// peekAt returns the token offset positions after the current one (1 is the +// next token), or an empty token past the end of input. +func (p *Parser) peekAt(offset int) models.TokenWithSpan { + if i := p.currentPos + offset; i >= 0 && i < len(p.tokens) { + return p.tokens[i] + } + return models.TokenWithSpan{} +} + +// peekTypeAt returns the type of the token offset positions after the current +// one, or TokenTypeUnknown past the end of input. +func (p *Parser) peekTypeAt(offset int) models.TokenType { + if i := p.currentPos + offset; i >= 0 && i < len(p.tokens) { + return p.tokens[i].Token.Type + } + return models.TokenTypeUnknown +} + +// nextAdjacent reports whether the token after the current one starts exactly +// where the current one ends, so that two tokens the tokenizer split apart +// (for example "<=" and ">") really were written as one operator ("<=>"). +func (p *Parser) nextAdjacent(offset int) bool { + prev := p.currentToken + if offset > 1 { + prev = p.peekAt(offset - 1) + } + next := p.peekAt(offset) + return prev.End == next.Start +} + +// peekValueIs reports whether the token offset positions ahead has the given +// upper-case text (matched case-insensitively, whatever its token type). +func (p *Parser) peekValueIs(offset int, upper string) bool { + return equalsUpper(p.peekAt(offset).Token.Value, upper) +} + +// bitwiseOperator reports the bitwise or "other" binary operator that starts +// at the current token: its text, its precedence (1 loosest) and how many +// tokens it spans. n is 0 when the current token starts no such operator. +// +// Precedence follows MySQL, the dialect that defines the most levels: +// "|" < "&" and the PostgreSQL operators < "<<" ">>". All of them bind +// looser than + - and || and tighter than comparisons. SQL Server treats & and +// | like + and -, which parseAdditiveExpression handles, so they are not +// reported here for that dialect. +func (p *Parser) bitwiseOperator() (op string, prec, n int) { + switch p.currentToken.Token.Type { + case models.TokenTypePipe: + if p.IsSQLServer() { + return "", 0, 0 + } + return "|", 1, 1 + case models.TokenTypeAmpersand: + if p.IsSQLServer() { + return "", 0, 0 + } + return "&", 2, 1 + case models.TokenTypeAtAt: + return "@@", 2, 1 + case models.TokenTypeOverlap: + // MySQL and MariaDB read && as logical AND (parseAndExpression). + if p.isMySQLFamily() { + return "", 0, 0 + } + return "&&", 2, 1 + case models.TokenTypeSharp: + return "#", 2, 1 + case models.TokenTypeLtEq: + // <=> is the null-safe equality operator in MySQL (a comparison, see + // combinedComparison) and the cosine-distance operator of pgvector in + // PostgreSQL. + if p.peekTypeAt(1) == models.TokenTypeGt && p.IsPostgreSQL() && p.nextAdjacent(1) { + return "<=>", 2, 2 + } + case models.TokenTypeLt: + switch p.peekTypeAt(1) { + case models.TokenTypeLt: + if p.nextAdjacent(1) { + return "<<", 3, 2 + } + case models.TokenTypeArrow: + // a <-> b: the distance operator of pg_trgm and pgvector. + if p.IsPostgreSQL() && p.nextAdjacent(1) { + return "<->", 2, 2 + } + } + case models.TokenTypeGt: + if p.peekTypeAt(1) == models.TokenTypeGt && p.nextAdjacent(1) { + return ">>", 3, 2 + } + } + return "", 0, 0 +} + +// parseBitwiseExpression parses the bitwise and "other" binary operators with +// precedence climbing: operators with a precedence below minPrec are left to +// the caller. The operands are string-concatenation expressions. +func (p *Parser) parseBitwiseExpression(minPrec int) (ast.Expression, error) { + left, err := p.parseStringConcatExpression() + if err != nil { + return nil, err + } + for { + op, prec, n := p.bitwiseOperator() + if n == 0 || prec < minPrec { + return left, nil + } + opPos := p.currentLocation() + for i := 0; i < n; i++ { + p.advance() // Consume the operator token(s) + } + right, err := p.parseBitwiseExpression(prec + 1) + if err != nil { + return nil, err + } + left = &ast.BinaryExpression{ + Left: left, + Operator: op, + Right: right, + Pos: opPos, + } + } +} + +// sqlServerBitwise reports whether the current token is a T-SQL bitwise +// operator (&, |), which shares the additive precedence level there. +func (p *Parser) sqlServerBitwise() bool { + t := p.currentToken.Token.Type + return (t == models.TokenTypeAmpersand || t == models.TokenTypePipe) && p.IsSQLServer() +} + +// mysqlWordMulOperator reports whether the current token is MySQL's integer +// division or modulo keyword operator (DIV, MOD), returning its canonical +// text. They are reserved words in MySQL and MariaDB, so they cannot be an +// alias there. +func (p *Parser) mysqlWordMulOperator() (string, bool) { + if p.currentToken.Token.Type != models.TokenTypeIdentifier || !p.isMySQLFamily() { + return "", false + } + switch v := p.currentToken.Token.Value; { + case equalsUpper(v, "DIV"): + return "DIV", true + case equalsUpper(v, "MOD"): + return "MOD", true + } + return "", false +} + +// isLogicalAnd reports whether the current token is the AND operator, +// including MySQL's && synonym. +func (p *Parser) isLogicalAnd() bool { + t := p.currentToken.Token.Type + return t == models.TokenTypeAnd || (t == models.TokenTypeOverlap && p.isMySQLFamily()) +} + +// parseXorExpression parses MySQL's logical XOR, which binds looser than AND +// and tighter than OR. Other dialects have no XOR operator and fall straight +// through to AND. +func (p *Parser) parseXorExpression() (ast.Expression, error) { + left, err := p.parseAndExpression() + if err != nil { + return nil, err + } + for p.currentToken.Token.Type == models.TokenTypeIdentifier && p.isMySQLFamily() && equalsUpper(p.currentToken.Token.Value, "XOR") { + opPos := p.currentLocation() + p.advance() // Consume XOR + right, err := p.parseAndExpression() + if err != nil { + return nil, err + } + left = &ast.BinaryExpression{ + Left: left, + Operator: "XOR", + Right: right, + Pos: opPos, + } + } + return left, nil +} + +// parsePostfixOperators parses the postfix operators that bind tighter than +// the arithmetic operators: AT TIME ZONE zone and COLLATE name (PostgreSQL +// precedence: after :: and unary signs, before ^ * / + -). They chain to the +// left. +func (p *Parser) parsePostfixOperators(left ast.Expression) (ast.Expression, error) { + for { + switch { + case p.currentToken.Token.Type == models.TokenTypeCollate: + collate, err := p.parseCollateSuffix(left) + if err != nil { + return nil, err + } + left = collate + case p.isAtTimeZone(): + atPos := p.currentLocation() + p.advance() // Consume AT + p.advance() // Consume TIME + p.advance() // Consume ZONE + zone, err := p.parseCastOperand() + if err != nil { + return nil, err + } + left = &ast.AtTimeZoneExpression{Expr: left, Zone: zone, Pos: atPos} + default: + return left, nil + } + } +} + +// isAtTimeZone reports whether the current tokens spell AT TIME ZONE. +func (p *Parser) isAtTimeZone() bool { + if p.currentToken.Token.Type != models.TokenTypeIdentifier || !equalsUpper(p.currentToken.Token.Value, "AT") { + return false + } + return p.peekValueIs(1, "TIME") && p.peekValueIs(2, "ZONE") +} + +// parseCastOperand parses a primary expression with any trailing :: casts, +// the operand of AT TIME ZONE ('UTC'::text). +func (p *Parser) parseCastOperand() (ast.Expression, error) { + operand, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + for p.isType(models.TokenTypeDoubleColon) { + p.advance() // Consume :: + dataType, err := p.parseDataType() + if err != nil { + return nil, err + } + operand = &ast.CastExpression{Expr: operand, Type: dataType} + } + return operand, nil +} + +// parseCollateSuffix parses COLLATE name, with an optionally schema-qualified +// name, after left. The current token must be COLLATE. +func (p *Parser) parseCollateSuffix(left ast.Expression) (ast.Expression, error) { + collatePos := p.currentLocation() + p.advance() // Consume COLLATE + + if !p.isIdentifier() && !p.isNonReservedKeyword() && !p.isStringLiteral() { + return nil, p.expectedError("collation name") + } + name := p.currentToken.Token.Value + if p.isStringLiteral() { + p.advance() // Consume the string literal + return &ast.CollateExpression{Expr: left, Collation: name, Literal: true, Pos: collatePos}, nil + } + quoted := p.isType(models.TokenTypeDoubleQuotedString) + p.advance() // Consume the name + + for p.isType(models.TokenTypePeriod) { + p.advance() // Consume . + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("collation name") + } + name += "." + p.currentToken.Token.Value + quoted = p.isType(models.TokenTypeDoubleQuotedString) + p.advance() + } + return &ast.CollateExpression{Expr: left, Collation: name, Quoted: quoted, Pos: collatePos}, nil +} + +// parseIsSuffix parses what follows IS [NOT] in a comparison. The current +// token is the one after IS (and NOT, when isNot is set). +// +// IS [NOT] NULL IS NULL / IS NOT NULL +// IS [NOT] TRUE | FALSE | UNKNOWN IS / IS NOT with a boolean literal +// IS [NOT] DISTINCT FROM expr IS [NOT] DISTINCT FROM +// IS [NOT] expr SQLite only: null-safe equality +func (p *Parser) parseIsSuffix(left ast.Expression, isPos models.Location, isNot bool) (ast.Expression, error) { + if p.isType(models.TokenTypeNull) { + p.advance() // Consume NULL + return &ast.BinaryExpression{ + Left: left, + Operator: "IS NULL", + Right: &ast.LiteralValue{Value: nil, Type: "null"}, + Not: isNot, + Pos: isPos, + }, nil + } + + isOp := "IS" + if isNot { + isOp = "IS NOT" + } + + switch { + case p.isBooleanLiteral(): + right, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + return &ast.BinaryExpression{Left: left, Operator: isOp, Right: right, Pos: isPos}, nil + + case p.currentToken.Token.Type == models.TokenTypeIdentifier && equalsUpper(p.currentToken.Token.Value, "UNKNOWN"): + p.advance() // Consume UNKNOWN + return &ast.BinaryExpression{ + Left: left, + Operator: isOp, + Right: &ast.LiteralValue{Value: "UNKNOWN", Type: "UNKNOWN"}, + Pos: isPos, + }, nil + + case p.isType(models.TokenTypeDistinct): + p.advance() // Consume DISTINCT + if !p.isType(models.TokenTypeFrom) { + return nil, p.expectedError("FROM after IS DISTINCT") + } + p.advance() // Consume FROM + right, err := p.parseBitwiseExpression(1) + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse IS DISTINCT FROM operand: %v", err), + p.currentLocation(), + "", + ).WithCause(err) + } + op := "IS DISTINCT FROM" + if isNot { + op = "IS NOT DISTINCT FROM" + } + return &ast.BinaryExpression{Left: left, Operator: op, Right: right, Pos: isPos}, nil + + case p.IsSQLite(): + // SQLite: "a IS b" is null-safe equality, with any expression on the right. + right, err := p.parseBitwiseExpression(1) + if err != nil { + return nil, err + } + return &ast.BinaryExpression{Left: left, Operator: isOp, Right: right, Pos: isPos}, nil + } + + return nil, p.expectedError("NULL, TRUE, FALSE, UNKNOWN or DISTINCT FROM") +} + +// isSomeQuantifier reports whether the current token is the SOME quantifier +// (a synonym of ANY) followed by an opening parenthesis. +func (p *Parser) isSomeQuantifier() bool { + if p.currentToken.Token.Type != models.TokenTypeSome && !equalsUpper(p.currentToken.Token.Value, "SOME") { + return false + } + return p.peekTypeAt(1) == models.TokenTypeLParen +} + +// parseQuantifiedComparison parses the quantifier part of "expr op ANY|ALL|SOME +// (...)": the current token is the quantifier. A subquery yields AnyExpression +// or AllExpression; any other argument (a placeholder, a column, ARRAY[...]) +// yields AnyAllExpression. SOME is kept as AnyAllExpression with the SOME +// spelling so the formatter does not rewrite it. +func (p *Parser) parseQuantifiedComparison(left ast.Expression, operator string) (ast.Expression, error) { + quantPos := p.currentLocation() + var quantifier string + switch { + case p.isType(models.TokenTypeAny): + quantifier = "ANY" + case p.isType(models.TokenTypeAll): + quantifier = "ALL" + default: + quantifier = "SOME" + } + p.advance() // Consume ANY/ALL/SOME + + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("(") + } + p.advance() // Consume ( + + isQuery := p.isType(models.TokenTypeSelect) || p.isType(models.TokenTypeWith) + if isQuery { + lparenPos := quantPos + subquery, err := p.parseSubquery() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse %s subquery: %v", quantifier, err), + p.currentLocation(), + "", + ) + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume ) + switch quantifier { + case "ANY": + return &ast.AnyExpression{Expr: left, Operator: operator, Subquery: subquery}, nil + case "ALL": + return &ast.AllExpression{Expr: left, Operator: operator, Subquery: subquery}, nil + } + return &ast.AnyAllExpression{ + Expr: left, + Operator: operator, + Quantifier: quantifier, + Array: &ast.SubqueryExpression{Subquery: subquery, Pos: lparenPos}, + Pos: quantPos, + }, nil + } + + arg, err := p.parseExpression() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse %s argument: %v", quantifier, err), + p.currentLocation(), + "", + ).WithCause(err) + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume ) + return &ast.AnyAllExpression{ + Expr: left, + Operator: operator, + Quantifier: quantifier, + Array: arg, + Pos: quantPos, + }, nil +} + +// parseEscapeClause parses an optional "ESCAPE expr" after a LIKE-style +// pattern. It returns nil when no ESCAPE follows. +func (p *Parser) parseEscapeClause() (ast.Expression, error) { + if p.currentToken.Token.Type != models.TokenTypeIdentifier || !equalsUpper(p.currentToken.Token.Value, "ESCAPE") { + return nil, nil + } + p.advance() // Consume ESCAPE + esc, err := p.parseBitwiseExpression(1) + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse ESCAPE character: %v", err), + p.currentLocation(), + "", + ).WithCause(err) + } + return esc, nil +} + +// combinedComparison reports a comparison operator the tokenizer splits into +// several tokens that sits at the current token: MySQL's null-safe <=> (the +// tokens <= and >) and the == spelling of equality. It returns the operator +// text and the number of tokens, or n == 0. +func (p *Parser) combinedComparison() (op string, n int) { + switch p.currentToken.Token.Type { + case models.TokenTypeLtEq: + if !p.IsPostgreSQL() && p.peekTypeAt(1) == models.TokenTypeGt && p.nextAdjacent(1) { + return "<=>", 2 + } + case models.TokenTypeEq: + if p.peekTypeAt(1) == models.TokenTypeEq && p.nextAdjacent(1) { + return "==", 2 + } + } + return "", 0 +} + +// postfixNullTest handles the PostgreSQL and SQLite postfix ISNULL / NOTNULL +// operators, which mean IS NULL and IS NOT NULL. +func (p *Parser) postfixNullTest(left ast.Expression) (ast.Expression, bool) { + if p.currentToken.Token.Type != models.TokenTypeIdentifier || !(p.IsPostgreSQL() || p.IsSQLite()) { + return nil, false + } + v := p.currentToken.Token.Value + isNot := false + switch { + case equalsUpper(v, "ISNULL"): + case equalsUpper(v, "NOTNULL"): + isNot = true + default: + return nil, false + } + pos := p.currentLocation() + p.advance() + return &ast.BinaryExpression{ + Left: left, + Operator: "IS NULL", + Right: &ast.LiteralValue{Value: nil, Type: "null"}, + Not: isNot, + Pos: pos, + }, true +} diff --git a/pkg/sql/parser/operators_c1_gating_test.go b/pkg/sql/parser/operators_c1_gating_test.go new file mode 100644 index 00000000..e17bead9 --- /dev/null +++ b/pkg/sql/parser/operators_c1_gating_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - operators_c1_gating_test.go +// Dialect gating, node fields and regressions for the operator and predicate +// work in operators_c1_test.go. + +package parser + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestOperatorsC1_Rejected pins the statements that must keep failing: syntax +// that is dialect specific must not leak into other dialects, and the +// operators that need a second token must not swallow unrelated input. +func TestOperatorsC1_Rejected(t *testing.T) { + tests := []struct { + name string + d keywords.SQLDialect + sql string + }{ + {"is expr only in sqlite", keywords.DialectPostgreSQL, "SELECT * FROM t WHERE a IS b"}, + {"is expr not in mysql", keywords.DialectMySQL, "SELECT * FROM t WHERE a IS b"}, + {"empty in only in sqlite pg", keywords.DialectPostgreSQL, "SELECT a FROM t WHERE a IN ()"}, + {"empty in only in sqlite mysql", keywords.DialectMySQL, "SELECT a FROM t WHERE a IN ()"}, + {"in table only in sqlite", keywords.DialectPostgreSQL, "SELECT a FROM t WHERE a IN u"}, + {"comparison does not chain", keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE a = b = c"}, + {"is distinct needs from", keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE a IS DISTINCT b"}, + {"global needs in", keywords.DialectClickHouse, "SELECT a FROM t WHERE a GLOBAL"}, + {"split shift is not a shift", keywords.DialectPostgreSQL, "SELECT a < < b FROM t"}, + {"split null safe equals", keywords.DialectMySQL, "SELECT * FROM t WHERE a <= > b"}, + {"glob only in sqlite", keywords.DialectPostgreSQL, "SELECT * FROM t WHERE p GLOB 'x'"}, + {"not glob only in sqlite", keywords.DialectMySQL, "SELECT * FROM t WHERE p NOT GLOB 'x'"}, + {"escape needs a value", keywords.DialectPostgreSQL, "SELECT a FROM t WHERE a LIKE 'x' ESCAPE"}, + {"any needs parentheses", keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE a = ANY tags"}, + {"ternary needs colon", keywords.DialectClickHouse, "SELECT a ? 1"}, + {"div only in mysql family", keywords.DialectPostgreSQL, "SELECT a DIV 3 FROM t"}, + {"mod word only in mysql family", keywords.DialectSQLServer, "SELECT a MOD 3 FROM t"}, + {"xor only in mysql family", keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE a XOR b"}, + {"sounds like only in mysql family", keywords.DialectPostgreSQL, "SELECT 1 FROM t WHERE a SOUNDS LIKE b"}, + {"isnull not in mysql", keywords.DialectMySQL, "SELECT 1 FROM t WHERE a ISNULL"}, + {"ternary only in clickhouse", keywords.DialectPostgreSQL, "SELECT a ? 1 : 2 FROM t"}, + {"bang not only in mysql family", keywords.DialectPostgreSQL, "SELECT !a FROM t"}, + {"assignment only in mysql family", keywords.DialectPostgreSQL, "SELECT @a := 1"}, + {"collate needs a name", keywords.DialectPostgreSQL, "SELECT a COLLATE FROM t"}, + } + for _, tc := range tests { + t.Run(string(tc.d)+"/"+tc.name, func(t *testing.T) { + tree, err := c1Parse(tc.sql, tc.d) + if err == nil { + defer ast.ReleaseAST(tree) + t.Fatalf("parse(%q) succeeded, want an error", tc.sql) + } + }) + } +} + +// TestOperatorsC1_NodeFields checks the fields the new constructs record. +func TestOperatorsC1_NodeFields(t *testing.T) { + t.Run("any array quantifier and operator", func(t *testing.T) { + stmt, tree := c1Select(t, "SELECT 1 FROM t WHERE id <> ALL(tags)", keywords.DialectPostgreSQL) + defer ast.ReleaseAST(tree) + any, ok := stmt.Where.(*ast.AnyAllExpression) + if !ok { + t.Fatalf("WHERE is %T, want *ast.AnyAllExpression", stmt.Where) + } + if any.Quantifier != "ALL" || any.Operator != "<>" { + t.Errorf("got %s %s, want ALL <>", any.Quantifier, any.Operator) + } + if _, ok := any.Array.(*ast.Identifier); !ok { + t.Errorf("Array is %T, want *ast.Identifier", any.Array) + } + if n := len(any.Children()); n != 2 { + t.Errorf("Children() = %d nodes, want 2", n) + } + }) + + t.Run("any subquery stays AnyExpression", func(t *testing.T) { + stmt, tree := c1Select(t, "SELECT 1 FROM t WHERE id = ANY (SELECT id FROM u)", keywords.DialectPostgreSQL) + defer ast.ReleaseAST(tree) + if _, ok := stmt.Where.(*ast.AnyExpression); !ok { + t.Fatalf("WHERE is %T, want *ast.AnyExpression", stmt.Where) + } + }) + + t.Run("global in", func(t *testing.T) { + stmt, tree := c1Select(t, "SELECT a FROM t WHERE a GLOBAL NOT IN (SELECT 1)", keywords.DialectClickHouse) + defer ast.ReleaseAST(tree) + in, ok := stmt.Where.(*ast.InExpression) + if !ok { + t.Fatalf("WHERE is %T, want *ast.InExpression", stmt.Where) + } + if !in.Global || !in.Not || in.Subquery == nil { + t.Errorf("Global=%v Not=%v Subquery=%v, want true true non-nil", in.Global, in.Not, in.Subquery) + } + }) + + t.Run("bare in", func(t *testing.T) { + stmt, tree := c1Select(t, "SELECT a FROM t WHERE a IN main.u", keywords.DialectSQLite) + defer ast.ReleaseAST(tree) + in, ok := stmt.Where.(*ast.InExpression) + if !ok { + t.Fatalf("WHERE is %T, want *ast.InExpression", stmt.Where) + } + if !in.Bare || len(in.List) != 1 { + t.Errorf("Bare=%v len(List)=%d, want true 1", in.Bare, len(in.List)) + } + }) + + t.Run("collate quoted", func(t *testing.T) { + stmt, tree := c1Select(t, `SELECT a COLLATE "C" FROM t`, keywords.DialectPostgreSQL) + defer ast.ReleaseAST(tree) + c, ok := stmt.Columns[0].(*ast.CollateExpression) + if !ok { + t.Fatalf("column is %T, want *ast.CollateExpression", stmt.Columns[0]) + } + if c.Collation != "C" || !c.Quoted || c.Literal { + t.Errorf("Collation=%q Quoted=%v Literal=%v, want C true false", c.Collation, c.Quoted, c.Literal) + } + }) + + t.Run("like escape", func(t *testing.T) { + stmt, tree := c1Select(t, "SELECT a FROM t WHERE a LIKE 'x!%' ESCAPE '!'", keywords.DialectPostgreSQL) + defer ast.ReleaseAST(tree) + bin, ok := stmt.Where.(*ast.BinaryExpression) + if !ok { + t.Fatalf("WHERE is %T, want *ast.BinaryExpression", stmt.Where) + } + esc, ok := bin.Escape.(*ast.LiteralValue) + if !ok { + t.Fatalf("Escape is %T, want *ast.LiteralValue", bin.Escape) + } + if esc.Value != "!" { + t.Errorf("Escape = %v, want !", esc.Value) + } + if n := len(bin.Children()); n != 3 { + t.Errorf("Children() = %d nodes, want 3 (left, pattern, escape)", n) + } + }) + + t.Run("and or are canonical upper case", func(t *testing.T) { + stmt, tree := c1Select(t, "select a from t where a = 1 and b = 2 or c = 3", keywords.DialectPostgreSQL) + defer ast.ReleaseAST(tree) + or, ok := stmt.Where.(*ast.BinaryExpression) + if !ok || or.Operator != "OR" { + t.Fatalf("WHERE = %T %v, want OR binary", stmt.Where, stmt.Where) + } + and, ok := or.Left.(*ast.BinaryExpression) + if !ok || and.Operator != "AND" { + t.Fatalf("left of OR = %T, want AND binary", or.Left) + } + }) + + t.Run("mysql pipes or keeps its flag", func(t *testing.T) { + stmt, tree := c1Select(t, "SELECT 1 FROM t WHERE a || b", keywords.DialectMySQL) + defer ast.ReleaseAST(tree) + or, ok := stmt.Where.(*ast.BinaryExpression) + if !ok || or.Operator != "OR" || !or.PipesOr { + t.Fatalf("WHERE = %#v, want OR with PipesOr", stmt.Where) + } + }) +} + +// TestOperatorsC1_NotStillPrefixesOnlyPredicates guards the NOT lookahead: +// NOT after an operand belongs to the operand only when a predicate operator +// follows it. +func TestOperatorsC1_NotStillPrefixesOnlyPredicates(t *testing.T) { + tests := []struct { + d keywords.SQLDialect + sql string + }{ + {keywords.DialectPostgreSQL, "SELECT a FROM t WHERE NOT active AND name LIKE 'x%'"}, + {keywords.DialectPostgreSQL, "CREATE TABLE t (a INT DEFAULT 0 NOT NULL)"}, + {keywords.DialectPostgreSQL, "CREATE TABLE t (a TEXT DEFAULT 'x' NOT NULL, b INT)"}, + {keywords.DialectMySQL, "SELECT a FROM t WHERE NOT MATCH (a) AGAINST ('x')"}, + {keywords.DialectSQLite, "SELECT a FROM t WHERE a NOT IN (1, 2) AND b NOT BETWEEN 1 AND 2"}, + {keywords.DialectPostgreSQL, "SELECT a FROM t WHERE a NOT SIMILAR TO 'x' AND b NOT LIKE 'y' ESCAPE '!'"}, + } + for _, tc := range tests { + t.Run(string(tc.d)+"/"+tc.sql, func(t *testing.T) { + tree, err := c1Parse(tc.sql, tc.d) + if err != nil { + t.Fatalf("parse(%q): %v", tc.sql, err) + } + ast.ReleaseAST(tree) + }) + } +} + +// TestOperatorsC1_IndexColumnCollate checks COLLATE on CREATE INDEX columns. +func TestOperatorsC1_IndexColumnCollate(t *testing.T) { + tree, err := c1Parse("CREATE INDEX i ON t (a COLLATE NOCASE DESC, b)", keywords.DialectSQLite) + if err != nil { + t.Fatalf("parse: %v", err) + } + defer ast.ReleaseAST(tree) + idx, ok := tree.Statements[0].(*ast.CreateIndexStatement) + if !ok { + t.Fatalf("got %T, want *ast.CreateIndexStatement", tree.Statements[0]) + } + if len(idx.Columns) != 2 { + t.Fatalf("got %d columns, want 2", len(idx.Columns)) + } + if idx.Columns[0].Collate != "NOCASE" || idx.Columns[0].Direction != "DESC" || idx.Columns[1].Collate != "" { + t.Errorf("columns = %+v, want a COLLATE NOCASE DESC and plain b", idx.Columns) + } +} + +// TestOperatorsC1_ReleaseAST parses statements that use every new node and +// releases them repeatedly: the pool release walks the new nodes' children +// and must neither panic nor double-put. +func TestOperatorsC1_ReleaseAST(t *testing.T) { + sqls := []struct { + d keywords.SQLDialect + sql string + }{ + {keywords.DialectPostgreSQL, `SELECT c AT TIME ZONE 'UTC', x COLLATE "C", ARRAY[1] FROM t WHERE id = ANY(ARRAY[1, 2]) AND a LIKE 'x' ESCAPE '!'`}, + {keywords.DialectClickHouse, "SELECT a ? 1 : 2 FROM t WHERE a GLOBAL IN (SELECT 1)"}, + {keywords.DialectSQLite, "SELECT a FROM t WHERE a IN u AND b IS NOT c"}, + } + for i := 0; i < 20; i++ { + for _, tc := range sqls { + tree, err := c1Parse(tc.sql, tc.d) + if err != nil { + t.Fatalf("parse(%q): %v", tc.sql, err) + } + ast.ReleaseAST(tree) + } + } +} diff --git a/pkg/sql/parser/operators_c1_test.go b/pkg/sql/parser/operators_c1_test.go new file mode 100644 index 00000000..2037000d --- /dev/null +++ b/pkg/sql/parser/operators_c1_test.go @@ -0,0 +1,291 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - operators_c1_test.go +// Tests for operators and predicates: IS forms, ANY/ALL/SOME over arrays, +// AT TIME ZONE, COLLATE, bitwise and other binary operators, word operators, +// LIKE ... ESCAPE, predicate chaining, IN variants, the ClickHouse ternary +// operator and multi-word / array CAST types. + +package parser + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" +) + +// c1Parse tokenizes and parses sql in dialect d, returning the tree. +func c1Parse(sql string, d keywords.SQLDialect) (*ast.AST, error) { + tkz, err := tokenizer.NewWithDialect(d) + if err != nil { + return nil, err + } + tokens, err := tkz.Tokenize([]byte(sql)) + if err != nil { + return nil, err + } + p := NewParser(WithDialect(string(d))) + defer p.Release() + return p.ParseFromModelTokens(tokens) +} + +// c1Select parses sql as one SELECT and fails the test otherwise. +func c1Select(t *testing.T, sql string, d keywords.SQLDialect) (*ast.SelectStatement, *ast.AST) { + t.Helper() + tree, err := c1Parse(sql, d) + if err != nil { + t.Fatalf("[%s] parse %q: %v", d, sql, err) + } + if len(tree.Statements) != 1 { + t.Fatalf("[%s] %q: got %d statements, want 1", d, sql, len(tree.Statements)) + } + stmt, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("[%s] %q: got %T, want *ast.SelectStatement", d, sql, tree.Statements[0]) + } + return stmt, tree +} + +// c1Shape renders an expression as a fully parenthesized s-expression so a +// test can assert how operators bind: a | b & c is (| a (& b c)). +func c1Shape(e ast.Expression) string { + switch n := e.(type) { + case *ast.BinaryExpression: + op := n.Operator + if n.Not { + op = "NOT " + op + } + if n.Right == nil { + return "(" + op + " " + c1Shape(n.Left) + ")" + } + s := "(" + op + " " + c1Shape(n.Left) + " " + c1Shape(n.Right) + if n.Escape != nil { + s += " ESCAPE " + c1Shape(n.Escape) + } + return s + ")" + case *ast.UnaryExpression: + return "(" + n.Operator.String() + " " + c1Shape(n.Expr) + ")" + case *ast.BetweenExpression: + return "(BETWEEN " + c1Shape(n.Expr) + " " + c1Shape(n.Lower) + " " + c1Shape(n.Upper) + ")" + case *ast.InExpression: + parts := make([]string, 0, len(n.List)) + for _, v := range n.List { + parts = append(parts, c1Shape(v)) + } + return "(IN " + c1Shape(n.Expr) + " [" + strings.Join(parts, " ") + "])" + case *ast.AtTimeZoneExpression: + return "(AT " + c1Shape(n.Expr) + " " + c1Shape(n.Zone) + ")" + case *ast.CollateExpression: + return "(COLLATE " + c1Shape(n.Expr) + " " + n.Collation + ")" + case *ast.CastExpression: + return "(CAST " + c1Shape(n.Expr) + " " + n.Type + ")" + case *ast.TernaryExpression: + return "(? " + c1Shape(n.Cond) + " " + c1Shape(n.Then) + " " + c1Shape(n.Else) + ")" + } + if s, ok := e.(interface{ SQL() string }); ok { + return s.SQL() + } + return e.TokenLiteral() +} + +// TestOperatorsC1_Shapes pins how each new operator parses (operator text and +// binding) and that SQL() writes it back. wantShape describes the first +// select-list item, or the WHERE clause when where is set. +func TestOperatorsC1_Shapes(t *testing.T) { + const ( + pg = keywords.DialectPostgreSQL + my = keywords.DialectMySQL + sl = keywords.DialectSQLite + ms = keywords.DialectSQLServer + ch = keywords.DialectClickHouse + sf = keywords.DialectSnowflake + ora = keywords.DialectOracle + ) + tests := []struct { + name string + d keywords.SQLDialect + sql string + where bool + wantShape string + wantSQL string // expression SQL(); empty skips the SQL and shape checks + }{ + // IS forms + {"is true", pg, "SELECT x IS TRUE FROM t", false, "(IS x TRUE)", "x IS TRUE"}, + {"is not false", pg, "SELECT x IS NOT FALSE FROM t", false, "(IS NOT x FALSE)", "x IS NOT FALSE"}, + {"is unknown", my, "SELECT x IS UNKNOWN FROM t", false, "(IS x UNKNOWN)", "x IS UNKNOWN"}, + {"is not unknown", pg, "SELECT x IS NOT UNKNOWN FROM t", false, "(IS NOT x UNKNOWN)", "x IS NOT UNKNOWN"}, + {"is distinct from", pg, "SELECT u.id FROM u WHERE u.id IS DISTINCT FROM 5", true, "(IS DISTINCT FROM u.id 5)", "u.id IS DISTINCT FROM 5"}, + {"is not distinct from", sf, "SELECT u.id FROM u WHERE u.id IS NOT DISTINCT FROM 5", true, "(IS NOT DISTINCT FROM u.id 5)", "u.id IS NOT DISTINCT FROM 5"}, + {"is not null stays", pg, "SELECT 1 FROM t WHERE a IS NOT NULL", true, "(NOT IS NULL a NULL)", "a IS NOT NULL"}, + {"sqlite is expr", sl, "SELECT * FROM t WHERE a IS b", true, "(IS a b)", "a IS b"}, + {"sqlite is not expr", sl, "SELECT * FROM t WHERE a IS NOT b", true, "(IS NOT a b)", "a IS NOT b"}, + {"isnull", pg, "SELECT 1 FROM t WHERE a ISNULL", true, "(IS NULL a NULL)", "a IS NULL"}, + {"notnull sqlite", sl, "SELECT 1 FROM t WHERE a NOTNULL", true, "(NOT IS NULL a NULL)", "a IS NOT NULL"}, + + // ANY / ALL / SOME over arrays + {"any placeholder", pg, "SELECT 1 FROM t WHERE id = ANY($1)", true, "id = ANY ($1)", "id = ANY ($1)"}, + {"any array constructor", pg, "SELECT 1 FROM t WHERE id = ANY(ARRAY[1, 2])", true, "id = ANY (ARRAY[1, 2])", "id = ANY (ARRAY[1, 2])"}, + {"any column", pg, "SELECT 1 FROM t WHERE 'x' = ANY(tags)", true, "'x' = ANY (tags)", "'x' = ANY (tags)"}, + {"all column", pg, "SELECT 1 FROM t WHERE id <> ALL(tags)", true, "id <> ALL (tags)", "id <> ALL (tags)"}, + {"some column keeps spelling", pg, "SELECT 1 FROM t WHERE id = SOME(tags)", true, "id = SOME (tags)", "id = SOME (tags)"}, + {"some subquery", pg, "SELECT 1 FROM t WHERE id = SOME(SELECT id FROM u)", true, "id = SOME (SELECT id FROM u)", "id = SOME (SELECT id FROM u)"}, + {"any subquery unchanged", pg, "SELECT 1 FROM t WHERE id = ANY(SELECT id FROM u)", true, "id = ANY (SELECT id FROM u)", "id = ANY (SELECT id FROM u)"}, + + // AT TIME ZONE and COLLATE + {"at time zone", pg, "SELECT created_at AT TIME ZONE 'UTC' FROM t", false, "(AT created_at 'UTC')", "created_at AT TIME ZONE 'UTC'"}, + {"at time zone chained", pg, "SELECT c AT TIME ZONE 'UTC' AT TIME ZONE 'PST' FROM t", false, "(AT (AT c 'UTC') 'PST')", "c AT TIME ZONE 'UTC' AT TIME ZONE 'PST'"}, + {"at time zone binds tighter than +", pg, "SELECT a + b AT TIME ZONE 'UTC' FROM t", false, "(+ a (AT b 'UTC'))", "a + b AT TIME ZONE 'UTC'"}, + {"at time zone cast zone", pg, "SELECT c AT TIME ZONE 'UTC'::text FROM t", false, "(AT c (CAST 'UTC' text))", "c AT TIME ZONE CAST('UTC' AS text)"}, + {"collate quoted keeps quotes", pg, `SELECT col COLLATE "C" FROM t`, false, "(COLLATE col C)", `col COLLATE "C"`}, + {"collate bare mysql", my, "SELECT x FROM t WHERE x COLLATE utf8mb4_bin = 'a'", true, "(= (COLLATE x utf8mb4_bin) 'a')", "x COLLATE utf8mb4_bin = 'a'"}, + {"collate qualified", pg, `SELECT a COLLATE pg_catalog."default" FROM t`, false, "(COLLATE a pg_catalog.default)", `a COLLATE pg_catalog."default"`}, + {"collate string literal clickhouse", ch, "SELECT x FROM t ORDER BY y COLLATE 'en'", false, "(COLLATE y en)", "y COLLATE 'en'"}, + + // Bitwise and other binary operators + {"tsvector match", pg, "SELECT x FROM t WHERE tsv @@ to_tsquery('a')", true, "(@@ tsv to_tsquery('a'))", "tsv @@ to_tsquery('a')"}, + {"array overlap", pg, "SELECT a && b FROM t", false, "(&& a b)", "a && b"}, + {"mysql && is AND", my, "SELECT 1 FROM t WHERE a && b", true, "(AND a b)", "a AND b"}, + {"bitwise and sqlserver", ms, "SELECT a & b FROM t", false, "(& a b)", "a & b"}, + {"bitwise or sqlserver binds like plus", ms, "SELECT a | b + 1 FROM t", false, "(+ (| a b) 1)", "a | b + 1"}, + {"pg xor operator", pg, "SELECT a # b FROM t", false, "(# a b)", "a # b"}, + {"bitwise precedence mysql", my, "SELECT a | b & c FROM t", false, "(| a (& b c))", "a | b & c"}, + {"shift binds tighter than &", my, "SELECT a & b << 1 FROM t", false, "(& a (<< b 1))", "a & b << 1"}, + {"plus binds tighter than shift", my, "SELECT 1 << 2 + 3 FROM t", false, "(<< 1 (+ 2 3))", "1 << 2 + 3"}, + {"comparison looser than bitwise", my, "SELECT 1 FROM t WHERE a & 1 = 1", true, "(= (& a 1) 1)", "a & 1 = 1"}, + {"right shift", sl, "SELECT a >> 2 FROM t", false, "(>> a 2)", "a >> 2"}, + {"bitwise not", my, "SELECT ~a FROM t", false, "(~ a)", "~a"}, + {"bang not mysql", my, "SELECT !0", false, "(! 0)", "!0"}, + {"null safe equals mysql", my, "SELECT * FROM t WHERE a <=> NULL", true, "(<=> a NULL)", "a <=> NULL"}, + {"pg distance", pg, "SELECT a <-> b FROM t", false, "(<-> a b)", "a <-> b"}, + {"pgvector cosine distance", pg, "SELECT a <=> b FROM t", false, "(<=> a b)", "a <=> b"}, + {"double equals sqlite", sl, "SELECT * FROM t WHERE a == 1", true, "(== a 1)", "a == 1"}, + + // Word operators + {"div", my, "SELECT a DIV 3 FROM t", false, "(DIV a 3)", "a DIV 3"}, + {"mod word", my, "SELECT a mod 3 FROM t", false, "(MOD a 3)", "a MOD 3"}, + {"div binds like *", my, "SELECT 1 + a DIV 3 FROM t", false, "(+ 1 (DIV a 3))", "1 + a DIV 3"}, + {"xor between and/or", my, "SELECT 1 FROM t WHERE a OR b XOR c AND d", true, "(OR a (XOR b (AND c d)))", "a OR b XOR c AND d"}, + {"not regexp", my, "SELECT * FROM t WHERE x NOT REGEXP 'a'", true, "(NOT REGEXP x 'a')", "x NOT REGEXP 'a'"}, + {"not rlike", my, "SELECT * FROM t WHERE x NOT RLIKE 'a'", true, "(NOT RLIKE x 'a')", "x NOT RLIKE 'a'"}, + {"sounds like", my, "SELECT * FROM t WHERE a SOUNDS LIKE b", true, "(SOUNDS LIKE a b)", "a SOUNDS LIKE b"}, + {"glob", sl, "SELECT * FROM t WHERE p GLOB '*.txt'", true, "(GLOB p '*.txt')", "p GLOB '*.txt'"}, + {"not glob", sl, "SELECT * FROM t WHERE p NOT GLOB '*.txt'", true, "(NOT GLOB p '*.txt')", "p NOT GLOB '*.txt'"}, + {"match", sl, "SELECT * FROM t WHERE a MATCH 'foo'", true, "(MATCH a 'foo')", "a MATCH 'foo'"}, + {"similar to", pg, "SELECT a FROM t WHERE n SIMILAR TO 'x%'", true, "(SIMILAR TO n 'x%')", "n SIMILAR TO 'x%'"}, + {"not similar to", pg, "SELECT a FROM t WHERE n NOT SIMILAR TO 'x%'", true, "(NOT SIMILAR TO n 'x%')", "n NOT SIMILAR TO 'x%'"}, + + // LIKE ... ESCAPE + {"like escape", ora, "SELECT a FROM t WHERE a LIKE 'x' ESCAPE 'y'", true, "(LIKE a 'x' ESCAPE 'y')", "a LIKE 'x' ESCAPE 'y'"}, + {"not ilike escape", pg, "SELECT a FROM t WHERE a NOT ILIKE 'x' ESCAPE 'y'", true, "(NOT ILIKE a 'x' ESCAPE 'y')", "a NOT ILIKE 'x' ESCAPE 'y'"}, + {"similar escape", pg, "SELECT a FROM t WHERE a SIMILAR TO 'x' ESCAPE '#'", true, "(SIMILAR TO a 'x' ESCAPE '#')", "a SIMILAR TO 'x' ESCAPE '#'"}, + {"like concat then escape", pg, "SELECT a FROM t WHERE a LIKE b || '%' ESCAPE '!'", true, "(LIKE a (|| b '%') ESCAPE '!')", "a LIKE b || '%' ESCAPE '!'"}, + + // Predicate chaining + {"in then equals", sl, "SELECT a IN (1, 2) = 1 FROM t", false, "(= (IN a [1 2]) 1)", "a IN (1, 2) = 1"}, + {"between then equals", pg, "SELECT a BETWEEN 1 AND 2 = TRUE FROM t", false, "(= (BETWEEN a 1 2) TRUE)", "a BETWEEN 1 AND 2 = TRUE"}, + {"like then is", pg, "SELECT a LIKE 'x' IS TRUE FROM t", false, "(IS (LIKE a 'x') TRUE)", "a LIKE 'x' IS TRUE"}, + {"comparison then is null", pg, "SELECT 1 FROM t WHERE a = b IS NULL", true, "(IS NULL (= a b) NULL)", "a = b IS NULL"}, + + // IN variants + {"sqlite empty in", sl, "SELECT a FROM t WHERE a IN ()", true, "(IN a [])", "a IN ()"}, + {"sqlite not in empty", sl, "SELECT a FROM t WHERE a NOT IN ()", true, "(IN a [])", "a NOT IN ()"}, + {"sqlite in table", sl, "SELECT a FROM t WHERE a IN u", true, "(IN a [u])", "a IN u"}, + {"clickhouse global in", ch, "SELECT a FROM t WHERE a GLOBAL IN (SELECT 1)", true, "(IN a [])", "a GLOBAL IN (SELECT 1)"}, + {"clickhouse global not in", ch, "SELECT a FROM t WHERE a GLOBAL NOT IN (1, 2)", true, "(IN a [1 2])", "a GLOBAL NOT IN (1, 2)"}, + + // ClickHouse ternary + {"ternary", ch, "SELECT a ? 1 : 2", false, "(? a 1 2)", "a ? 1 : 2"}, + {"ternary right associative", ch, "SELECT a ? 1 : b ? 2 : 3", false, "(? a 1 (? b 2 3))", "a ? 1 : b ? 2 : 3"}, + {"ternary condition is a comparison", ch, "SELECT a > 1 ? 'x' : 'y'", false, "(? (> a 1) 'x' 'y')", "a > 1 ? 'x' : 'y'"}, + + // MySQL user-variable assignment + {"assignment", my, "SELECT @a := 1", false, "(:= @a 1)", "@a := 1"}, + {"assignment right associative", my, "SELECT @a := @b := 2", false, "(:= @a (:= @b 2))", "@a := @b := 2"}, + {"assignment value is a full expression", my, "SELECT @a := b + 1 AND c", false, "(:= @a (AND (+ b 1) c))", "@a := b + 1 AND c"}, + + // Canonical AND / OR + {"and or upper-cased", pg, "select a from t where a = 1 and b = 2 or c = 3", true, "(OR (AND (= a 1) (= b 2)) (= c 3))", "a = 1 AND b = 2 OR c = 3"}, + + // Cast types + {"cast array", pg, "SELECT x::int[] FROM t", false, "(CAST x int[])", "CAST(x AS int[])"}, + {"cast text array func", pg, "SELECT CAST(a AS text[]) FROM t", false, "(CAST a text[])", "CAST(a AS text[])"}, + {"cast sized array", pg, "SELECT x::int[3][4] FROM t", false, "(CAST x int[3][4])", "CAST(x AS int[3][4])"}, + {"cast double precision", pg, "SELECT x::double precision FROM t", false, "(CAST x double precision)", "CAST(x AS double precision)"}, + {"cast character varying", pg, "SELECT x::character varying(10) FROM t", false, "(CAST x character varying(10))", "CAST(x AS character varying(10))"}, + {"cast timestamp with time zone", pg, "SELECT x::timestamp with time zone FROM t", false, "(CAST x timestamp with time zone)", "CAST(x AS timestamp with time zone)"}, + {"cast timestamp precision without tz", pg, "SELECT CAST(x AS timestamp(3) without time zone) FROM t", false, "(CAST x timestamp(3) without time zone)", "CAST(x AS timestamp(3) without time zone)"}, + {"cast qualified type", pg, "SELECT x::public.mood FROM t", false, "(CAST x public.mood)", "CAST(x AS public.mood)"}, + {"cast signed integer", my, "SELECT CAST(x AS SIGNED INTEGER) FROM t", false, "(CAST x SIGNED INTEGER)", "CAST(x AS SIGNED INTEGER)"}, + {"cast unsigned", my, "SELECT CAST(x AS UNSIGNED) FROM t", false, "(CAST x UNSIGNED)", "CAST(x AS UNSIGNED)"}, + {"cast char character set", my, "SELECT CAST(x AS CHAR CHARACTER SET utf8mb4) FROM t", false, "(CAST x CHAR CHARACTER SET utf8mb4)", "CAST(x AS CHAR CHARACTER SET utf8mb4)"}, + {"cast char length character set", my, "SELECT CAST(x AS CHAR(10) CHARACTER SET utf8mb4) FROM t", false, "(CAST x CHAR(10) CHARACTER SET utf8mb4)", "CAST(x AS CHAR(10) CHARACTER SET utf8mb4)"}, + {"cast decimal unchanged", my, "SELECT CAST(x AS DECIMAL(10,2)) FROM t", false, "(CAST x DECIMAL(10,2))", "CAST(x AS DECIMAL(10,2))"}, + } + + for _, tc := range tests { + t.Run(string(tc.d)+"/"+tc.name, func(t *testing.T) { + stmt, tree := c1Select(t, tc.sql, tc.d) + defer ast.ReleaseAST(tree) + + var expr ast.Expression + switch { + case tc.where: + expr = stmt.Where + case len(stmt.OrderBy) > 0: + expr = stmt.OrderBy[0].Expression + default: + expr = stmt.Columns[0] + } + if expr == nil { + t.Fatalf("no expression to inspect in %q", tc.sql) + } + if tc.wantSQL == "" { + return + } + if got := exprSQLForC1(expr); got != tc.wantSQL { + t.Errorf("SQL() = %q, want %q", got, tc.wantSQL) + } + // SQL() must re-parse in the same dialect to the same SQL(). + re, err := c1Parse("SELECT "+exprSQLForC1(expr)+" FROM t", tc.d) + if err != nil { + t.Fatalf("re-parse of %q: %v", exprSQLForC1(expr), err) + } + defer ast.ReleaseAST(re) + rs, ok := re.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("re-parse produced %T", re.Statements[0]) + } + if got := exprSQLForC1(rs.Columns[0]); got != tc.wantSQL { + t.Errorf("re-parsed SQL() = %q, want %q", got, tc.wantSQL) + } + if tc.wantShape != "" { + if shape := c1Shape(expr); shape != tc.wantShape { + t.Errorf("shape = %s, want %s", shape, tc.wantShape) + } + } + }) + } +} + +// exprSQLForC1 returns the SQL() text of e. +func exprSQLForC1(e ast.Expression) string { + if s, ok := e.(interface{ SQL() string }); ok { + return s.SQL() + } + return e.TokenLiteral() +} diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index be9163ad..ac7d5e80 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -75,8 +75,6 @@ clickhouse/05_dialect_specific.sql#35 parse clickhouse/05_dialect_specific.sql#36 parse clickhouse/05_dialect_specific.sql#37 parse clickhouse/05_dialect_specific.sql#38 drop -clickhouse/05_dialect_specific.sql#39 drop -clickhouse/05_dialect_specific.sql#40 drop clickhouse/05_dialect_specific.sql#5 drop clickhouse/05_dialect_specific.sql#50 parse clickhouse/05_dialect_specific.sql#55 parse @@ -138,7 +136,6 @@ clickhouse/08_etl_pipelines.sql#16 drop clickhouse/08_etl_pipelines.sql#17 parse clickhouse/08_etl_pipelines.sql#2 parse clickhouse/08_etl_pipelines.sql#20 parse -clickhouse/08_etl_pipelines.sql#27 parse clickhouse/08_etl_pipelines.sql#3 parse clickhouse/08_etl_pipelines.sql#32 drop clickhouse/08_etl_pipelines.sql#34 parse @@ -150,7 +147,6 @@ clickhouse/08_etl_pipelines.sql#39 parse clickhouse/09_deep_nesting.sql#10 drop clickhouse/09_deep_nesting.sql#16 drop clickhouse/09_deep_nesting.sql#17 parse -clickhouse/09_deep_nesting.sql#21 parse clickhouse/09_deep_nesting.sql#24 parse clickhouse/09_deep_nesting.sql#27 parse clickhouse/09_deep_nesting.sql#28 parse @@ -186,7 +182,6 @@ mariadb/01_app_crud.sql#17 parse mariadb/01_app_crud.sql#26 parse mariadb/01_app_crud.sql#27 parse mariadb/01_app_crud.sql#28 parse -mariadb/01_app_crud.sql#3 parse mariadb/01_app_crud.sql#7 parse mariadb/02_reporting.sql#1 drop mariadb/02_reporting.sql#13 parse @@ -199,9 +194,6 @@ mariadb/02_reporting.sql#26 drop mariadb/02_reporting.sql#27 parse mariadb/02_reporting.sql#29 parse mariadb/02_reporting.sql#3 drop -mariadb/02_reporting.sql#30 parse -mariadb/02_reporting.sql#31 parse -mariadb/02_reporting.sql#33 parse mariadb/02_reporting.sql#38 parse mariadb/02_reporting.sql#39 parse mariadb/02_reporting.sql#40 parse @@ -294,7 +286,6 @@ mariadb/06_etl_admin.sql#24 parse mariadb/06_etl_admin.sql#25 parse mariadb/06_etl_admin.sql#35 parse mariadb/06_etl_admin.sql#36 parse -mariadb/06_etl_admin.sql#37 parse mariadb/06_etl_admin.sql#38 parse mariadb/06_etl_admin.sql#39 parse mariadb/06_etl_admin.sql#40 parse @@ -334,11 +325,8 @@ mariadb/09_mysql_compat_misc.sql#10 parse mariadb/09_mysql_compat_misc.sql#11 parse mariadb/09_mysql_compat_misc.sql#12 parse mariadb/09_mysql_compat_misc.sql#13 drop -mariadb/09_mysql_compat_misc.sql#14 parse mariadb/09_mysql_compat_misc.sql#17 parse mariadb/09_mysql_compat_misc.sql#25 parse -mariadb/09_mysql_compat_misc.sql#33 parse -mariadb/09_mysql_compat_misc.sql#36 parse mariadb/09_mysql_compat_misc.sql#37 parse mariadb/09_mysql_compat_misc.sql#39 parse mariadb/09_mysql_compat_misc.sql#40 parse @@ -410,7 +398,6 @@ mysql/05_dialect_specific.sql#12 parse mysql/05_dialect_specific.sql#13 parse mysql/05_dialect_specific.sql#14 parse mysql/05_dialect_specific.sql#15 parse -mysql/05_dialect_specific.sql#18 parse mysql/05_dialect_specific.sql#19 parse mysql/05_dialect_specific.sql#20 parse mysql/05_dialect_specific.sql#21 parse @@ -446,7 +433,6 @@ mysql/05_dialect_specific.sql#61 parse mysql/05_dialect_specific.sql#62 parse mysql/05_dialect_specific.sql#63 parse mysql/05_dialect_specific.sql#64 parse -mysql/05_dialect_specific.sql#65 parse mysql/05_dialect_specific.sql#67 parse mysql/05_dialect_specific.sql#68 parse mysql/05_dialect_specific.sql#69 parse @@ -488,25 +474,20 @@ mysql/08_etl_admin.sql#6 parse mysql/08_etl_admin.sql#7 parse mysql/08_etl_admin.sql#8 parse mysql/08_etl_admin.sql#9 parse -mysql/09_deep_nesting.sql#1 parse -mysql/09_deep_nesting.sql#11 parse mysql/09_deep_nesting.sql#22 drop mysql/09_deep_nesting.sql#25 drop mysql/09_deep_nesting.sql#26 parse mysql/09_deep_nesting.sql#27 parse mysql/09_deep_nesting.sql#30 parse -mysql/09_deep_nesting.sql#31 parse mysql/09_deep_nesting.sql#5 parse mysql/10_misc_functions.sql#1 parse mysql/10_misc_functions.sql#17 reparse mysql/10_misc_functions.sql#18 drop -mysql/10_misc_functions.sql#19 parse mysql/10_misc_functions.sql#2 parse mysql/10_misc_functions.sql#21 parse mysql/10_misc_functions.sql#22 parse mysql/10_misc_functions.sql#23 parse mysql/10_misc_functions.sql#25 parse -mysql/10_misc_functions.sql#27 parse mysql/10_misc_functions.sql#3 parse mysql/10_misc_functions.sql#8 drop mysql/11_stored_programs.sql#10 parse @@ -551,7 +532,7 @@ mysql/12_orm_misc.sql#6 parse oracle/01_app_crud.sql#10 parse oracle/01_app_crud.sql#24 parse oracle/01_app_crud.sql#25 parse -oracle/01_app_crud.sql#30 parse +oracle/01_app_crud.sql#30 idempotent oracle/01_app_crud.sql#31 idempotent oracle/01_app_crud.sql#33 parse oracle/01_app_crud.sql#34 parse @@ -703,11 +684,8 @@ oracle/08_complex_nested.sql#19 parse postgresql/01_app_crud.sql#1 parse postgresql/01_app_crud.sql#11 parse postgresql/01_app_crud.sql#13 parse -postgresql/01_app_crud.sql#20 parse -postgresql/01_app_crud.sql#8 parse postgresql/02_reporting.sql#27 drop postgresql/02_reporting.sql#4 parse -postgresql/02_reporting.sql#7 parse postgresql/02_reporting.sql#8 parse postgresql/03_ddl_migrations.sql#1 parse postgresql/03_ddl_migrations.sql#10 parse @@ -750,26 +728,14 @@ postgresql/03_ddl_migrations.sql#65 parse postgresql/03_ddl_migrations.sql#7 reparse postgresql/03_ddl_migrations.sql#9 parse postgresql/04_window_analytics.sql#21 parse -postgresql/05_dialect_specific.sql#12 parse postgresql/05_dialect_specific.sql#14 parse -postgresql/05_dialect_specific.sql#15 parse -postgresql/05_dialect_specific.sql#17 parse postgresql/05_dialect_specific.sql#27 parse -postgresql/05_dialect_specific.sql#28 parse postgresql/05_dialect_specific.sql#29 parse -postgresql/05_dialect_specific.sql#30 parse -postgresql/05_dialect_specific.sql#31 parse -postgresql/05_dialect_specific.sql#32 reparse postgresql/05_dialect_specific.sql#33 parse postgresql/05_dialect_specific.sql#35 parse -postgresql/05_dialect_specific.sql#36 parse postgresql/05_dialect_specific.sql#38 parse -postgresql/05_dialect_specific.sql#39 parse postgresql/05_dialect_specific.sql#40 parse -postgresql/05_dialect_specific.sql#41 parse -postgresql/05_dialect_specific.sql#42 parse postgresql/05_dialect_specific.sql#43 parse -postgresql/05_dialect_specific.sql#46 parse postgresql/05_dialect_specific.sql#47 parse postgresql/05_dialect_specific.sql#49 parse postgresql/05_dialect_specific.sql#51 parse @@ -821,12 +787,9 @@ postgresql/07_admin_ops.sql#8 parse postgresql/07_admin_ops.sql#9 parse postgresql/08_supabase_rls_misc.sql#0 parse postgresql/08_supabase_rls_misc.sql#10 parse -postgresql/08_supabase_rls_misc.sql#16 parse postgresql/08_supabase_rls_misc.sql#17 parse postgresql/08_supabase_rls_misc.sql#26 parse postgresql/08_supabase_rls_misc.sql#28 parse -postgresql/08_supabase_rls_misc.sql#3 parse -postgresql/08_supabase_rls_misc.sql#4 parse postgresql/08_supabase_rls_misc.sql#5 parse postgresql/08_supabase_rls_misc.sql#6 parse snowflake/01_app_crud.sql#10 parse @@ -968,17 +931,16 @@ snowflake/09_dbt_models.sql#13 drop snowflake/09_dbt_models.sql#15 parse snowflake/09_dbt_models.sql#17 parse snowflake/09_dbt_models.sql#2 parse -snowflake/10_complex_nested.sql#12 parse snowflake/10_complex_nested.sql#19 drop snowflake/10_complex_nested.sql#21 drop snowflake/10_complex_nested.sql#22 parse snowflake/10_complex_nested.sql#26 parse snowflake/10_complex_nested.sql#34 parse -snowflake/10_complex_nested.sql#39 parse +snowflake/10_complex_nested.sql#39 idempotent sqlite/01_app_crud.sql#0 drop sqlite/01_app_crud.sql#4 parse sqlite/01_app_crud.sql#7 parse -sqlite/01_app_crud.sql#8 parse +sqlite/01_app_crud.sql#8 idempotent sqlite/02_upsert_replace.sql#0 drop sqlite/02_upsert_replace.sql#13 parse sqlite/02_upsert_replace.sql#16 parse @@ -992,7 +954,6 @@ sqlite/02_upsert_replace.sql#6 parse sqlite/02_upsert_replace.sql#7 parse sqlite/02_upsert_replace.sql#8 parse sqlite/03_ddl_migrations.sql#1 drop -sqlite/03_ddl_migrations.sql#11 parse sqlite/03_ddl_migrations.sql#13 parse sqlite/03_ddl_migrations.sql#15 drop sqlite/03_ddl_migrations.sql#23 parse @@ -1017,18 +978,10 @@ sqlite/03_ddl_migrations.sql#5 parse sqlite/03_ddl_migrations.sql#6 parse sqlite/03_ddl_migrations.sql#7 drop sqlite/03_ddl_migrations.sql#9 reparse -sqlite/05_dialect_specific.sql#0 parse -sqlite/05_dialect_specific.sql#1 parse -sqlite/05_dialect_specific.sql#10 parse -sqlite/05_dialect_specific.sql#11 parse -sqlite/05_dialect_specific.sql#12 parse -sqlite/05_dialect_specific.sql#14 parse -sqlite/05_dialect_specific.sql#15 parse -sqlite/05_dialect_specific.sql#16 parse +sqlite/05_dialect_specific.sql#14 drop +sqlite/05_dialect_specific.sql#15 drop sqlite/05_dialect_specific.sql#17 parse sqlite/05_dialect_specific.sql#18 parse -sqlite/05_dialect_specific.sql#20 parse -sqlite/05_dialect_specific.sql#23 parse sqlite/05_dialect_specific.sql#26 parse sqlite/05_dialect_specific.sql#27 parse sqlite/05_dialect_specific.sql#29 drop @@ -1037,10 +990,6 @@ sqlite/05_dialect_specific.sql#31 parse sqlite/05_dialect_specific.sql#42 parse sqlite/05_dialect_specific.sql#43 parse sqlite/05_dialect_specific.sql#44 parse -sqlite/05_dialect_specific.sql#59 parse -sqlite/05_dialect_specific.sql#61 parse -sqlite/05_dialect_specific.sql#65 parse -sqlite/05_dialect_specific.sql#9 parse sqlite/06_json.sql#16 parse sqlite/06_json.sql#17 parse sqlite/06_json.sql#18 parse @@ -1059,7 +1008,6 @@ sqlite/09_etl_admin.sql#10 parse sqlite/09_etl_admin.sql#17 parse sqlite/09_etl_admin.sql#18 parse sqlite/09_etl_admin.sql#19 parse -sqlite/09_etl_admin.sql#2 parse sqlite/09_etl_admin.sql#28 parse sqlite/09_etl_admin.sql#29 parse sqlite/09_etl_admin.sql#30 parse @@ -1073,8 +1021,6 @@ sqlite/09_etl_admin.sql#39 parse sqlite/09_etl_admin.sql#40 parse sqlite/09_etl_admin.sql#8 parse sqlite/10_mobile_offline_sync.sql#0 drop -sqlite/10_mobile_offline_sync.sql#15 parse -sqlite/10_mobile_offline_sync.sql#16 parse sqlite/10_mobile_offline_sync.sql#18 parse sqlite/10_mobile_offline_sync.sql#20 drop sqlite/10_mobile_offline_sync.sql#21 parse @@ -1084,10 +1030,9 @@ sqlite/10_mobile_offline_sync.sql#6 parse sqlite/10_mobile_offline_sync.sql#7 parse sqlite/10_mobile_offline_sync.sql#8 parse sqlite/11_deep_nesting.sql#14 parse -sqlite/11_deep_nesting.sql#19 parse +sqlite/11_deep_nesting.sql#19 idempotent sqlite/11_deep_nesting.sql#30 parse sqlite/11_deep_nesting.sql#31 parse -sqlite/11_deep_nesting.sql#7 parse sqlite/12_orm_generated.sql#10 parse sqlite/12_orm_generated.sql#11 drop sqlite/12_orm_generated.sql#12 drop From 0c3e6a045f39f4da251ec391ab7d7ac3e17edc78 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 18:14:14 +0530 Subject: [PATCH 04/95] fix(parser): complete DML grammar for UPDATE, DELETE, INSERT, REPLACE and MERGE UPDATE and DELETE read a single table name and then expected SET/WHERE, and the MySQL row limit was parsed but had no AST field, so the formatter silently dropped LIMIT and the reformatted statement touched every row. UPDATE / DELETE: - targets with AS / bare aliases, PostgreSQL ONLY, Oracle derived-table targets and FROM-less DELETE, SQL Server TOP (n) [PERCENT] - MySQL/MariaDB multi-table forms (UPDATE t JOIN u / UPDATE t1, t2, DELETE t1[, t2] FROM ..., DELETE FROM t1, t2 USING ...), SQL Server DELETE t FROM ... and UPDATE ... FROM t JOIN u - DELETE ... USING routed into DeleteStatement.Using - statement modifiers (LOW_PRIORITY, QUICK, IGNORE; SQLite OR ...) - ORDER BY ... LIMIT n stored on the AST and rendered - SET targets: qualified columns, (a, b) tuples, @variables, DEFAULT values - SQL Server OUTPUT [INTO], RETURNING aliases and Oracle RETURNING ... INTO INSERT: MySQL/SQLite/Snowflake modifiers, INTO-less SQL Server/MySQL form, VALUES () and DEFAULT items, MySQL row alias, OVERRIDING SYSTEM|USER VALUE, ON CONFLICT expression targets and partial-index WHERE, OUTPUT ... INTO, ClickHouse SETTINGS / FUNCTION / FORMAT (FORMAT was dropped before), Oracle INSERT ALL / INSERT FIRST. REPLACE gains SELECT, SET and RETURNING forms. MERGE: derived-table sources, DO NOTHING, NOT MATCHED BY TARGET, RETURNING, Oracle UPDATE ... WHERE / DELETE WHERE and INSERT ... WHERE; the formatter now renders the OUTPUT clause it used to drop. New dialect fields live in embedded structs (ast.DMLTargetFields, ast.InsertDialectFields) so single-table code that reads TableName/Alias is unchanged; keyword fields are allow-listed on render and reset by the pool release functions. 91 real-world corpus statements now pass (one moves from parse to the pre-existing INTERVAL drop). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/render.go | 89 +-- pkg/formatter/render_dml.go | 231 ++++++++ pkg/gosqlx/dml_grammar_test.go | 242 ++++++++ pkg/gosqlx/extract.go | 28 +- pkg/sql/ast/ast_clauses.go | 6 + pkg/sql/ast/ast_dml.go | 207 +++++++ pkg/sql/ast/ast_statements.go | 48 +- pkg/sql/ast/dml_fields_test.go | 135 +++++ pkg/sql/ast/keyword_allowlist_632_test.go | 5 +- pkg/sql/ast/pool_dml_release.go | 85 +++ pkg/sql/ast/pool_statement_release.go | 28 + pkg/sql/ast/sql.go | 90 ++- pkg/sql/ast/sql_dml.go | 289 +++++++++ pkg/sql/parser/dml_ast_shape_test.go | 414 +++++++++++++ pkg/sql/parser/dml_common.go | 411 +++++++++++++ pkg/sql/parser/dml_delete.go | 120 +++- pkg/sql/parser/dml_insert.go | 646 ++++++++++++--------- pkg/sql/parser/dml_insert_multi.go | 116 ++++ pkg/sql/parser/dml_merge.go | 97 +++- pkg/sql/parser/dml_replace.go | 97 ++++ pkg/sql/parser/dml_update.go | 146 ++--- pkg/sql/parser/insert_grammar_gaps_test.go | 2 +- pkg/sql/parser/mysql.go | 86 --- testdata/realworld/known_failures.txt | 92 +-- 24 files changed, 3016 insertions(+), 694 deletions(-) create mode 100644 pkg/formatter/render_dml.go create mode 100644 pkg/gosqlx/dml_grammar_test.go create mode 100644 pkg/sql/ast/ast_dml.go create mode 100644 pkg/sql/ast/dml_fields_test.go create mode 100644 pkg/sql/ast/pool_dml_release.go create mode 100644 pkg/sql/ast/sql_dml.go create mode 100644 pkg/sql/parser/dml_ast_shape_test.go create mode 100644 pkg/sql/parser/dml_common.go create mode 100644 pkg/sql/parser/dml_insert_multi.go create mode 100644 pkg/sql/parser/dml_replace.go diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 27e8c32f..4051dd74 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -399,6 +399,13 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { if i == nil { return "" } + if i.MultiTable != nil { + // Oracle INSERT ALL / FIRST: rendered on one line by the AST. + if opts.AddSemicolon { + return i.SQL() + ";" + } + return i.SQL() + } f := newNodeFormatter(opts) sb := f.sb @@ -407,9 +414,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { sb.WriteString(f.clauseSep()) } - sb.WriteString(f.kw("INSERT INTO")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteQualifiedName(i.TableName)) + writeInsertHead(sb, f, i) // MySQL INSERT ... SET form: Columns[k] = Values[0][k]. setForm := i.SetSyntax && i.Query == nil && len(i.Columns) > 0 && @@ -421,11 +426,9 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { sb.WriteString(")") } - if len(i.Output) > 0 && !setForm { - sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("OUTPUT")) - sb.WriteString(" ") - sb.WriteString(exprListSQL(i.Output)) + if !setForm { + writeDMLOutput(sb, f, i.Output, i.OutputInto) + writeInsertBeforeSource(sb, f, i) } if setForm { @@ -457,6 +460,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("DEFAULT VALUES")) } + writeInsertAfterSource(sb, f, i) if i.OnConflict != nil { sb.WriteString(onConflictSQL(i.OnConflict)) @@ -473,12 +477,7 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { sb.WriteString(strings.Join(upds, ", ")) } - if len(i.Returning) > 0 { - sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("RETURNING")) - sb.WriteString(" ") - sb.WriteString(exprListSQL(i.Returning)) - } + writeInsertTail(sb, f, i) if opts.AddSemicolon { sb.WriteString(";") @@ -501,10 +500,11 @@ func renderUpdate(u *ast.UpdateStatement, opts ast.FormatOptions) string { sb.WriteString(f.kw("UPDATE")) sb.WriteString(" ") - sb.WriteString(ast.QuoteQualifiedName(u.TableName)) - if u.Alias != "" { - sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(u.Alias)) + writeDMLModifiers(sb, f, u.Modifiers) + writeDMLTop(sb, f, u.Top) + writeDMLTargets(sb, f, &u.DMLTargetFields, u.TableName, u.Alias) + if len(u.From) == 0 { + writeDMLJoins(sb, f, u.Joins) } sb.WriteString(f.clauseSep()) @@ -515,6 +515,7 @@ func renderUpdate(u *ast.UpdateStatement, opts ast.FormatOptions) string { upds[i] = ast.ColumnRefSQL(upd.Column) + " = " + exprSQL(upd.Value) } sb.WriteString(strings.Join(upds, ", ")) + writeDMLOutput(sb, f, u.Output, u.OutputInto) if len(u.From) > 0 { sb.WriteString(f.clauseSep()) @@ -525,6 +526,7 @@ func renderUpdate(u *ast.UpdateStatement, opts ast.FormatOptions) string { froms[i] = tableRefSQL(&u.From[i], f) } sb.WriteString(strings.Join(froms, ", ")) + writeDMLJoins(sb, f, u.Joins) } if u.Where != nil { @@ -533,13 +535,8 @@ func renderUpdate(u *ast.UpdateStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sb.WriteString(FormatExpression(u.Where, opts)) } - - if len(u.Returning) > 0 { - sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("RETURNING")) - sb.WriteString(" ") - sb.WriteString(exprListSQL(u.Returning)) - } + writeDMLOrderLimit(sb, f, &u.DMLTargetFields) + writeReturning(sb, f, u.Returning, u.ReturningInto) if opts.AddSemicolon { sb.WriteString(";") @@ -560,12 +557,26 @@ func renderDelete(d *ast.DeleteStatement, opts ast.FormatOptions) string { sb.WriteString(f.clauseSep()) } - sb.WriteString(f.kw("DELETE FROM")) + sb.WriteString(f.kw("DELETE")) sb.WriteString(" ") - sb.WriteString(ast.QuoteQualifiedName(d.TableName)) - if d.Alias != "" { + writeDMLModifiers(sb, f, d.Modifiers) + writeDMLTop(sb, f, d.Top) + if len(d.From) == 0 { + sb.WriteString(f.kw("FROM")) sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(d.Alias)) + } + writeDMLTargets(sb, f, &d.DMLTargetFields, d.TableName, d.Alias) + writeDMLOutput(sb, f, d.Output, d.OutputInto) + + if len(d.From) > 0 { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("FROM")) + sb.WriteString(" ") + froms := make([]string, len(d.From)) + for i := range d.From { + froms[i] = tableRefSQL(&d.From[i], f) + } + sb.WriteString(strings.Join(froms, ", ")) } if len(d.Using) > 0 { @@ -578,6 +589,7 @@ func renderDelete(d *ast.DeleteStatement, opts ast.FormatOptions) string { } sb.WriteString(strings.Join(usings, ", ")) } + writeDMLJoins(sb, f, d.Joins) if d.Where != nil { sb.WriteString(f.clauseSep()) @@ -585,13 +597,8 @@ func renderDelete(d *ast.DeleteStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sb.WriteString(FormatExpression(d.Where, opts)) } - - if len(d.Returning) > 0 { - sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("RETURNING")) - sb.WriteString(" ") - sb.WriteString(exprListSQL(d.Returning)) - } + writeDMLOrderLimit(sb, f, &d.DMLTargetFields) + writeReturning(sb, f, d.Returning, d.ReturningInto) if opts.AddSemicolon { sb.WriteString(";") @@ -1090,6 +1097,8 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(f.kw("NOT MATCHED")) case "NOT_MATCHED_BY_SOURCE": sb.WriteString(f.kw("NOT MATCHED BY SOURCE")) + case "NOT_MATCHED_BY_TARGET": + sb.WriteString(f.kw("NOT MATCHED BY TARGET")) default: // An unknown kind is left out: "WHEN THEN" does not parse. sb.WriteString(f.kw(ast.MergeWhenText(when.Type))) @@ -1141,9 +1150,13 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(")") } } + default: + sb.WriteString(f.kw(ast.MergeActionText(when.Action.ActionType))) } + writeMergeActionWhere(sb, f, when.Action) } } + writeMergeTail(sb, f, m) if opts.AddSemicolon { sb.WriteString(";") @@ -1639,6 +1652,10 @@ func onConflictSQL(oc *ast.OnConflict) string { sb.WriteString(ast.ColumnRefListSQL(oc.Target)) sb.WriteString(")") } + if oc.TargetWhere != nil { + sb.WriteString(" WHERE ") + sb.WriteString(exprSQL(oc.TargetWhere)) + } if oc.Constraint != "" { sb.WriteString(" ON CONSTRAINT ") sb.WriteString(ast.QuoteColumnName(oc.Constraint)) diff --git a/pkg/formatter/render_dml.go b/pkg/formatter/render_dml.go new file mode 100644 index 00000000..e28fed6b --- /dev/null +++ b/pkg/formatter/render_dml.go @@ -0,0 +1,231 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// writeDMLModifiers writes each known statement modifier followed by a space. +func writeDMLModifiers(sb *strings.Builder, f *nodeFormatter, mods []string) { + for _, m := range mods { + if t := ast.DMLModifierText(m); t != "" { + sb.WriteString(f.kw(t)) + sb.WriteString(" ") + } + } +} + +// writeDMLTop writes "TOP (n) [PERCENT] "; DML requires the parentheses. +func writeDMLTop(sb *strings.Builder, f *nodeFormatter, top *ast.TopClause) { + if top == nil || top.Count == nil { + return + } + sb.WriteString(f.kw("TOP")) + sb.WriteString(" (") + sb.WriteString(exprSQL(top.Count)) + sb.WriteString(") ") + if top.IsPercent { + sb.WriteString(f.kw("PERCENT")) + sb.WriteString(" ") + } +} + +// writeDMLTargets writes the UPDATE / DELETE target list. +func writeDMLTargets(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetFields, name, alias string) { + if d.Only { + sb.WriteString(f.kw("ONLY")) + sb.WriteString(" ") + } + first := true + if name != "" { + sb.WriteString(ast.QuoteQualifiedName(name)) + if alias != "" { + sb.WriteString(" ") + sb.WriteString(ast.QuoteTableAlias(alias)) + } + first = false + } + for i := range d.Tables { + if !first { + sb.WriteString(", ") + } + sb.WriteString(tableRefSQL(&d.Tables[i], f)) + first = false + } +} + +// writeDMLJoins writes the JOIN clauses of a multi-table UPDATE / DELETE. +func writeDMLJoins(sb *strings.Builder, f *nodeFormatter, joins []ast.JoinClause) { + for i := range joins { + sb.WriteString(f.clauseSep()) + sb.WriteString(joinSQL(&joins[i], f)) + } +} + +// writeDMLOutput writes the SQL Server OUTPUT [INTO] clause. +func writeDMLOutput(sb *strings.Builder, f *nodeFormatter, output []ast.Expression, into *ast.OutputInto) { + if len(output) == 0 { + return + } + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("OUTPUT")) + sb.WriteString(" ") + sb.WriteString(exprListSQL(output)) + if into != nil && into.Target != "" { + sb.WriteString(" ") + sb.WriteString(f.kw("INTO")) + sb.WriteString(" ") + sb.WriteString(ast.QuoteQualifiedName(into.Target)) + if len(into.Columns) > 0 { + sb.WriteString(" (") + sb.WriteString(ast.ColumnRefListSQL(into.Columns)) + sb.WriteString(")") + } + } +} + +// writeDMLOrderLimit writes the single-table ORDER BY ... LIMIT n clauses. +func writeDMLOrderLimit(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetFields) { + if len(d.OrderBy) > 0 { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("ORDER BY")) + sb.WriteString(" ") + sb.WriteString(orderBySQL(d.OrderBy)) + } + if d.Limit != nil { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("LIMIT")) + sb.WriteString(" ") + sb.WriteString(exprSQL(d.Limit)) + } +} + +// writeReturning writes RETURNING items [INTO targets]. +func writeReturning(sb *strings.Builder, f *nodeFormatter, returning, into []ast.Expression) { + if len(returning) == 0 { + return + } + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("RETURNING")) + sb.WriteString(" ") + sb.WriteString(exprListSQL(returning)) + if len(into) > 0 { + sb.WriteString(" ") + sb.WriteString(f.kw("INTO")) + sb.WriteString(" ") + sb.WriteString(exprListSQL(into)) + } +} + +// writeInsertHead writes "INSERT [modifiers] INTO target". +func writeInsertHead(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStatement) { + sb.WriteString(f.kw("INSERT")) + sb.WriteString(" ") + writeDMLModifiers(sb, f, i.Modifiers) + sb.WriteString(f.kw("INTO")) + sb.WriteString(" ") + if i.TableFunction != nil { + sb.WriteString(f.kw("FUNCTION")) + sb.WriteString(" ") + sb.WriteString(exprSQL(i.TableFunction)) + return + } + sb.WriteString(ast.QuoteQualifiedName(i.TableName)) +} + +// writeInsertBeforeSource writes ClickHouse SETTINGS and PostgreSQL +// OVERRIDING ... VALUE, which sit between the column list and the source. +func writeInsertBeforeSource(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStatement) { + if len(i.Settings) > 0 { + sb.WriteString(" ") + sb.WriteString(f.kw("SETTINGS")) + sb.WriteString(" ") + for k := range i.Settings { + if k > 0 { + sb.WriteString(", ") + } + sb.WriteString(i.Settings[k].SQL()) + } + } + if t := ast.OverridingText(i.Overriding); t != "" { + sb.WriteString(" ") + sb.WriteString(f.kw("OVERRIDING " + t + " VALUE")) + } +} + +// writeInsertAfterSource writes the MySQL row alias AS name [(cols)]. +func writeInsertAfterSource(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStatement) { + if i.RowAlias == "" { + return + } + sb.WriteString(" ") + sb.WriteString(f.kw("AS")) + sb.WriteString(" ") + sb.WriteString(ast.QuoteTableAlias(i.RowAlias)) + if len(i.RowAliasColumns) > 0 { + sb.WriteString(" (") + sb.WriteString(quoteColumnNames(i.RowAliasColumns)) + sb.WriteString(")") + } +} + +// writeInsertTail writes RETURNING [INTO] and the ClickHouse FORMAT name. +func writeInsertTail(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStatement) { + writeReturning(sb, f, i.Returning, i.ReturningInto) + if i.DataFormat != "" { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("FORMAT")) + sb.WriteString(" ") + sb.WriteString(formatNameSQL(i.DataFormat)) + } +} + +// formatNameSQL writes a ClickHouse format name (CSV, JSONEachRow, Values) +// bare when it is a plain word and double-quoted otherwise. +func formatNameSQL(name string) string { + for k := 0; k < len(name); k++ { + c := name[k] + if c != '_' && (c < '0' || c > '9') && (c|0x20 < 'a' || c|0x20 > 'z') { + return ast.QuoteName(name) + } + } + return name +} + +// writeMergeTail writes the MERGE OUTPUT and RETURNING clauses. +func writeMergeTail(sb *strings.Builder, f *nodeFormatter, m *ast.MergeStatement) { + writeDMLOutput(sb, f, m.Output, nil) + writeReturning(sb, f, m.Returning, nil) +} + +// writeMergeActionWhere writes the Oracle WHERE / DELETE WHERE predicates of +// a MERGE action. +func writeMergeActionWhere(sb *strings.Builder, f *nodeFormatter, a *ast.MergeAction) { + if a.Where != nil { + sb.WriteString(" ") + sb.WriteString(f.kw("WHERE")) + sb.WriteString(" ") + sb.WriteString(exprSQL(a.Where)) + } + if a.DeleteWhere != nil { + sb.WriteString(" ") + sb.WriteString(f.kw("DELETE WHERE")) + sb.WriteString(" ") + sb.WriteString(exprSQL(a.DeleteWhere)) + } +} diff --git a/pkg/gosqlx/dml_grammar_test.go b/pkg/gosqlx/dml_grammar_test.go new file mode 100644 index 00000000..922eeb7f --- /dev/null +++ b/pkg/gosqlx/dml_grammar_test.go @@ -0,0 +1,242 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx + +import ( + "reflect" + "sort" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// dmlRoundTripCases are the DML grammar forms of UPDATE / DELETE / INSERT / +// REPLACE / MERGE: each must parse in its dialect, format to want with +// FormatAST and with the statement's own SQL(), and re-parse to the same +// text. want differs from sql only by normalization (AS dropped before a +// table alias, INNER added to a plain JOIN, INTO added where optional). +var dmlRoundTripCases = []struct { + name string + dialect keywords.SQLDialect + sql string + want string // "" means want == sql +}{ + // UPDATE / DELETE row limit (the LIMIT used to be parsed and dropped). + {"update limit", keywords.DialectMySQL, "UPDATE t SET a = 1 WHERE b = 2 LIMIT 5", ""}, + {"delete limit", keywords.DialectMySQL, "DELETE FROM t WHERE b = 2 LIMIT 5", ""}, + {"delete limit mariadb", keywords.DialectMariaDB, "DELETE FROM t WHERE b = 2 LIMIT 5", ""}, + {"delete order limit", keywords.DialectMySQL, "DELETE FROM t ORDER BY id LIMIT 5", ""}, + {"update order limit", keywords.DialectMySQL, "UPDATE t SET a = 1 WHERE b = 2 ORDER BY id DESC LIMIT 5", ""}, + {"sqlite delete order limit", keywords.DialectSQLite, "DELETE FROM t WHERE a = 1 ORDER BY id LIMIT 3", ""}, + + // Target aliases. + {"update as alias", keywords.DialectPostgreSQL, "UPDATE t AS x SET a = 1", "UPDATE t x SET a = 1"}, + {"update bare alias", keywords.DialectOracle, "UPDATE t x SET a = 1", ""}, + {"update alias sqlite from", keywords.DialectSQLite, "UPDATE orders AS o SET status = 'vip' FROM customers AS c WHERE c.id = o.customer_id", + "UPDATE orders o SET status = 'vip' FROM customers c WHERE c.id = o.customer_id"}, + {"delete bare alias", keywords.DialectPostgreSQL, "DELETE FROM t x WHERE x.a = 1", ""}, + {"delete as alias", keywords.DialectPostgreSQL, "DELETE FROM t AS x WHERE x.a = 1", "DELETE FROM t x WHERE x.a = 1"}, + {"update only", keywords.DialectPostgreSQL, "UPDATE ONLY t SET x = 1 WHERE d > 0", ""}, + {"delete only", keywords.DialectPostgreSQL, "DELETE FROM ONLY t WHERE a = 1", ""}, + + // DELETE ... USING and multi-table forms. + {"delete using", keywords.DialectPostgreSQL, "DELETE FROM a USING b WHERE a.id = b.id RETURNING a.id", ""}, + {"delete using snowflake", keywords.DialectSnowflake, "DELETE FROM a USING b WHERE a.id = b.id", ""}, + {"mysql delete using join", keywords.DialectMySQL, "DELETE FROM a, b USING a INNER JOIN b ON a.id = b.a_id WHERE a.flag = 0", ""}, + {"mysql delete targets from", keywords.DialectMySQL, "DELETE o FROM o JOIN c ON c.id = o.cid", "DELETE o FROM o INNER JOIN c ON c.id = o.cid"}, + {"mysql delete two targets", keywords.DialectMySQL, "DELETE a, b FROM p a LEFT JOIN m b ON b.pid = a.id WHERE a.k = 'r'", ""}, + {"sqlserver delete target from", keywords.DialectSQLServer, "DELETE o FROM dbo.Orders o LEFT JOIN dbo.Customers c ON c.Id = o.CustomerId WHERE c.Id IS NULL", ""}, + {"oracle delete without from", keywords.DialectOracle, "DELETE employees WHERE employee_id = 999", "DELETE FROM employees WHERE employee_id = 999"}, + {"oracle delete derived", keywords.DialectOracle, "DELETE FROM (SELECT * FROM e WHERE d = 10) WHERE s < 1000", ""}, + + // Multi-table UPDATE. + {"mysql update join", keywords.DialectMySQL, "UPDATE t JOIN u ON t.id = u.id SET t.a = 1", "UPDATE t INNER JOIN u ON t.id = u.id SET t.a = 1"}, + {"mysql update comma", keywords.DialectMySQL, "UPDATE t, u SET t.a = u.b WHERE t.id = u.id", ""}, + {"mysql update join derived", keywords.DialectMySQL, "UPDATE c INNER JOIN (SELECT id, MAX(x) AS m FROM o GROUP BY id) s ON s.id = c.id SET c.m = s.m", ""}, + {"sqlserver update from join", keywords.DialectSQLServer, "UPDATE t SET t.a = s.a FROM dbo.Orders t INNER JOIN dbo.S s ON s.id = t.id WHERE t.b = 1", ""}, + {"postgres update from join", keywords.DialectPostgreSQL, "UPDATE t SET a = s.b FROM s INNER JOIN u ON s.id = u.id WHERE t.id = s.id", ""}, + {"oracle update derived", keywords.DialectOracle, "UPDATE (SELECT e.s AS o, n.s AS n FROM e INNER JOIN n ON e.id = n.id) SET o = n", ""}, + + // Modifiers. + {"insert ignore", keywords.DialectMySQL, "INSERT IGNORE INTO t (a) VALUES (1)", ""}, + {"insert low priority ignore", keywords.DialectMySQL, "INSERT LOW_PRIORITY IGNORE INTO t (a) VALUES (1)", ""}, + {"update modifiers", keywords.DialectMySQL, "UPDATE LOW_PRIORITY IGNORE t SET x = 1", ""}, + {"delete modifiers", keywords.DialectMariaDB, "DELETE LOW_PRIORITY QUICK IGNORE FROM logs WHERE ts < 5", ""}, + {"table named like modifier", keywords.DialectMySQL, "UPDATE ignore SET a = 1", ""}, + {"sqlite insert or replace", keywords.DialectSQLite, "INSERT OR REPLACE INTO t (a) VALUES (1)", ""}, + {"sqlite insert or rollback", keywords.DialectSQLite, "INSERT OR ROLLBACK INTO t (a) VALUES (1)", ""}, + {"sqlite update or ignore", keywords.DialectSQLite, "UPDATE OR IGNORE t SET a = 1 WHERE id = 3", ""}, + {"snowflake insert overwrite", keywords.DialectSnowflake, "INSERT OVERWRITE INTO d SELECT * FROM s", ""}, + {"sqlserver update top", keywords.DialectSQLServer, "UPDATE TOP (500) dbo.Queue SET Status = 1 WHERE Status = 0", ""}, + {"sqlserver delete top percent", keywords.DialectSQLServer, "DELETE TOP (10) PERCENT FROM t", ""}, + {"sqlserver insert without into", keywords.DialectSQLServer, "INSERT t (a) VALUES (1)", "INSERT INTO t (a) VALUES (1)"}, + + // SET list targets and values. + {"set tuple", keywords.DialectPostgreSQL, "UPDATE t SET (a, b) = (1, 2) WHERE id = 5", ""}, + {"set tuple subquery", keywords.DialectOracle, "UPDATE t SET (a, b) = (SELECT x, y FROM u WHERE u.id = t.id) WHERE id = 6", ""}, + {"set qualified target", keywords.DialectSQLServer, "UPDATE o SET o.a = 1 FROM dbo.Orders AS o", "UPDATE o SET o.a = 1 FROM dbo.Orders o"}, + {"set default", keywords.DialectPostgreSQL, "UPDATE t SET a = DEFAULT, b = 2", ""}, + {"set keyword column", keywords.DialectSQLite, "UPDATE OR REPLACE s SET key = 'k2' WHERE id = 1", ""}, + {"values default", keywords.DialectPostgreSQL, "INSERT INTO t (a, b) VALUES (DEFAULT, 1), (2, DEFAULT)", ""}, + {"values empty mysql", keywords.DialectMySQL, "INSERT INTO t () VALUES ()", "INSERT INTO t VALUES ()"}, + {"keyword insert columns", keywords.DialectSQLite, "INSERT INTO settings (key, value) VALUES ('a', '1')", ""}, + + // INSERT variants. + {"mysql row alias", keywords.DialectMySQL, "INSERT INTO t (a) VALUES (1) AS n ON DUPLICATE KEY UPDATE a = n.a", ""}, + {"mysql row alias columns", keywords.DialectMySQL, "INSERT INTO t (a) VALUES (1) AS n (x) ON DUPLICATE KEY UPDATE a = n.x", ""}, + {"on conflict where", keywords.DialectPostgreSQL, "INSERT INTO t (a) VALUES (1) ON CONFLICT (a) WHERE b > 0 DO NOTHING", ""}, + {"on conflict expression", keywords.DialectPostgreSQL, "INSERT INTO t (a) VALUES (1) ON CONFLICT (lower(a)) DO NOTHING", ""}, + {"on conflict keyword column", keywords.DialectSQLite, "INSERT INTO s (key) VALUES ('p') ON CONFLICT (key) DO UPDATE SET key = excluded.key WHERE s.key <> 'x'", ""}, + {"overriding system value", keywords.DialectPostgreSQL, "INSERT INTO t (id, name) OVERRIDING SYSTEM VALUE VALUES (1, 'x')", ""}, + {"overriding user value", keywords.DialectPostgreSQL, "INSERT INTO t (id) OVERRIDING USER VALUE SELECT 1", ""}, + {"returning alias", keywords.DialectPostgreSQL, "INSERT INTO t (a) VALUES (1) RETURNING a AS x, (xmax = 0) AS inserted", ""}, + {"delete returning alias", keywords.DialectPostgreSQL, "DELETE FROM t x WHERE x.c < 1 RETURNING x.id AS deleted_id", ""}, + {"oracle returning into", keywords.DialectOracle, "INSERT INTO t (a) VALUES (1) RETURNING a INTO :x", ""}, + {"oracle update returning into", keywords.DialectOracle, "UPDATE t SET b = b - :amt WHERE id = :id RETURNING b INTO :nb", ""}, + {"oracle delete returning into", keywords.DialectOracle, "DELETE FROM t WHERE d < SYSDATE RETURNING COUNT(*) INTO :cnt", ""}, + {"oracle insert all", keywords.DialectOracle, "INSERT ALL INTO a (x) VALUES (1) INTO b (x) VALUES (2) SELECT 1 FROM dual", ""}, + {"oracle insert all when", keywords.DialectOracle, "INSERT ALL WHEN r = 'E' THEN INTO e VALUES (id, amt) WHEN r = 'W' THEN INTO w VALUES (id, amt) ELSE INTO o VALUES (id, amt) SELECT id, r, amt FROM s", ""}, + {"oracle insert first", keywords.DialectOracle, "INSERT FIRST WHEN amt > 10 THEN INTO big VALUES (id, amt) ELSE INTO small VALUES (id, amt) SELECT id, amt FROM s", ""}, + {"clickhouse insert settings", keywords.DialectClickHouse, "INSERT INTO t (a, b) SETTINGS async_insert = 1, wait_for_async_insert = 0 VALUES (1, 'x')", ""}, + {"clickhouse insert settings format", keywords.DialectClickHouse, "INSERT INTO t SETTINGS async_insert = 1 FORMAT CSV", ""}, + {"clickhouse insert function", keywords.DialectClickHouse, "INSERT INTO FUNCTION s3('https://b.s3.amazonaws.com/o.parquet', 'Parquet') SELECT * FROM events", ""}, + {"clickhouse insert format", keywords.DialectClickHouse, "INSERT INTO t (a) FORMAT JSONEachRow", ""}, + {"clickhouse values format", keywords.DialectClickHouse, "INSERT INTO t VALUES (1) FORMAT Values", ""}, + + // REPLACE. + {"replace select", keywords.DialectMySQL, "REPLACE INTO t (a) SELECT a FROM u", ""}, + {"replace set", keywords.DialectMySQL, "REPLACE t SET a = 1, b = 'x'", "REPLACE INTO t SET a = 1, b = 'x'"}, + {"replace returning", keywords.DialectMariaDB, "REPLACE INTO s (k, v) VALUES ('a', '1') RETURNING k, v", ""}, + {"replace keyword columns", keywords.DialectSQLite, "REPLACE INTO settings (key, value) VALUES ('lang', 'en')", ""}, + + // OUTPUT. + {"delete output", keywords.DialectSQLServer, "DELETE FROM t OUTPUT DELETED.id WHERE a = 1", ""}, + {"delete output into", keywords.DialectSQLServer, "DELETE FROM dbo.O OUTPUT DELETED.Id, DELETED.T INTO dbo.A (Id, T) WHERE c < '2020-01-01'", ""}, + {"update output alias", keywords.DialectSQLServer, "UPDATE dbo.O SET S = 'x' OUTPUT DELETED.S AS OldS, INSERTED.S AS NewS WHERE Id = 1", ""}, + {"insert output into variable", keywords.DialectSQLServer, "INSERT INTO dbo.Log (Msg) OUTPUT INSERTED.Id INTO @Ids (Id) VALUES ('x')", ""}, + {"delete output then from", keywords.DialectSQLServer, "DELETE FROM t OUTPUT deleted.* FROM t JOIN u ON u.id = t.id", "DELETE t OUTPUT deleted.* FROM t INNER JOIN u ON u.id = t.id"}, + + // MERGE. + {"merge derived source", keywords.DialectSnowflake, "MERGE INTO t USING (SELECT k FROM s2) s ON t.k = s.k WHEN NOT MATCHED THEN INSERT (k) VALUES (s.k)", ""}, + {"merge derived source as", keywords.DialectSQLServer, "MERGE INTO dbo.I AS t USING (SELECT Sku, SUM(Qty) AS Qty FROM dbo.R GROUP BY Sku) AS s ON t.Sku = s.Sku WHEN MATCHED THEN UPDATE SET t.OnHand = t.OnHand + s.Qty", + "MERGE INTO dbo.I t USING (SELECT Sku, SUM(Qty) AS Qty FROM dbo.R GROUP BY Sku) s ON t.Sku = s.Sku WHEN MATCHED THEN UPDATE SET t.OnHand = t.OnHand + s.Qty"}, + {"merge do nothing returning", keywords.DialectPostgreSQL, "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DO NOTHING WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id) RETURNING merge_action(), t.*", ""}, + {"merge by target", keywords.DialectSQLServer, "MERGE INTO dbo.T tgt USING dbo.S src ON tgt.Id = src.Id WHEN NOT MATCHED BY TARGET THEN INSERT (Id) VALUES (src.Id) WHEN NOT MATCHED BY SOURCE THEN DELETE", ""}, + {"merge oracle delete where", keywords.DialectOracle, "MERGE INTO t USING s ON (t.a = s.a) WHEN MATCHED THEN UPDATE SET b = s.b DELETE WHERE (s.c = 1)", ""}, + {"merge oracle update and insert where", keywords.DialectOracle, "MERGE INTO e USING u ON (e.id = u.id) WHEN MATCHED THEN UPDATE SET e.s = u.s WHERE e.s <> u.s WHEN NOT MATCHED THEN INSERT (id, s) VALUES (u.id, u.s) WHERE u.s > 0", ""}, + {"merge output", keywords.DialectSQLServer, "MERGE INTO t x USING u y ON x.id = y.id WHEN MATCHED THEN UPDATE SET a = 1 OUTPUT $action, INSERTED.id", ""}, +} + +func TestDMLGrammarRoundTrip(t *testing.T) { + for _, tc := range dmlRoundTripCases { + t.Run(tc.name, func(t *testing.T) { + want := tc.want + if want == "" { + want = tc.sql + } + tree, err := ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatalf("parse %q (%s): %v", tc.sql, tc.dialect, err) + } + defer ast.ReleaseAST(tree) + if len(tree.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(tree.Statements)) + } + if got := FormatAST(tree, WithIndent(0)); got != want { + t.Errorf("FormatAST:\n got: %s\nwant: %s", got, want) + } + sqler, ok := tree.Statements[0].(interface{ SQL() string }) + if !ok { + t.Fatalf("%T has no SQL()", tree.Statements[0]) + } + if got := sqler.SQL(); got != want { + t.Errorf("SQL():\n got: %s\nwant: %s", got, want) + } + // The readable (one clause per line) form must re-parse to itself. + readable := FormatAST(tree) + tree2, err := ParseWithDialect(readable, tc.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", readable, err) + } + defer ast.ReleaseAST(tree2) + if got := FormatAST(tree2); got != readable { + t.Errorf("not idempotent:\n got: %s\nwant: %s", got, readable) + } + if got := FormatAST(tree2, WithIndent(0)); got != want { + t.Errorf("reparsed compact form:\n got: %s\nwant: %s", got, want) + } + }) + } +} + +// TestDMLGrammarDialectGates checks that dialect-specific DML syntax is +// rejected outside its dialects instead of being misread. +func TestDMLGrammarDialectGates(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + }{ + {"order by on delete in postgres", keywords.DialectPostgreSQL, "DELETE FROM t ORDER BY id"}, + {"mysql modifier in postgres", keywords.DialectPostgreSQL, "INSERT IGNORE INTO t (a) VALUES (1)"}, + {"sqlite conflict modifier in mysql", keywords.DialectMySQL, "INSERT OR REPLACE INTO t (a) VALUES (1)"}, + {"multi-table update in postgres", keywords.DialectPostgreSQL, "UPDATE t JOIN u ON t.id = u.id SET a = 1"}, + {"delete without from in postgres", keywords.DialectPostgreSQL, "DELETE t WHERE a = 1"}, + {"insert without into in postgres", keywords.DialectPostgreSQL, "INSERT t (a) VALUES (1)"}, + {"multi-table delete without from", keywords.DialectMySQL, "DELETE a, b WHERE a.id = 1"}, + {"insert all outside oracle", keywords.DialectPostgreSQL, "INSERT ALL INTO a VALUES (1) SELECT 1"}, + {"insert first needs when", keywords.DialectOracle, "INSERT FIRST INTO a VALUES (1) SELECT 1 FROM dual"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := ParseWithDialect(tc.sql, tc.dialect) + if err == nil { + ast.ReleaseAST(tree) + t.Fatalf("expected an error for %q in %s", tc.sql, tc.dialect) + } + }) + } +} + +// TestDMLGrammarExtractTables checks that ExtractTables sees the tables of +// multi-table UPDATE and DELETE statements. +func TestDMLGrammarExtractTables(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + want []string + }{ + {"update join", keywords.DialectMySQL, "UPDATE a JOIN b ON a.id = b.id SET a.x = b.x", []string{"a", "b"}}, + {"update comma", keywords.DialectMySQL, "UPDATE a, b SET a.x = b.x WHERE a.id = b.id", []string{"a", "b"}}, + {"delete targets from", keywords.DialectMySQL, "DELETE o FROM orders o JOIN users u ON u.id = o.uid", []string{"orders", "users"}}, + {"delete using join", keywords.DialectMySQL, "DELETE FROM a USING a JOIN b ON a.id = b.id", []string{"a", "b"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatalf("parse: %v", err) + } + defer ast.ReleaseAST(tree) + got := ExtractTables(tree) + sort.Strings(got) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("ExtractTables = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/gosqlx/extract.go b/pkg/gosqlx/extract.go index b8b78d4e..5544623c 100644 --- a/pkg/gosqlx/extract.go +++ b/pkg/gosqlx/extract.go @@ -372,6 +372,17 @@ func (c *metadataCollector) addTable(name string) { } } +// addDMLTargetTables adds the extra UPDATE / DELETE targets and the joined +// tables of a multi-table statement. +func (c *metadataCollector) addDMLTargetTables(d *ast.DMLTargetFields) { + for i := range d.Tables { + c.addTable(d.Tables[i].Name) + } + for i := range d.Joins { + c.addTable(d.Joins[i].Right.Name) + } +} + func (c *metadataCollector) addColumn(table, name string, want extractKind) { if name == "" || name == "*" { return @@ -447,6 +458,7 @@ func (c *metadataCollector) collectFromNode(node ast.Node) { for _, from := range n.From { c.addTable(from.Name) } + c.addDMLTargetTables(&n.DMLTargetFields) for i := range n.Assignments { c.collectFromNode(&n.Assignments[i]) } @@ -455,10 +467,24 @@ func (c *metadataCollector) collectFromNode(node ast.Node) { c.collectFromNode(n.With) } case *ast.DeleteStatement: - c.addTable(n.TableName) + // In DELETE t1 FROM t1 JOIN t2 the targets name FROM entries + // (often by alias), so the tables are the FROM list. + if len(n.From) == 0 { + c.addTable(n.TableName) + } for _, using := range n.Using { c.addTable(using.Name) } + for _, from := range n.From { + c.addTable(from.Name) + } + if len(n.From) == 0 { + c.addDMLTargetTables(&n.DMLTargetFields) + } else { + for _, j := range n.Joins { + c.addTable(j.Right.Name) + } + } c.collectFromExpression(n.Where, c.want) if n.With != nil { c.collectFromNode(n.With) diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 30f9cacc..caf3e6dd 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -463,6 +463,9 @@ type OnConflict struct { Target []Expression // Target columns Constraint string // Optional constraint name Action OnConflictAction + // TargetWhere is the partial-index predicate of the conflict target: + // ON CONFLICT (a) WHERE b > 0 DO ... + TargetWhere Expression } func (o *OnConflict) expressionNode() {} @@ -477,6 +480,9 @@ func (o OnConflict) Children() []Node { if o.Action.Where != nil { children = append(children, o.Action.Where) } + if o.TargetWhere != nil { + children = append(children, o.TargetWhere) + } return children } diff --git a/pkg/sql/ast/ast_dml.go b/pkg/sql/ast/ast_dml.go new file mode 100644 index 00000000..5a96f09f --- /dev/null +++ b/pkg/sql/ast/ast_dml.go @@ -0,0 +1,207 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +// DMLTargetFields holds the dialect-specific parts of UPDATE and DELETE that +// surround the target table. It is embedded in UpdateStatement and +// DeleteStatement, so its fields are accessed directly (stmt.Limit). +// +// The first target stays in the statement's TableName and Alias when it is a +// plain table name, so code that only looks at TableName keeps working; Tables +// holds every further target (MySQL UPDATE t1, t2 / DELETE t1, t2 FROM ...), +// or all of them when TableName is empty (a derived-table target such as +// Oracle UPDATE (SELECT ...) SET ...). +type DMLTargetFields struct { + // Modifiers are the keywords written between the verb and the target, + // upper-cased and in source order: MySQL LOW_PRIORITY, QUICK, IGNORE; + // SQLite OR ROLLBACK / OR ABORT / OR REPLACE / OR FAIL / OR IGNORE. + // Renderers write only known modifiers (see DMLModifierText). + Modifiers []string + // Top is the SQL Server TOP (n) [PERCENT] row limit. + Top *TopClause + // Only is the PostgreSQL ONLY modifier (UPDATE ONLY t, DELETE FROM ONLY t). + Only bool + // Tables are the targets after TableName/Alias (see the type comment). + Tables []TableReference + // Joins are the JOIN clauses of a multi-table statement: MySQL + // UPDATE t JOIN u ON ... SET, and the joins after DELETE ... FROM or + // DELETE ... USING. + Joins []JoinClause + // Output is the SQL Server OUTPUT list (items may be AliasedExpression). + Output []Expression + // OutputInto is the SQL Server OUTPUT ... INTO target. + OutputInto *OutputInto + // OrderBy and Limit are the MySQL/MariaDB/SQLite ORDER BY ... LIMIT n + // row limit of a single-table UPDATE or DELETE. + OrderBy []OrderByExpression + Limit Expression + // ReturningInto holds the Oracle RETURNING ... INTO :bind targets. + ReturningInto []Expression +} + +// dmlTargetChildren appends the nodes of d to children. +func (d *DMLTargetFields) dmlTargetChildren(children []Node) []Node { + if d.Top != nil { + children = append(children, d.Top) + } + for i := range d.Tables { + children = append(children, &d.Tables[i]) + } + for i := range d.Joins { + children = append(children, &d.Joins[i]) + } + children = append(children, nodifyExpressions(d.Output)...) + if d.OutputInto != nil { + children = append(children, d.OutputInto) + } + for i := range d.OrderBy { + children = append(children, &d.OrderBy[i]) + } + if d.Limit != nil { + children = append(children, d.Limit) + } + children = append(children, nodifyExpressions(d.ReturningInto)...) + return children +} + +// OutputInto is the INTO part of a SQL Server OUTPUT clause: +// OUTPUT inserted.id INTO @ids (id) or INTO dbo.archive (id, total). +type OutputInto struct { + // Target is the table name or table variable (@ids). + Target string + // Columns is the optional column list. + Columns []Expression +} + +func (o *OutputInto) expressionNode() {} +func (o OutputInto) TokenLiteral() string { return "INTO" } +func (o OutputInto) Children() []Node { return nodifyExpressions(o.Columns) } + +// InsertDialectFields holds the dialect-specific parts of INSERT. It is +// embedded in InsertStatement, so its fields are accessed directly. +type InsertDialectFields struct { + // Modifiers are the keywords between INSERT and INTO, upper-cased and in + // source order: MySQL IGNORE / LOW_PRIORITY / HIGH_PRIORITY / DELAYED, + // SQLite OR REPLACE / OR IGNORE / OR ABORT / OR FAIL / OR ROLLBACK, + // Snowflake OVERWRITE. Renderers write only known modifiers. + Modifiers []string + // Overriding is the PostgreSQL OVERRIDING {SYSTEM | USER} VALUE kind: + // "SYSTEM" or "USER". + Overriding string + // RowAlias is the MySQL 8.0.19+ row alias: VALUES (...) AS RowAlias + // [(RowAliasColumns)] ON DUPLICATE KEY UPDATE ... + RowAlias string + RowAliasColumns []string + // OutputInto is the SQL Server OUTPUT ... INTO target. + OutputInto *OutputInto + // ReturningInto holds the Oracle RETURNING ... INTO :bind targets. + ReturningInto []Expression + // TableFunction is the ClickHouse INSERT INTO FUNCTION f(...) target; + // TableName is empty when it is set. + TableFunction *FunctionCall + // Settings is the ClickHouse INSERT ... SETTINGS name = value list. + Settings []QuerySetting + // DataFormat is the ClickHouse FORMAT name of the inserted data + // (INSERT INTO t FORMAT CSV). + DataFormat string + // MultiTable is the Oracle INSERT ALL / INSERT FIRST body; Query holds the + // source subquery and TableName is empty. + MultiTable *MultiTableInsert +} + +// insertDialectChildren appends the nodes of d to children. +func (d *InsertDialectFields) insertDialectChildren(children []Node) []Node { + if d.TableFunction != nil { + children = append(children, d.TableFunction) + } + if d.OutputInto != nil { + children = append(children, d.OutputInto) + } + children = append(children, nodifyExpressions(d.ReturningInto)...) + for i := range d.Settings { + if v := d.Settings[i].Value; v != nil { + children = append(children, v) + } + } + if d.MultiTable != nil { + children = append(children, d.MultiTable) + } + return children +} + +// MultiTableInsert is the Oracle multi-table insert body: +// +// INSERT ALL INTO a VALUES (...) INTO b VALUES (...) SELECT ... +// INSERT {ALL | FIRST} WHEN c THEN INTO a ... [ELSE INTO b ...] SELECT ... +type MultiTableInsert struct { + // First marks INSERT FIRST (otherwise INSERT ALL). + First bool + // Branches are the INTO groups in source order. An unconditional + // INSERT ALL has one branch with a nil When. + Branches []MultiInsertBranch +} + +func (m *MultiTableInsert) expressionNode() {} + +// TokenLiteral returns "INSERT ALL" or "INSERT FIRST". +func (m MultiTableInsert) TokenLiteral() string { + if m.First { + return "INSERT FIRST" + } + return "INSERT ALL" +} + +// Children returns each branch's condition and INTO clauses in order. +func (m MultiTableInsert) Children() []Node { + var children []Node + for i := range m.Branches { + b := &m.Branches[i] + if b.When != nil { + children = append(children, b.When) + } + for j := range b.Into { + children = append(children, nodifyExpressions(b.Into[j].Columns)...) + children = append(children, nodifyExpressions(b.Into[j].Values)...) + } + } + return children +} + +// MultiInsertBranch is one WHEN ... THEN group (or the ELSE group, or the +// unconditional group of INSERT ALL) of a multi-table insert. +type MultiInsertBranch struct { + When Expression // nil for ELSE and for the unconditional form + Else bool + Into []MultiInsertInto +} + +// MultiInsertInto is one INTO table [(cols)] [VALUES (...)] clause of a +// multi-table insert. +type MultiInsertInto struct { + Table string + Columns []Expression + Values []Expression // nil when the clause has no VALUES list +} + +// DefaultExpr is the DEFAULT keyword used as a value: INSERT ... VALUES +// (DEFAULT) and UPDATE ... SET c = DEFAULT. +type DefaultExpr struct{} + +func (d *DefaultExpr) expressionNode() {} +func (d DefaultExpr) TokenLiteral() string { return "DEFAULT" } +func (d DefaultExpr) Children() []Node { return nil } + +// SQL returns "DEFAULT". +func (d *DefaultExpr) SQL() string { return "DEFAULT" } diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index 8eddae16..0798bc79 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -281,6 +281,10 @@ type InsertStatement struct { // The assignments are stored as Columns plus a single Values row // (Columns[i] = Values[0][i]); renderers emit SET when the shapes match. SetSyntax bool + // InsertDialectFields holds the dialect-specific INSERT clauses + // (modifiers, OVERRIDING, row alias, OUTPUT INTO, RETURNING INTO, + // ClickHouse SETTINGS / FUNCTION / FORMAT, Oracle INSERT ALL/FIRST). + InsertDialectFields } func (i *InsertStatement) statementNode() {} @@ -331,7 +335,7 @@ func (i InsertStatement) Children() []Node { if i.OnDuplicateKey != nil { children = append(children, i.OnDuplicateKey) } - return children + return i.insertDialectChildren(children) } // Values represents VALUES clause @@ -359,6 +363,9 @@ type UpdateStatement struct { Where Expression Returning []Expression Pos models.Location // Source position of the UPDATE keyword (1-based line and column) + // DMLTargetFields holds modifiers, TOP, ONLY, extra targets, joins, + // OUTPUT, ORDER BY, LIMIT and RETURNING INTO (see ast_dml.go). + DMLTargetFields } // GetUpdates returns Assignments for backward compatibility. @@ -386,7 +393,7 @@ func (u UpdateStatement) Children() []Node { children = append(children, u.Where) } children = append(children, nodifyExpressions(u.Returning)...) - return children + return u.dmlTargetChildren(children) } // CreateTableStatement represents a CREATE TABLE statement @@ -439,6 +446,13 @@ type DeleteStatement struct { Where Expression Returning []Expression Pos models.Location // Source position of the DELETE keyword (1-based line and column) + // From is the source table list of the multi-table form + // DELETE t1[, t2] FROM t1 JOIN t2 ... (MySQL, SQL Server). When set, the + // targets (TableName/Alias and Tables) are written before FROM. + From []TableReference + // DMLTargetFields holds modifiers, TOP, ONLY, extra targets, joins, + // OUTPUT, ORDER BY, LIMIT and RETURNING INTO (see ast_dml.go). + DMLTargetFields } func (d *DeleteStatement) statementNode() {} @@ -452,11 +466,14 @@ func (d DeleteStatement) Children() []Node { for i := range d.Using { children = append(children, &d.Using[i]) } + for i := range d.From { + children = append(children, &d.From[i]) + } if d.Where != nil { children = append(children, d.Where) } children = append(children, nodifyExpressions(d.Returning)...) - return children + return d.dmlTargetChildren(children) } // AlterTableStatement represents an ALTER TABLE statement. @@ -550,6 +567,7 @@ type MergeStatement struct { OnCondition Expression // The join/match condition WhenClauses []*MergeWhenClause // List of WHEN clauses Output []Expression // SQL Server OUTPUT clause columns + Returning []Expression // PostgreSQL 17 RETURNING list } func (m *MergeStatement) statementNode() {} @@ -582,7 +600,7 @@ func (m *MergeStatement) childrenInPlace() []Node { for _, e := range m.Output { children = append(children, e) } - return children + return append(children, nodifyExpressions(m.Returning)...) } // MergeWhenClause represents a WHEN clause in a MERGE statement @@ -614,6 +632,11 @@ type MergeAction struct { Columns []string // For INSERT: column list Values []Expression // For INSERT: value list DefaultValues bool // For INSERT: use DEFAULT VALUES + // Where is the Oracle action predicate: UPDATE SET ... WHERE cond or + // INSERT ... VALUES (...) WHERE cond. + Where Expression + // DeleteWhere is the Oracle UPDATE SET ... DELETE WHERE cond predicate. + DeleteWhere Expression } func (a *MergeAction) expressionNode() {} @@ -626,6 +649,12 @@ func (a MergeAction) Children() []Node { for _, val := range a.Values { children = append(children, val) } + if a.Where != nil { + children = append(children, a.Where) + } + if a.DeleteWhere != nil { + children = append(children, a.DeleteWhere) + } return children } @@ -796,6 +825,12 @@ type ReplaceStatement struct { TableName string Columns []Expression Values [][]Expression + // Query is the source of REPLACE ... SELECT. + Query QueryExpression + // SetSyntax marks REPLACE t SET col = expr, ... (Columns plus one Values row). + SetSyntax bool + // Returning is the MariaDB RETURNING list. + Returning []Expression } func (r *ReplaceStatement) statementNode() {} @@ -806,7 +841,10 @@ func (r ReplaceStatement) Children() []Node { for _, row := range r.Values { children = append(children, nodifyExpressions(row)...) } - return children + if r.Query != nil { + children = append(children, r.Query) + } + return append(children, nodifyExpressions(r.Returning)...) } // ── MariaDB SEQUENCE DDL (10.3+) ─────────────────────────────────────────── diff --git a/pkg/sql/ast/dml_fields_test.go b/pkg/sql/ast/dml_fields_test.go new file mode 100644 index 00000000..2dfb1ac6 --- /dev/null +++ b/pkg/sql/ast/dml_fields_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "reflect" + "runtime" + "strings" + "testing" +) + +// TestDMLDialectFields_ResetOnPut fills the UPDATE / DELETE / INSERT dialect +// fields, releases the statement and checks that the object the pool hands +// out again carries none of them. +func TestDMLDialectFields_ResetOnPut(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cases := []struct { + name string + fill func() any + reget func() any + check func(v any) bool + }{ + {"Update", func() any { + s := GetUpdateStatement() + fillNonZero(reflect.ValueOf(&s.DMLTargetFields).Elem()) + PutUpdateStatement(s) + return s + }, func() any { return GetUpdateStatement() }, func(v any) bool { + s, ok := v.(*UpdateStatement) + return ok && reflect.ValueOf(s.DMLTargetFields).IsZero() + }}, + {"Delete", func() any { + s := GetDeleteStatement() + fillNonZero(reflect.ValueOf(&s.DMLTargetFields).Elem()) + s.From = []TableReference{{Name: "x"}} + PutDeleteStatement(s) + return s + }, func() any { return GetDeleteStatement() }, func(v any) bool { + s, ok := v.(*DeleteStatement) + return ok && reflect.ValueOf(s.DMLTargetFields).IsZero() && len(s.From) == 0 + }}, + {"Insert", func() any { + s := GetInsertStatement() + fillNonZero(reflect.ValueOf(&s.InsertDialectFields).Elem()) + s.OnConflict = &OnConflict{TargetWhere: &Identifier{Name: "x"}} + PutInsertStatement(s) + return s + }, func() any { return GetInsertStatement() }, func(v any) bool { + s, ok := v.(*InsertStatement) + return ok && reflect.ValueOf(s.InsertDialectFields).IsZero() && s.OnConflict == nil + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for try := 0; try < maxReuseTries; try++ { + put := tc.fill() + got := tc.reget() + if got != put { + continue + } + if !tc.check(got) { + t.Fatalf("%s: dialect fields not reset after Put", tc.name) + } + return + } + t.Skipf("pool never returned the just-released %s", tc.name) + }) + } +} + +// TestDMLRender_UnknownKeywordTextDropped checks that keyword fields set by +// hand to arbitrary text are not written into the SQL. +func TestDMLRender_UnknownKeywordTextDropped(t *testing.T) { + u := &UpdateStatement{ + TableName: "t", + Assignments: []UpdateExpression{{Column: &Identifier{Name: "a"}, Value: &LiteralValue{Value: "1", Type: "int"}}}, + DMLTargetFields: DMLTargetFields{Modifiers: []string{"IGNORE", "; DROP TABLE x; --"}}, + } + if got, want := u.SQL(), "UPDATE IGNORE t SET a = 1"; got != want { + t.Errorf("UPDATE SQL() = %q, want %q", got, want) + } + i := &InsertStatement{ + TableName: "t", + Values: [][]Expression{{&LiteralValue{Value: "1", Type: "int"}}}, + InsertDialectFields: InsertDialectFields{Modifiers: []string{"OR REPLACE", "evil"}, Overriding: "evil"}, + } + if got, want := i.SQL(), "INSERT OR REPLACE INTO t VALUES (1)"; got != want { + t.Errorf("INSERT SQL() = %q, want %q", got, want) + } + i.DataFormat = "x; DROP" + if got := i.SQL(); !strings.HasSuffix(got, ` FORMAT "x; DROP"`) { + t.Errorf("FORMAT name not quoted: %q", got) + } +} + +// TestDMLChildren_IncludeDialectFields checks that visitors reach the +// expressions stored in the new DML fields. +func TestDMLChildren_IncludeDialectFields(t *testing.T) { + limit := &LiteralValue{Value: "5", Type: "int"} + into := &Identifier{Name: "v"} + u := &UpdateStatement{ + TableName: "t", + DMLTargetFields: DMLTargetFields{Limit: limit, ReturningInto: []Expression{into}}, + } + children := u.Children() + found := 0 + for _, c := range children { + if c == Node(limit) || c == Node(into) { + found++ + } + } + if found != 2 { + t.Errorf("UpdateStatement.Children reached %d of 2 dialect-field expressions", found) + } + + where := &Identifier{Name: "w"} + m := &MergeAction{ActionType: "UPDATE", Where: where} + if kids := m.Children(); len(kids) != 1 || kids[0] != Node(where) { + t.Errorf("MergeAction.Children = %v, want the WHERE predicate", kids) + } +} diff --git a/pkg/sql/ast/keyword_allowlist_632_test.go b/pkg/sql/ast/keyword_allowlist_632_test.go index 382765d4..844c8d6d 100644 --- a/pkg/sql/ast/keyword_allowlist_632_test.go +++ b/pkg/sql/ast/keyword_allowlist_632_test.go @@ -49,6 +49,8 @@ var keywordFieldLiterals = []string{ "SELECT a FROM t MINUS SELECT b FROM u", "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET a = s.a WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id)", "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY SOURCE THEN DELETE", + "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DO NOTHING WHEN NOT MATCHED BY TARGET THEN INSERT (id) VALUES (s.id)", + "INSERT INTO t (a) OVERRIDING SYSTEM VALUE VALUES (1)", "INSERT INTO t (a) OVERRIDING USER VALUE VALUES (1)", "CREATE TABLE t (a INT NOT NULL, b INT NULL, c INT UNIQUE, d INT PRIMARY KEY, e INT DEFAULT 1, f INT CHECK (f > 0), g INT REFERENCES r (x), h INT AUTO_INCREMENT)", "CREATE TABLE t (a INT, PRIMARY KEY (a), UNIQUE (a), FOREIGN KEY (a) REFERENCES r (x), CHECK (a > 0))", "CREATE TABLE t (a INT) PARTITION BY RANGE (a)", "CREATE TABLE t (a INT) PARTITION BY LIST (a)", @@ -103,7 +105,7 @@ var keywordFieldGates = map[string]func(string) bool{ "ShowStatement.ShowType": func(v string) bool { return v == "" || ast.IsShowTypeText(v) }, "SetOperation.Operator": func(v string) bool { return ast.SetOperatorText(v) == v }, "MergeWhenClause.Type": func(v string) bool { - return v == "MATCHED" || v == "NOT_MATCHED" || v == "NOT_MATCHED_BY_SOURCE" || ast.MergeWhenText(v) == v + return v == "MATCHED" || v == "NOT_MATCHED" || v == "NOT_MATCHED_BY_SOURCE" || v == "NOT_MATCHED_BY_TARGET" || ast.MergeWhenText(v) == v }, "MergeAction.ActionType": func(v string) bool { return v == "UPDATE" || v == "INSERT" || ast.MergeActionText(v) == v @@ -117,6 +119,7 @@ var keywordFieldGates = map[string]func(string) bool{ "PartitionBy.Type": func(v string) bool { return ast.PartitionByText(v) == v }, "TableOption.Name": func(v string) bool { return ast.TableOptionNameText(v) == v }, "CreateViewStatement.WithOption": func(v string) bool { return v == "" || ast.ViewWithOptionText(v) == v }, + "InsertDialectFields.Overriding": func(v string) bool { return v == "" || ast.OverridingText(v) == v }, "BinaryExpression.Operator": ast.IsOperatorText, "AnyExpression.Operator": ast.IsOperatorText, "AllExpression.Operator": ast.IsOperatorText, diff --git a/pkg/sql/ast/pool_dml_release.go b/pkg/sql/ast/pool_dml_release.go new file mode 100644 index 00000000..6f40653f --- /dev/null +++ b/pkg/sql/ast/pool_dml_release.go @@ -0,0 +1,85 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +// releaseDMLTargetFields releases the expressions reachable from the UPDATE / +// DELETE dialect fields and zeroes them. +func releaseDMLTargetFields(d *DMLTargetFields, mode releaseMode) { + if d.Top != nil { + releaseExpression(d.Top.Count, mode) + } + for i := range d.Tables { + releaseTableReference(&d.Tables[i], mode) + } + // Join Lefts are value copies of a target (see releaseSelectStatement); + // only Right, Condition and Using are owned by the join. + for i := range d.Joins { + releaseTableReference(&d.Joins[i].Right, mode) + releaseExpression(d.Joins[i].Condition, mode) + for _, u := range d.Joins[i].Using { + releaseExpression(u, mode) + } + } + for _, e := range d.Output { + releaseExpression(e, mode) + } + if d.OutputInto != nil { + for _, e := range d.OutputInto.Columns { + releaseExpression(e, mode) + } + } + for i := range d.OrderBy { + releaseExpression(d.OrderBy[i].Expression, mode) + } + releaseExpression(d.Limit, mode) + for _, e := range d.ReturningInto { + releaseExpression(e, mode) + } + *d = DMLTargetFields{} +} + +// releaseInsertDialectFields releases the expressions reachable from the +// INSERT dialect fields and zeroes them. +func releaseInsertDialectFields(d *InsertDialectFields, mode releaseMode) { + if d.OutputInto != nil { + for _, e := range d.OutputInto.Columns { + releaseExpression(e, mode) + } + } + for _, e := range d.ReturningInto { + releaseExpression(e, mode) + } + if d.TableFunction != nil { + releaseExpression(d.TableFunction, mode) + } + for i := range d.Settings { + releaseExpression(d.Settings[i].Value, mode) + } + if m := d.MultiTable; m != nil { + for i := range m.Branches { + b := &m.Branches[i] + releaseExpression(b.When, mode) + for j := range b.Into { + for _, e := range b.Into[j].Columns { + releaseExpression(e, mode) + } + for _, e := range b.Into[j].Values { + releaseExpression(e, mode) + } + } + } + } + *d = InsertDialectFields{} +} diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index ae6980c3..8f79f901 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -113,6 +113,8 @@ func releaseInsertStatement(stmt *InsertStatement, mode releaseMode) { stmt.OnConflict.Action.DoUpdate = nil releaseExpression(stmt.OnConflict.Action.Where, mode) stmt.OnConflict.Action.Where = nil + releaseExpression(stmt.OnConflict.TargetWhere, mode) + stmt.OnConflict.TargetWhere = nil stmt.OnConflict = nil } @@ -132,6 +134,7 @@ func releaseInsertStatement(stmt *InsertStatement, mode releaseMode) { stmt.Pos = models.Location{} stmt.DefaultValues = false stmt.SetSyntax = false + releaseInsertDialectFields(&stmt.InsertDialectFields, mode) // Return to pool if mode == releaseAll { @@ -209,6 +212,7 @@ func releaseUpdateStatement(stmt *UpdateStatement, mode releaseMode) { stmt.TableName = "" stmt.Alias = "" stmt.Pos = models.Location{} + releaseDMLTargetFields(&stmt.DMLTargetFields, mode) // Return to pool if mode == releaseAll { @@ -276,6 +280,11 @@ func releaseDeleteStatement(stmt *DeleteStatement, mode releaseMode) { stmt.TableName = "" stmt.Alias = "" stmt.Pos = models.Location{} + for i := range stmt.From { + releaseTableReference(&stmt.From[i], mode) + } + stmt.From = nil + releaseDMLTargetFields(&stmt.DMLTargetFields, mode) // Return to pool if mode == releaseAll { @@ -787,6 +796,10 @@ func PutMergeStatement(stmt *MergeStatement) { stmt.WhenClauses[i].Action.Columns = stmt.WhenClauses[i].Action.Columns[:0] stmt.WhenClauses[i].Action.ActionType = "" stmt.WhenClauses[i].Action.DefaultValues = false + PutExpression(stmt.WhenClauses[i].Action.Where) + stmt.WhenClauses[i].Action.Where = nil + PutExpression(stmt.WhenClauses[i].Action.DeleteWhere) + stmt.WhenClauses[i].Action.DeleteWhere = nil stmt.WhenClauses[i].Action = nil } stmt.WhenClauses[i].Type = "" @@ -800,6 +813,11 @@ func PutMergeStatement(stmt *MergeStatement) { stmt.Output[i] = nil } stmt.Output = stmt.Output[:0] + for i, expr := range stmt.Returning { + PutExpression(expr) + stmt.Returning[i] = nil + } + stmt.Returning = stmt.Returning[:0] // Reset TargetTable / SourceTable (value types - zero them out) stmt.TargetTable = TableReference{} @@ -840,6 +858,16 @@ func PutReplaceStatement(stmt *ReplaceStatement) { } stmt.Values = stmt.Values[:0] + if stmt.Query != nil { + releaseStatement(stmt.Query, releaseAll) + stmt.Query = nil + } + for i := range stmt.Returning { + PutExpression(stmt.Returning[i]) + stmt.Returning[i] = nil + } + stmt.Returning = stmt.Returning[:0] + stmt.SetSyntax = false stmt.TableName = "" replaceStmtPool.Put(stmt) diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 7372fb1b..d037f7e3 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1318,8 +1318,11 @@ func (i *InsertStatement) writeSQL(buf *bytes.Buffer) { buf.WriteByte(' ') } - buf.WriteString("INSERT INTO ") - writeQualifiedName(buf, i.TableName) + if i.MultiTable != nil { + i.writeMultiTableInsert(buf) + return + } + i.writeInsertHead(buf) if i.isSetForm() { buf.WriteString(" SET ") @@ -1338,10 +1341,8 @@ func (i *InsertStatement) writeSQL(buf *bytes.Buffer) { buf.WriteByte(')') } - if len(i.Output) > 0 { - buf.WriteString(" OUTPUT ") - writeExprList(buf, i.Output) - } + writeDMLOutput(buf, i.Output, i.OutputInto) + i.writeInsertBeforeSource(buf) if i.Query != nil { buf.WriteByte(' ') @@ -1353,6 +1354,7 @@ func (i *InsertStatement) writeSQL(buf *bytes.Buffer) { buf.WriteString(" DEFAULT VALUES") } } + i.writeInsertRowAlias(buf) if i.OnConflict != nil { writeOnConflict(buf, i.OnConflict) @@ -1363,10 +1365,8 @@ func (i *InsertStatement) writeSQL(buf *bytes.Buffer) { writeAssignments(buf, i.OnDuplicateKey.Updates) } - if len(i.Returning) > 0 { - buf.WriteString(" RETURNING ") - writeExprList(buf, i.Returning) - } + writeReturning(buf, i.Returning, i.ReturningInto) + i.writeInsertFormat(buf) } // SQL returns the full SQL string for this UPDATE statement, including the @@ -1393,29 +1393,29 @@ func (u *UpdateStatement) writeSQL(buf *bytes.Buffer) { } buf.WriteString("UPDATE ") - writeQualifiedName(buf, u.TableName) - if u.Alias != "" { - buf.WriteByte(' ') - writeTableAlias(buf, u.Alias) + writeDMLModifiers(buf, u.Modifiers) + writeDMLTop(buf, u.Top) + u.writeDMLTargets(buf, u.TableName, u.Alias) + if len(u.From) == 0 { + writeDMLJoins(buf, u.Joins) } buf.WriteString(" SET ") writeAssignments(buf, u.Assignments) + writeDMLOutput(buf, u.Output, u.OutputInto) if len(u.From) > 0 { buf.WriteString(" FROM ") writeTableRefList(buf, u.From) + writeDMLJoins(buf, u.Joins) } if u.Where != nil { buf.WriteString(" WHERE ") writeExpr(buf, u.Where) } - - if len(u.Returning) > 0 { - buf.WriteString(" RETURNING ") - writeExprList(buf, u.Returning) - } + u.writeDMLOrderLimit(buf) + writeReturning(buf, u.Returning, u.ReturningInto) } // SQL returns the full SQL string for this DELETE statement, including the @@ -1440,27 +1440,31 @@ func (d *DeleteStatement) writeSQL(buf *bytes.Buffer) { buf.WriteByte(' ') } - buf.WriteString("DELETE FROM ") - writeQualifiedName(buf, d.TableName) - if d.Alias != "" { - buf.WriteByte(' ') - writeTableAlias(buf, d.Alias) + buf.WriteString("DELETE ") + writeDMLModifiers(buf, d.Modifiers) + writeDMLTop(buf, d.Top) + if len(d.From) == 0 { + buf.WriteString("FROM ") } + d.writeDMLTargets(buf, d.TableName, d.Alias) + writeDMLOutput(buf, d.Output, d.OutputInto) + if len(d.From) > 0 { + buf.WriteString(" FROM ") + writeTableRefList(buf, d.From) + } if len(d.Using) > 0 { buf.WriteString(" USING ") writeTableRefList(buf, d.Using) } + writeDMLJoins(buf, d.Joins) if d.Where != nil { buf.WriteString(" WHERE ") writeExpr(buf, d.Where) } - - if len(d.Returning) > 0 { - buf.WriteString(" RETURNING ") - writeExprList(buf, d.Returning) - } + d.writeDMLOrderLimit(buf) + writeReturning(buf, d.Returning, d.ReturningInto) } // SQL returns the full SQL string for this CREATE TABLE statement including @@ -1846,6 +1850,8 @@ func (m *MergeStatement) SQL() string { sb.WriteString("NOT MATCHED") case "NOT_MATCHED_BY_SOURCE": sb.WriteString("NOT MATCHED BY SOURCE") + case "NOT_MATCHED_BY_TARGET": + sb.WriteString("NOT MATCHED BY TARGET") default: // An unknown kind is left out: "WHEN THEN" does not parse. sb.WriteString(MergeWhenText(when.Type)) @@ -1857,8 +1863,10 @@ func (m *MergeStatement) SQL() string { sb.WriteString(" THEN ") if when.Action != nil { sb.WriteString(mergeActionSQL(when.Action)) + sb.WriteString(mergeActionWhereSQL(when.Action)) } } + m.writeMergeTail(sb) return sb.String() } @@ -2378,6 +2386,10 @@ func writeOnConflict(buf *bytes.Buffer, oc *OnConflict) { writeColumnRefList(buf, oc.Target) buf.WriteByte(')') } + if oc.TargetWhere != nil { + buf.WriteString(" WHERE ") + writeExpr(buf, oc.TargetWhere) + } if oc.Constraint != "" { buf.WriteString(" ON CONSTRAINT ") writeColumnName(buf, oc.Constraint) @@ -2793,25 +2805,7 @@ func (r *ReplaceStatement) SQL() string { } sb := getBuf() defer putBuf(sb) - sb.WriteString("REPLACE INTO ") - writeQualifiedName(sb, r.TableName) - if len(r.Columns) > 0 { - sb.WriteString(" (") - sb.WriteString(exprListSQL(r.Columns)) - sb.WriteString(")") - } - if len(r.Values) > 0 { - sb.WriteString(" VALUES ") - rows := make([]string, len(r.Values)) - for idx, row := range r.Values { - vals := make([]string, len(row)) - for j, v := range row { - vals[j] = exprSQL(v) - } - rows[idx] = "(" + strings.Join(vals, ", ") + ")" - } - sb.WriteString(strings.Join(rows, ", ")) - } + r.writeReplaceSQL(sb) return sb.String() } diff --git a/pkg/sql/ast/sql_dml.go b/pkg/sql/ast/sql_dml.go new file mode 100644 index 00000000..8513fb00 --- /dev/null +++ b/pkg/sql/ast/sql_dml.go @@ -0,0 +1,289 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "bytes" + +// dmlModifierKeywords are the statement modifiers the parser stores in +// DMLTargetFields.Modifiers and InsertDialectFields.Modifiers. +var dmlModifierKeywords = []string{ + "LOW_PRIORITY", "HIGH_PRIORITY", "DELAYED", "QUICK", "IGNORE", "OVERWRITE", + "OR REPLACE", "OR IGNORE", "OR ABORT", "OR FAIL", "OR ROLLBACK", +} + +// DMLModifierText returns s when it is a known INSERT / UPDATE / DELETE +// modifier (LOW_PRIORITY, IGNORE, OR REPLACE, OVERWRITE, ...; any case) and +// "" otherwise. Renderers drop an unknown modifier instead of writing +// arbitrary text into the statement. +func DMLModifierText(s string) string { return keywordOneOf(s, dmlModifierKeywords) } + +// OverridingText returns s when it is SYSTEM or USER (any case), the kinds +// of PostgreSQL OVERRIDING ... VALUE, and "" otherwise. +func OverridingText(s string) string { return keywordOneOf(s, overridingKeywords) } + +var overridingKeywords = []string{"SYSTEM", "USER"} + +// writeDMLModifiers writes each known modifier followed by a space. +func writeDMLModifiers(buf *bytes.Buffer, mods []string) { + for _, m := range mods { + if t := DMLModifierText(m); t != "" { + buf.WriteString(t) + buf.WriteByte(' ') + } + } +} + +// writeDMLTop writes "TOP (n) [PERCENT] " (DML requires the parentheses). +func writeDMLTop(buf *bytes.Buffer, top *TopClause) { + if top == nil || top.Count == nil { + return + } + buf.WriteString("TOP (") + writeExpr(buf, top.Count) + buf.WriteByte(')') + if top.IsPercent { + buf.WriteString(" PERCENT") + } + buf.WriteByte(' ') +} + +// writeDMLTargets writes the target list: name [alias] followed by Tables. +func (d *DMLTargetFields) writeDMLTargets(buf *bytes.Buffer, name, alias string) { + if d.Only { + buf.WriteString("ONLY ") + } + first := true + if name != "" { + writeQualifiedName(buf, name) + if alias != "" { + buf.WriteByte(' ') + writeTableAlias(buf, alias) + } + first = false + } + for i := range d.Tables { + if !first { + buf.WriteString(", ") + } + writeTableRef(buf, &d.Tables[i]) + first = false + } +} + +// writeDMLJoins writes " JOIN ..." for every join. +func writeDMLJoins(buf *bytes.Buffer, joins []JoinClause) { + for i := range joins { + buf.WriteByte(' ') + writeJoin(buf, &joins[i]) + } +} + +// writeDMLOutput writes " OUTPUT items [INTO target [(cols)]]". +func writeDMLOutput(buf *bytes.Buffer, output []Expression, into *OutputInto) { + if len(output) == 0 { + return + } + buf.WriteString(" OUTPUT ") + writeExprList(buf, output) + if into != nil && into.Target != "" { + buf.WriteString(" INTO ") + writeQualifiedName(buf, into.Target) + if len(into.Columns) > 0 { + buf.WriteString(" (") + writeColumnRefList(buf, into.Columns) + buf.WriteByte(')') + } + } +} + +// writeDMLOrderLimit writes " ORDER BY ... LIMIT n". +func (d *DMLTargetFields) writeDMLOrderLimit(buf *bytes.Buffer) { + if len(d.OrderBy) > 0 { + buf.WriteString(" ORDER BY ") + writeOrderBy(buf, d.OrderBy) + } + if d.Limit != nil { + buf.WriteString(" LIMIT ") + writeExpr(buf, d.Limit) + } +} + +// writeReturning writes " RETURNING items [INTO targets]". +func writeReturning(buf *bytes.Buffer, returning, into []Expression) { + if len(returning) == 0 { + return + } + buf.WriteString(" RETURNING ") + writeExprList(buf, returning) + if len(into) > 0 { + buf.WriteString(" INTO ") + writeExprList(buf, into) + } +} + +// writeInsertHead writes "INSERT [modifiers] INTO target" for an INSERT +// statement, including the ClickHouse FUNCTION target. +func (i *InsertStatement) writeInsertHead(buf *bytes.Buffer) { + buf.WriteString("INSERT ") + writeDMLModifiers(buf, i.Modifiers) + buf.WriteString("INTO ") + if i.TableFunction != nil { + buf.WriteString("FUNCTION ") + i.TableFunction.writeSQL(buf) + return + } + writeQualifiedName(buf, i.TableName) +} + +// writeInsertBeforeSource writes the clauses between the column list and the +// row source: ClickHouse SETTINGS and PostgreSQL OVERRIDING ... VALUE. +func (i *InsertStatement) writeInsertBeforeSource(buf *bytes.Buffer) { + if len(i.Settings) > 0 { + buf.WriteString(" SETTINGS ") + writeQuerySettings(buf, i.Settings) + } + if t := OverridingText(i.Overriding); t != "" { + buf.WriteString(" OVERRIDING ") + buf.WriteString(t) + buf.WriteString(" VALUE") + } +} + +// writeInsertRowAlias writes the MySQL row alias " AS name [(cols)]". +func (i *InsertStatement) writeInsertRowAlias(buf *bytes.Buffer) { + if i.RowAlias == "" { + return + } + buf.WriteString(" AS ") + writeTableAlias(buf, i.RowAlias) + if len(i.RowAliasColumns) > 0 { + buf.WriteString(" (") + writeColumnNameList(buf, i.RowAliasColumns) + buf.WriteByte(')') + } +} + +// writeInsertFormat writes the ClickHouse " FORMAT name" suffix. +func (i *InsertStatement) writeInsertFormat(buf *bytes.Buffer) { + if i.DataFormat != "" { + buf.WriteString(" FORMAT ") + writeSafeIdentifier(buf, i.DataFormat) + } +} + +// writeMultiTableInsert writes an Oracle INSERT ALL / INSERT FIRST statement. +func (i *InsertStatement) writeMultiTableInsert(buf *bytes.Buffer) { + m := i.MultiTable + if m.First { + buf.WriteString("INSERT FIRST") + } else { + buf.WriteString("INSERT ALL") + } + for bi := range m.Branches { + b := &m.Branches[bi] + switch { + case b.Else: + buf.WriteString(" ELSE") + case b.When != nil: + buf.WriteString(" WHEN ") + writeExpr(buf, b.When) + buf.WriteString(" THEN") + } + for j := range b.Into { + in := &b.Into[j] + buf.WriteString(" INTO ") + writeQualifiedName(buf, in.Table) + if len(in.Columns) > 0 { + buf.WriteString(" (") + writeColumnRefList(buf, in.Columns) + buf.WriteByte(')') + } + if in.Values != nil { + buf.WriteString(" VALUES (") + writeExprList(buf, in.Values) + buf.WriteByte(')') + } + } + } + if i.Query != nil { + buf.WriteByte(' ') + writeStmt(buf, i.Query) + } +} + +// writeMergeTail writes the MERGE OUTPUT and RETURNING clauses. +func (m *MergeStatement) writeMergeTail(buf *bytes.Buffer) { + writeDMLOutput(buf, m.Output, nil) + writeReturning(buf, m.Returning, nil) +} + +// mergeActionWhereSQL returns the Oracle " WHERE c" and " DELETE WHERE c" +// action predicates. +func mergeActionWhereSQL(a *MergeAction) string { + if a.Where == nil && a.DeleteWhere == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + if a.Where != nil { + buf.WriteString(" WHERE ") + writeExpr(buf, a.Where) + } + if a.DeleteWhere != nil { + buf.WriteString(" DELETE WHERE ") + writeExpr(buf, a.DeleteWhere) + } + return buf.String() +} + +// MergeActionWhereSQL returns the Oracle WHERE / DELETE WHERE predicates of +// a MERGE action, each with a leading space, or "" when there are none. +func MergeActionWhereSQL(a *MergeAction) string { + if a == nil { + return "" + } + return mergeActionWhereSQL(a) +} + +// writeReplaceSQL writes a REPLACE statement in the form it was parsed. +func (r *ReplaceStatement) writeReplaceSQL(buf *bytes.Buffer) { + buf.WriteString("REPLACE INTO ") + writeQualifiedName(buf, r.TableName) + if r.SetSyntax && r.Query == nil && len(r.Values) == 1 && len(r.Values[0]) == len(r.Columns) && len(r.Columns) > 0 { + buf.WriteString(" SET ") + for idx, col := range r.Columns { + if idx > 0 { + buf.WriteString(", ") + } + writeColumnRef(buf, col) + buf.WriteString(" = ") + writeExpr(buf, r.Values[0][idx]) + } + } else { + if len(r.Columns) > 0 { + buf.WriteString(" (") + writeColumnRefList(buf, r.Columns) + buf.WriteByte(')') + } + if r.Query != nil { + buf.WriteByte(' ') + writeStmt(buf, r.Query) + } else if len(r.Values) > 0 { + buf.WriteString(" VALUES ") + writeValueRows(buf, r.Values) + } + } + writeReturning(buf, r.Returning, nil) +} diff --git a/pkg/sql/parser/dml_ast_shape_test.go b/pkg/sql/parser/dml_ast_shape_test.go new file mode 100644 index 00000000..381f4ffe --- /dev/null +++ b/pkg/sql/parser/dml_ast_shape_test.go @@ -0,0 +1,414 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "reflect" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// parseDMLShape parses sql in dialect and returns its only statement. The +// returned AST must be released by the caller. +func parseDMLShape(t *testing.T, sql string, dialect keywords.SQLDialect) (ast.Statement, *ast.AST) { + t.Helper() + tree, err := ParseWithDialect(sql, dialect) + if err != nil { + t.Fatalf("ParseWithDialect(%q, %s): %v", sql, dialect, err) + } + if len(tree.Statements) != 1 { + ast.ReleaseAST(tree) + t.Fatalf("expected 1 statement for %q, got %d", sql, len(tree.Statements)) + } + return tree.Statements[0], tree +} + +func TestDMLShape_Update(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + check func(t *testing.T, u *ast.UpdateStatement) + }{ + {"limit is stored", keywords.DialectMySQL, "UPDATE t SET a = 1 WHERE b = 2 ORDER BY id LIMIT 5", + func(t *testing.T, u *ast.UpdateStatement) { + lit, ok := u.Limit.(*ast.LiteralValue) + if !ok { + t.Fatalf("Limit: expected *ast.LiteralValue, got %T", u.Limit) + } + if lit.Value != "5" { + t.Errorf("Limit = %v, want 5", lit.Value) + } + if len(u.OrderBy) != 1 { + t.Errorf("OrderBy: got %d items, want 1", len(u.OrderBy)) + } + }}, + {"alias", keywords.DialectPostgreSQL, "UPDATE t AS x SET a = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if u.TableName != "t" || u.Alias != "x" { + t.Errorf("target = %q %q, want t x", u.TableName, u.Alias) + } + if len(u.Tables) != 0 { + t.Errorf("Tables should be empty for a single plain target, got %d", len(u.Tables)) + } + }}, + {"modifiers", keywords.DialectMySQL, "UPDATE LOW_PRIORITY IGNORE t SET x = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if !reflect.DeepEqual(u.Modifiers, []string{"LOW_PRIORITY", "IGNORE"}) { + t.Errorf("Modifiers = %v", u.Modifiers) + } + }}, + {"sqlite or modifier", keywords.DialectSQLite, "UPDATE OR IGNORE t SET a = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if !reflect.DeepEqual(u.Modifiers, []string{"OR IGNORE"}) { + t.Errorf("Modifiers = %v", u.Modifiers) + } + }}, + {"joins", keywords.DialectMySQL, "UPDATE t JOIN u ON t.id = u.id SET t.a = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if len(u.Joins) != 1 || u.Joins[0].Right.Name != "u" { + t.Fatalf("Joins = %+v", u.Joins) + } + col, ok := u.Assignments[0].Column.(*ast.Identifier) + if !ok { + t.Fatalf("Column: expected *ast.Identifier, got %T", u.Assignments[0].Column) + } + if col.Table != "t" || col.Name != "a" { + t.Errorf("Column = %s.%s, want t.a", col.Table, col.Name) + } + }}, + {"comma targets", keywords.DialectMySQL, "UPDATE t1, t2 x SET t1.a = x.b", + func(t *testing.T, u *ast.UpdateStatement) { + if u.TableName != "t1" || len(u.Tables) != 1 || u.Tables[0].Name != "t2" || u.Tables[0].Alias != "x" { + t.Errorf("targets = %q + %+v", u.TableName, u.Tables) + } + }}, + {"derived target", keywords.DialectOracle, "UPDATE (SELECT a FROM t) SET a = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if u.TableName != "" || len(u.Tables) != 1 || u.Tables[0].Subquery == nil { + t.Errorf("derived target not stored in Tables: %q %+v", u.TableName, u.Tables) + } + }}, + {"top and only", keywords.DialectSQLServer, "UPDATE TOP (5) t SET a = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if u.Top == nil || u.Top.Count == nil { + t.Fatal("Top not stored") + } + }}, + {"only", keywords.DialectPostgreSQL, "UPDATE ONLY t SET a = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if !u.Only || u.TableName != "t" { + t.Errorf("Only = %v, TableName = %q", u.Only, u.TableName) + } + }}, + {"tuple target and default", keywords.DialectPostgreSQL, "UPDATE t SET (a, b) = (1, 2), c = DEFAULT", + func(t *testing.T, u *ast.UpdateStatement) { + tuple, ok := u.Assignments[0].Column.(*ast.TupleExpression) + if !ok { + t.Fatalf("Column: expected *ast.TupleExpression, got %T", u.Assignments[0].Column) + } + if len(tuple.Expressions) != 2 { + t.Errorf("tuple has %d columns, want 2", len(tuple.Expressions)) + } + if _, ok := u.Assignments[1].Value.(*ast.DefaultExpr); !ok { + t.Errorf("Value: expected *ast.DefaultExpr, got %T", u.Assignments[1].Value) + } + }}, + {"output into", keywords.DialectSQLServer, "UPDATE t SET a = 1 OUTPUT inserted.a AS na INTO @v (a) WHERE b = 1", + func(t *testing.T, u *ast.UpdateStatement) { + if len(u.Output) != 1 { + t.Fatalf("Output = %v", u.Output) + } + if _, ok := u.Output[0].(*ast.AliasedExpression); !ok { + t.Errorf("Output[0]: expected *ast.AliasedExpression, got %T", u.Output[0]) + } + if u.OutputInto == nil || u.OutputInto.Target != "@v" || len(u.OutputInto.Columns) != 1 { + t.Errorf("OutputInto = %+v", u.OutputInto) + } + if u.Where == nil { + t.Error("Where lost after OUTPUT") + } + }}, + {"returning into", keywords.DialectOracle, "UPDATE t SET a = 1 RETURNING a INTO :x", + func(t *testing.T, u *ast.UpdateStatement) { + if len(u.Returning) != 1 || len(u.ReturningInto) != 1 { + t.Errorf("Returning = %v, ReturningInto = %v", u.Returning, u.ReturningInto) + } + }}, + {"from with joins", keywords.DialectSQLServer, "UPDATE t SET a = 1 FROM t JOIN u ON u.id = t.id", + func(t *testing.T, u *ast.UpdateStatement) { + if len(u.From) != 1 || len(u.Joins) != 1 { + t.Errorf("From = %d, Joins = %d; want 1, 1", len(u.From), len(u.Joins)) + } + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stmt, tree := parseDMLShape(t, tc.sql, tc.dialect) + defer ast.ReleaseAST(tree) + u, ok := stmt.(*ast.UpdateStatement) + if !ok { + t.Fatalf("expected *ast.UpdateStatement, got %T", stmt) + } + tc.check(t, u) + }) + } +} + +func TestDMLShape_Delete(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + check func(t *testing.T, d *ast.DeleteStatement) + }{ + {"limit", keywords.DialectMySQL, "DELETE FROM t WHERE a = 1 LIMIT 10", + func(t *testing.T, d *ast.DeleteStatement) { + if d.Limit == nil { + t.Error("Limit not stored") + } + }}, + {"using", keywords.DialectPostgreSQL, "DELETE FROM a USING b, c WHERE a.id = b.id", + func(t *testing.T, d *ast.DeleteStatement) { + if len(d.Using) != 2 || d.Using[0].Name != "b" { + t.Errorf("Using = %+v", d.Using) + } + }}, + {"mysql multi-table", keywords.DialectMySQL, "DELETE a, b FROM a JOIN b ON a.id = b.id", + func(t *testing.T, d *ast.DeleteStatement) { + if d.TableName != "a" || len(d.Tables) != 1 || d.Tables[0].Name != "b" { + t.Errorf("targets = %q + %+v", d.TableName, d.Tables) + } + if len(d.From) != 1 || d.From[0].Name != "a" || len(d.Joins) != 1 { + t.Errorf("From = %+v, Joins = %d", d.From, len(d.Joins)) + } + }}, + {"modifiers", keywords.DialectMySQL, "DELETE LOW_PRIORITY QUICK IGNORE FROM t", + func(t *testing.T, d *ast.DeleteStatement) { + if !reflect.DeepEqual(d.Modifiers, []string{"LOW_PRIORITY", "QUICK", "IGNORE"}) { + t.Errorf("Modifiers = %v", d.Modifiers) + } + }}, + {"top output", keywords.DialectSQLServer, "DELETE TOP (3) FROM t OUTPUT deleted.id WHERE a = 1", + func(t *testing.T, d *ast.DeleteStatement) { + if d.Top == nil || len(d.Output) != 1 || d.Where == nil { + t.Errorf("Top = %v, Output = %v, Where = %v", d.Top, d.Output, d.Where) + } + }}, + {"oracle without from", keywords.DialectOracle, "DELETE t x WHERE x.a = 1", + func(t *testing.T, d *ast.DeleteStatement) { + if d.TableName != "t" || d.Alias != "x" || len(d.From) != 0 { + t.Errorf("target = %q %q, From = %v", d.TableName, d.Alias, d.From) + } + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stmt, tree := parseDMLShape(t, tc.sql, tc.dialect) + defer ast.ReleaseAST(tree) + d, ok := stmt.(*ast.DeleteStatement) + if !ok { + t.Fatalf("expected *ast.DeleteStatement, got %T", stmt) + } + tc.check(t, d) + }) + } +} + +func TestDMLShape_Insert(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + check func(t *testing.T, i *ast.InsertStatement) + }{ + {"modifiers", keywords.DialectMySQL, "INSERT IGNORE INTO t (a) VALUES (1)", + func(t *testing.T, i *ast.InsertStatement) { + if !reflect.DeepEqual(i.Modifiers, []string{"IGNORE"}) || i.TableName != "t" { + t.Errorf("Modifiers = %v, TableName = %q", i.Modifiers, i.TableName) + } + }}, + {"sqlite or replace", keywords.DialectSQLite, "INSERT OR REPLACE INTO t (a) VALUES (1)", + func(t *testing.T, i *ast.InsertStatement) { + if !reflect.DeepEqual(i.Modifiers, []string{"OR REPLACE"}) { + t.Errorf("Modifiers = %v", i.Modifiers) + } + }}, + {"default values items", keywords.DialectPostgreSQL, "INSERT INTO t (a, b) VALUES (DEFAULT, 1)", + func(t *testing.T, i *ast.InsertStatement) { + if _, ok := i.Values[0][0].(*ast.DefaultExpr); !ok { + t.Errorf("Values[0][0]: expected *ast.DefaultExpr, got %T", i.Values[0][0]) + } + }}, + {"empty row", keywords.DialectMySQL, "INSERT INTO t VALUES ()", + func(t *testing.T, i *ast.InsertStatement) { + if len(i.Values) != 1 || len(i.Values[0]) != 0 { + t.Errorf("Values = %v, want one empty row", i.Values) + } + }}, + {"row alias", keywords.DialectMySQL, "INSERT INTO t (a) VALUES (1) AS n (x) ON DUPLICATE KEY UPDATE a = n.x", + func(t *testing.T, i *ast.InsertStatement) { + if i.RowAlias != "n" || !reflect.DeepEqual(i.RowAliasColumns, []string{"x"}) || i.OnDuplicateKey == nil { + t.Errorf("RowAlias = %q %v, OnDuplicateKey = %v", i.RowAlias, i.RowAliasColumns, i.OnDuplicateKey) + } + }}, + {"on conflict target where", keywords.DialectPostgreSQL, "INSERT INTO t (a) VALUES (1) ON CONFLICT (lower(a)) WHERE b > 0 DO NOTHING", + func(t *testing.T, i *ast.InsertStatement) { + if i.OnConflict == nil || i.OnConflict.TargetWhere == nil || !i.OnConflict.Action.DoNothing { + t.Fatalf("OnConflict = %+v", i.OnConflict) + } + if _, ok := i.OnConflict.Target[0].(*ast.FunctionCall); !ok { + t.Errorf("Target[0]: expected *ast.FunctionCall, got %T", i.OnConflict.Target[0]) + } + }}, + {"overriding", keywords.DialectPostgreSQL, "INSERT INTO t (a) OVERRIDING SYSTEM VALUE VALUES (1)", + func(t *testing.T, i *ast.InsertStatement) { + if i.Overriding != "SYSTEM" { + t.Errorf("Overriding = %q", i.Overriding) + } + }}, + {"returning alias into", keywords.DialectOracle, "INSERT INTO t (a) VALUES (1) RETURNING a AS x INTO :v", + func(t *testing.T, i *ast.InsertStatement) { + if len(i.Returning) != 1 || len(i.ReturningInto) != 1 { + t.Fatalf("Returning = %v, ReturningInto = %v", i.Returning, i.ReturningInto) + } + if _, ok := i.Returning[0].(*ast.AliasedExpression); !ok { + t.Errorf("Returning[0]: expected *ast.AliasedExpression, got %T", i.Returning[0]) + } + }}, + {"output into", keywords.DialectSQLServer, "INSERT INTO t (a) OUTPUT inserted.id INTO @ids (id) VALUES (1)", + func(t *testing.T, i *ast.InsertStatement) { + if len(i.Output) != 1 || i.OutputInto == nil || i.OutputInto.Target != "@ids" { + t.Errorf("Output = %v, OutputInto = %+v", i.Output, i.OutputInto) + } + }}, + {"clickhouse settings function format", keywords.DialectClickHouse, "INSERT INTO FUNCTION s3('x') SETTINGS a = 1 FORMAT CSV", + func(t *testing.T, i *ast.InsertStatement) { + if i.TableFunction == nil || i.TableName != "" { + t.Errorf("TableFunction = %v, TableName = %q", i.TableFunction, i.TableName) + } + if len(i.Settings) != 1 || i.Settings[0].Name != "a" { + t.Errorf("Settings = %+v", i.Settings) + } + if i.DataFormat != "CSV" { + t.Errorf("DataFormat = %q", i.DataFormat) + } + }}, + {"oracle insert first", keywords.DialectOracle, "INSERT FIRST WHEN a > 1 THEN INTO x VALUES (a) INTO y (b) VALUES (a) ELSE INTO z SELECT a FROM s", + func(t *testing.T, i *ast.InsertStatement) { + m := i.MultiTable + if m == nil || !m.First || len(m.Branches) != 2 { + t.Fatalf("MultiTable = %+v", m) + } + if m.Branches[0].When == nil || len(m.Branches[0].Into) != 2 { + t.Errorf("first branch = %+v", m.Branches[0]) + } + if !m.Branches[1].Else || m.Branches[1].Into[0].Values != nil { + t.Errorf("ELSE branch = %+v", m.Branches[1]) + } + if i.Query == nil { + t.Error("Query not stored") + } + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stmt, tree := parseDMLShape(t, tc.sql, tc.dialect) + defer ast.ReleaseAST(tree) + i, ok := stmt.(*ast.InsertStatement) + if !ok { + t.Fatalf("expected *ast.InsertStatement, got %T", stmt) + } + tc.check(t, i) + }) + } +} + +func TestDMLShape_MergeAndReplace(t *testing.T) { + t.Run("merge derived source and oracle predicates", func(t *testing.T) { + stmt, tree := parseDMLShape(t, + "MERGE INTO t USING (SELECT a, b FROM s) src ON (t.a = src.a) WHEN MATCHED THEN UPDATE SET t.b = src.b WHERE t.b <> src.b DELETE WHERE src.b IS NULL", + keywords.DialectOracle) + defer ast.ReleaseAST(tree) + m, ok := stmt.(*ast.MergeStatement) + if !ok { + t.Fatalf("expected *ast.MergeStatement, got %T", stmt) + } + if m.SourceTable.Subquery == nil || m.SourceAlias != "src" || m.SourceTable.Alias != "" { + t.Errorf("source = %+v alias %q", m.SourceTable, m.SourceAlias) + } + a := m.WhenClauses[0].Action + if a.Where == nil || a.DeleteWhere == nil { + t.Errorf("Where = %v, DeleteWhere = %v", a.Where, a.DeleteWhere) + } + }) + t.Run("merge do nothing, by target, returning", func(t *testing.T) { + stmt, tree := parseDMLShape(t, + "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DO NOTHING WHEN NOT MATCHED BY TARGET THEN INSERT (id) VALUES (s.id) RETURNING t.id", + keywords.DialectPostgreSQL) + defer ast.ReleaseAST(tree) + m, ok := stmt.(*ast.MergeStatement) + if !ok { + t.Fatalf("expected *ast.MergeStatement, got %T", stmt) + } + if m.WhenClauses[0].Action.ActionType != "DO NOTHING" { + t.Errorf("ActionType = %q", m.WhenClauses[0].Action.ActionType) + } + if m.WhenClauses[1].Type != "NOT_MATCHED_BY_TARGET" { + t.Errorf("Type = %q", m.WhenClauses[1].Type) + } + if len(m.Returning) != 1 { + t.Errorf("Returning = %v", m.Returning) + } + }) + replaceTests := []struct { + name string + dialect keywords.SQLDialect + sql string + check func(t *testing.T, r *ast.ReplaceStatement) + }{ + {"select", keywords.DialectMySQL, "REPLACE INTO t (a) SELECT a FROM u", + func(t *testing.T, r *ast.ReplaceStatement) { + if r.Query == nil || len(r.Values) != 0 { + t.Errorf("Query = %v, Values = %v", r.Query, r.Values) + } + }}, + {"set", keywords.DialectMySQL, "REPLACE t SET a = 1", + func(t *testing.T, r *ast.ReplaceStatement) { + if !r.SetSyntax || len(r.Columns) != 1 || len(r.Values) != 1 { + t.Errorf("SetSyntax = %v, Columns = %v, Values = %v", r.SetSyntax, r.Columns, r.Values) + } + }}, + {"returning", keywords.DialectMariaDB, "REPLACE INTO t (a) VALUES (1) RETURNING a", + func(t *testing.T, r *ast.ReplaceStatement) { + if len(r.Returning) != 1 { + t.Errorf("Returning = %v", r.Returning) + } + }}, + } + for _, tc := range replaceTests { + t.Run("replace "+tc.name, func(t *testing.T) { + stmt, tree := parseDMLShape(t, tc.sql, tc.dialect) + defer ast.ReleaseAST(tree) + r, ok := stmt.(*ast.ReplaceStatement) + if !ok { + t.Fatalf("expected *ast.ReplaceStatement, got %T", stmt) + } + tc.check(t, r) + }) + } +} diff --git a/pkg/sql/parser/dml_common.go b/pkg/sql/parser/dml_common.go new file mode 100644 index 00000000..b45062b0 --- /dev/null +++ b/pkg/sql/parser/dml_common.go @@ -0,0 +1,411 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - dml_common.go +// Clauses shared by INSERT, UPDATE, DELETE and MERGE: statement modifiers, +// UPDATE/DELETE targets, SET assignments, OUTPUT, RETURNING and the +// single-table ORDER BY ... LIMIT row limit. + +package parser + +import ( + "fmt" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// dmlVerb selects the modifier set accepted by parseDMLModifiers. +type dmlVerb int + +const ( + dmlInsert dmlVerb = iota + dmlUpdate + dmlDelete +) + +// mysqlDMLModifiers lists the MySQL/MariaDB modifiers per statement verb. +var mysqlDMLModifiers = [...][]string{ + dmlInsert: {"LOW_PRIORITY", "HIGH_PRIORITY", "DELAYED", "IGNORE"}, + dmlUpdate: {"LOW_PRIORITY", "IGNORE"}, + dmlDelete: {"LOW_PRIORITY", "QUICK", "IGNORE"}, +} + +// sqliteConflictModifiers maps the SQLite OR keyword to the +// stored modifier. +var sqliteConflictModifiers = [...][2]string{ + {"ROLLBACK", "OR ROLLBACK"}, + {"ABORT", "OR ABORT"}, + {"REPLACE", "OR REPLACE"}, + {"FAIL", "OR FAIL"}, + {"IGNORE", "OR IGNORE"}, +} + +// isMySQLFamily reports whether the dialect is MySQL or MariaDB. +func (p *Parser) isMySQLFamily() bool { + return p.dialect == string(keywords.DialectMySQL) || p.isMariaDB() +} + +// parseDMLModifiers consumes the dialect's statement modifiers written +// between INSERT/UPDATE/DELETE and the target (or INTO / FROM) and returns +// them upper-cased in source order. +func (p *Parser) parseDMLModifiers(verb dmlVerb) []string { + var mods []string + for { + m := p.dmlModifierAt(verb) + if m == "" { + return mods + } + mods = append(mods, m) + } +} + +// dmlModifierAt consumes and returns one modifier at the current token, or +// returns "" without consuming anything. +func (p *Parser) dmlModifierAt(verb dmlVerb) string { + switch { + case p.isMySQLFamily(): + if !p.isType(models.TokenTypeIdentifier) && !p.isType(models.TokenTypeKeyword) { + return "" + } + // A modifier is never followed by '.', ',' or AS; those make the + // word a table name (UPDATE ignore SET ..., DELETE quick.t ...). + switch p.peekToken().Token.Type { + case models.TokenTypePeriod, models.TokenTypeComma, models.TokenTypeAs, models.TokenTypeSet: + return "" + } + for _, m := range mysqlDMLModifiers[verb] { + if equalsUpper(p.currentToken.Token.Value, m) { + p.advance() + return m + } + } + case p.dialect == string(keywords.DialectSQLite): + if verb == dmlDelete || !p.isType(models.TokenTypeOr) { + return "" + } + next := p.peekToken().Token.Value + for _, m := range sqliteConflictModifiers { + if equalsUpper(next, m[0]) { + p.advance() // OR + p.advance() // resolution + return m[1] + } + } + case p.dialect == string(keywords.DialectSnowflake): + if verb == dmlInsert && equalsUpper(p.currentToken.Token.Value, "OVERWRITE") && + p.peekToken().Token.Type == models.TokenTypeInto { + p.advance() + return "OVERWRITE" + } + } + return "" +} + +// isDMLOnly reports whether the current token is the PostgreSQL ONLY +// modifier before an UPDATE/DELETE target. +func (p *Parser) isDMLOnly() bool { + if p.dialect != "" && p.dialect != string(keywords.DialectPostgreSQL) { + return false + } + if !equalsUpper(p.currentToken.Token.Value, "ONLY") || p.isStringLiteral() { + return false + } + next := p.peekToken().Token.Type + return next == models.TokenTypeIdentifier || next == models.TokenTypeDoubleQuotedString +} + +// isDMLOutputKeyword reports whether the current token is an unquoted SQL +// Server OUTPUT keyword. +func (p *Parser) isDMLOutputKeyword() bool { + return p.dialect == string(keywords.DialectSQLServer) && + p.isType(models.TokenTypeIdentifier) && + equalsUpper(p.currentToken.Token.Value, "OUTPUT") && + !p.isQuotedIdentifierToken() +} + +// isDMLTargetAlias reports whether the current token is a bare alias after +// an UPDATE/DELETE target name rather than the clause that follows it. +func (p *Parser) isDMLTargetAlias() bool { + if !p.isIdentifier() { + return false + } + return !p.isReturningKeyword() && !p.isDMLOutputKeyword() +} + +// parseDMLTargetRef parses one UPDATE/DELETE target: a possibly qualified +// table name with an optional alias, or a derived table (Oracle +// UPDATE (SELECT ...) SET, DELETE FROM (SELECT ...)). +func (p *Parser) parseDMLTargetRef() (ast.TableReference, error) { + if p.isType(models.TokenTypeLParen) { + return p.parseFromTableReference() + } + name, err := p.parseQualifiedName() + if err != nil { + return ast.TableReference{}, p.qualifiedNameError(err, "table name") + } + ref := ast.TableReference{Name: name} + if p.isType(models.TokenTypeAs) { + p.advance() // Consume AS + if !p.isIdentifier() { + return ref, p.expectedError("alias after AS") + } + ref.Alias = p.currentToken.Token.Value + p.advance() + } else if p.isDMLTargetAlias() { + ref.Alias = p.currentToken.Token.Value + p.advance() + } + return ref, nil +} + +// parseDMLTargets parses the target list of UPDATE/DELETE. The first target +// is stored in *name / *alias when it is a plain table name and in d.Tables +// otherwise; further comma-separated targets (multi=true, MySQL multi-table +// forms) go to d.Tables. +func (p *Parser) parseDMLTargets(name, alias *string, d *ast.DMLTargetFields, multi bool) (ast.TableReference, error) { + first, err := p.parseDMLTargetRef() + if err != nil { + return first, err + } + if first.Subquery == nil && first.SetSubquery == nil && first.TableFunc == nil { + *name, *alias = first.Name, first.Alias + } else { + d.Tables = append(d.Tables, first) + } + for multi && p.isType(models.TokenTypeComma) { + p.advance() // Consume , + ref, err := p.parseDMLTargetRef() + if err != nil { + return first, err + } + d.Tables = append(d.Tables, ref) + } + return first, nil +} + +// isWordToken reports whether the current token is a bare word (identifier +// or keyword), as opposed to a literal, operator or punctuation. +func (p *Parser) isWordToken() bool { + if p.isIdentifier() { + return true + } + if p.isStringLiteral() || p.isType(models.TokenTypeNumber) { + return false + } + v := p.currentToken.Token.Value + if v == "" { + return false + } + c := v[0] + return c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') +} + +// parseAssignmentTarget parses the left side of a SET assignment: a column, +// a qualified column (t.c), a column tuple ((a, b)) or a variable (@v). +func (p *Parser) parseAssignmentTarget() (ast.Expression, error) { + if p.isType(models.TokenTypeLParen) { + p.advance() // Consume ( + tuple := &ast.TupleExpression{} + for { + col, err := p.parseAssignmentColumn() + if err != nil { + return nil, err + } + tuple.Expressions = append(tuple.Expressions, col) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // Consume , + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume ) + return tuple, nil + } + if p.isType(models.TokenTypePlaceholder) { + // SQL Server / MySQL variable target: SET @v = expr. + return p.parsePrimaryExpression() + } + return p.parseAssignmentColumn() +} + +// parseAssignmentColumn parses a possibly qualified column name. Keyword +// words are accepted as names here (SET key = ..., SET status = ...) since an +// assignment target can only be a column. +func (p *Parser) parseAssignmentColumn() (ast.Expression, error) { + if !p.isWordToken() { + return nil, p.expectedError("column name") + } + ident := &ast.Identifier{ + Name: p.currentToken.Token.Value, + Pos: p.currentLocation(), + Quoted: p.isType(models.TokenTypeDoubleQuotedString), + } + p.advance() + for p.isType(models.TokenTypePeriod) { + p.advance() // Consume . + if !p.isWordToken() { + return nil, p.expectedError("column name after .") + } + if ident.Table == "" { + ident.Table = ident.Name + } else { + ident.Table += "." + ident.Name + } + ident.Name = p.currentToken.Token.Value + ident.Quoted = p.isType(models.TokenTypeDoubleQuotedString) + p.advance() + } + return ident, nil +} + +// parseDMLValue parses a value in a SET assignment or VALUES row, accepting +// the DEFAULT keyword. +func (p *Parser) parseDMLValue() (ast.Expression, error) { + if p.isType(models.TokenTypeDefault) { + switch p.peekToken().Token.Type { + case models.TokenTypeLParen, models.TokenTypePeriod: + // DEFAULT(col) or a column named default: an expression. + default: + p.advance() // Consume DEFAULT + return &ast.DefaultExpr{}, nil + } + } + return p.parseExpression() +} + +// parseAssignments parses "target = value [, ...]" (the SET list of UPDATE, +// ON CONFLICT DO UPDATE and ON DUPLICATE KEY UPDATE). context names the +// clause in errors. +func (p *Parser) parseAssignments(context string) ([]ast.UpdateExpression, error) { + var updates []ast.UpdateExpression + for { + col, err := p.parseAssignmentTarget() + if err != nil { + return nil, err + } + if !p.isType(models.TokenTypeEq) { + return nil, p.expectedError("=") + } + p.advance() // Consume = + value, err := p.parseDMLValue() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse %s value: %v", context, err), + p.currentLocation(), + "", + ).WithCause(err) + } + updates = append(updates, ast.UpdateExpression{Column: col, Value: value}) + if !p.isType(models.TokenTypeComma) { + return updates, nil + } + p.advance() // Consume , + } +} + +// parseDMLOutputClause parses the SQL Server OUTPUT clause; the current token +// must be OUTPUT. Items may carry aliases; an INTO target with an optional +// column list may follow. +func (p *Parser) parseDMLOutputClause() ([]ast.Expression, *ast.OutputInto, error) { + p.advance() // Consume OUTPUT + items, err := p.parseSelectColumnList() + if err != nil { + return nil, nil, err + } + if !p.isType(models.TokenTypeInto) { + return items, nil, nil + } + p.advance() // Consume INTO + into := &ast.OutputInto{} + if p.isType(models.TokenTypePlaceholder) { + into.Target = p.currentToken.Token.Value + p.advance() + } else { + name, err := p.parseQualifiedName() + if err != nil { + return nil, nil, p.qualifiedNameError(err, "OUTPUT INTO target") + } + into.Target = name + } + if p.isType(models.TokenTypeLParen) && p.peekToken().Token.Type != models.TokenTypeSelect { + cols, err := p.parseInsertColumnList() + if err != nil { + return nil, nil, err + } + into.Columns = cols + } + return items, into, nil +} + +// parseReturningClause parses "RETURNING items [INTO targets]"; the current +// token must be RETURNING. Items may carry aliases (RETURNING id AS new_id). +func (p *Parser) parseReturningClause() ([]ast.Expression, []ast.Expression, error) { + p.advance() // Consume RETURNING + items, err := p.parseSelectColumnList() + if err != nil { + return nil, nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse RETURNING column: %v", err), + p.currentLocation(), + "", + ).WithCause(err) + } + if !p.isType(models.TokenTypeInto) { + return items, nil, nil + } + p.advance() // Consume INTO + var into []ast.Expression + for { + target, err := p.parsePrimaryExpression() + if err != nil { + return nil, nil, err + } + into = append(into, target) + if !p.isType(models.TokenTypeComma) { + return items, into, nil + } + p.advance() // Consume , + } +} + +// parseDMLOrderLimit parses the single-table row limit of UPDATE / DELETE: +// [ORDER BY ...] [LIMIT n] (MySQL, MariaDB, SQLite). LIMIT is accepted in +// every dialect, as before; ORDER BY only where the dialect has it. +func (p *Parser) parseDMLOrderLimit(d *ast.DMLTargetFields) error { + if p.isType(models.TokenTypeOrder) && + (p.isMySQLFamily() || p.dialect == string(keywords.DialectSQLite)) { + orderBy, err := p.parseOrderByClause() + if err != nil { + return err + } + d.OrderBy = orderBy + } + if p.isType(models.TokenTypeLimit) { + p.advance() // Consume LIMIT + limit, err := p.parsePrimaryExpression() + if err != nil { + return goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse LIMIT value: %v", err), + p.currentLocation(), + "", + ).WithCause(err) + } + d.Limit = limit + } + return nil +} diff --git a/pkg/sql/parser/dml_delete.go b/pkg/sql/parser/dml_delete.go index 9f0616cd..fcf1bc3d 100644 --- a/pkg/sql/parser/dml_delete.go +++ b/pkg/sql/parser/dml_delete.go @@ -20,58 +20,118 @@ package parser import ( "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" ) -// parseDeleteStatement parses a DELETE statement +// parseDeleteStatement parses a DELETE statement: +// +// DELETE [modifiers] [TOP (n)] FROM [ONLY] target [alias] [OUTPUT ...] +// [USING sources [JOIN ...]] [WHERE ...] [ORDER BY ...] [LIMIT n] +// [RETURNING ... [INTO ...]] +// DELETE [modifiers] target [, target] [OUTPUT ...] FROM sources [JOIN ...] ... +// (MySQL/MariaDB/SQL Server multi-table) +// DELETE target [alias] WHERE ... (Oracle / SQL Server: FROM is optional) func (p *Parser) parseDeleteStatement() (ast.Statement, error) { // We've already consumed the DELETE token in matchType + stmt := &ast.DeleteStatement{} + stmt.Modifiers = p.parseDMLModifiers(dmlDelete) + top, err := p.parseTopClause() + if err != nil { + return nil, err + } + stmt.Top = top - // Parse FROM - if !p.isType(models.TokenTypeFrom) { - return nil, p.expectedError("FROM") + sqlServer := p.dialect == string(keywords.DialectSQLServer) + if p.isType(models.TokenTypeFrom) { + p.advance() // Consume FROM + if p.isDMLOnly() { + stmt.Only = true + p.advance() // Consume ONLY + } + // MySQL: DELETE FROM t1, t2 USING t1 JOIN t2 ... + if _, err := p.parseDMLTargets(&stmt.TableName, &stmt.Alias, &stmt.DMLTargetFields, p.isMySQLFamily()); err != nil { + return nil, err + } + } else { + multiFrom := p.isMySQLFamily() || sqlServer + if !multiFrom && p.dialect != string(keywords.DialectOracle) { + return nil, p.expectedError("FROM") + } + if _, err := p.parseDMLTargets(&stmt.TableName, &stmt.Alias, &stmt.DMLTargetFields, p.isMySQLFamily()); err != nil { + return nil, err + } + if p.isDMLOutputKeyword() { + stmt.Output, stmt.OutputInto, err = p.parseDMLOutputClause() + if err != nil { + return nil, err + } + } + if multiFrom && p.isType(models.TokenTypeFrom) { + if err := p.parseDeleteSources(&stmt.From, stmt); err != nil { + return nil, err + } + } else if len(stmt.Tables) > 0 && stmt.TableName != "" { + // DELETE t1, t2 needs a FROM clause naming the source tables. + return nil, p.expectedError("FROM") + } } - p.advance() // Consume FROM - // Parse table name (supports schema.table qualification and double-quoted identifiers) - tableName, err := p.parseQualifiedName() - if err != nil { - return nil, p.qualifiedNameError(err, "table name") + // SQL Server: DELETE FROM t OUTPUT ... [FROM t JOIN ...] + if len(stmt.Output) == 0 && p.isDMLOutputKeyword() { + stmt.Output, stmt.OutputInto, err = p.parseDMLOutputClause() + if err != nil { + return nil, err + } + } + if sqlServer && len(stmt.From) == 0 && p.isType(models.TokenTypeFrom) { + if err := p.parseDeleteSources(&stmt.From, stmt); err != nil { + return nil, err + } + } + + // PostgreSQL / Snowflake / MySQL: DELETE FROM t USING s [JOIN ...] + if len(stmt.From) == 0 && p.isType(models.TokenTypeUsing) { + if err := p.parseDeleteSources(&stmt.Using, stmt); err != nil { + return nil, err + } } // Parse WHERE clause if present - var whereClause ast.Expression if p.isType(models.TokenTypeWhere) { p.advance() // Consume WHERE - var err error - whereClause, err = p.parseExpression() + stmt.Where, err = p.parseExpression() if err != nil { return nil, err } } - // Parse LIMIT clause if present (MySQL) - if p.isType(models.TokenTypeLimit) { - p.advance() // Consume LIMIT - if p.isNumericLiteral() { - p.advance() // Consume limit value - } + // MySQL / MariaDB / SQLite: ORDER BY ... LIMIT n + if err := p.parseDMLOrderLimit(&stmt.DMLTargetFields); err != nil { + return nil, err } - // Parse RETURNING clause if present (PostgreSQL) - var returning []ast.Expression - if p.isType(models.TokenTypeReturning) || p.currentToken.Token.Value == "RETURNING" { - p.advance() // Consume RETURNING - var err error - returning, err = p.parseReturningColumns() + // Parse RETURNING clause if present (PostgreSQL, SQLite, MariaDB, Oracle INTO) + if p.isReturningKeyword() { + stmt.Returning, stmt.ReturningInto, err = p.parseReturningClause() if err != nil { return nil, err } } + return stmt, nil +} - // Create DELETE statement - return &ast.DeleteStatement{ - TableName: tableName, - Where: whereClause, - Returning: returning, - }, nil +// parseDeleteSources consumes FROM or USING and parses the source table list +// and its joins into *dst and stmt.Joins. +func (p *Parser) parseDeleteSources(dst *[]ast.TableReference, stmt *ast.DeleteStatement) error { + p.advance() // Consume FROM / USING + prev := p.dmlReturning + p.dmlReturning = true + refs, joins, err := p.parseUpdateFromList() + p.dmlReturning = prev + if err != nil { + return err + } + *dst = refs + stmt.Joins = joins + return nil } diff --git a/pkg/sql/parser/dml_insert.go b/pkg/sql/parser/dml_insert.go index 099a2616..7c3b541d 100644 --- a/pkg/sql/parser/dml_insert.go +++ b/pkg/sql/parser/dml_insert.go @@ -19,7 +19,6 @@ package parser import ( "fmt" - "strings" goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/models" @@ -30,147 +29,127 @@ import ( // parseInsertStatement parses an INSERT statement func (p *Parser) parseInsertStatement() (ast.Statement, error) { // We've already consumed the INSERT token in matchType + stmt := &ast.InsertStatement{} - // Parse INTO - if !p.isType(models.TokenTypeInto) { + // Oracle multi-table insert: INSERT ALL ... / INSERT FIRST ... + if p.dialect == string(keywords.DialectOracle) && + (p.isType(models.TokenTypeAll) || p.isTokenMatch("FIRST")) { + return p.parseMultiTableInsert(stmt) + } + + stmt.Modifiers = p.parseDMLModifiers(dmlInsert) + + // Parse INTO. SQL Server, MySQL and MariaDB make it optional. + if p.isType(models.TokenTypeInto) { + p.advance() // Consume INTO + } else if p.dialect != string(keywords.DialectSQLServer) && !p.isMySQLFamily() { return nil, p.expectedError("INTO") } - p.advance() // Consume INTO - // Parse table name (supports schema.table qualification and double-quoted identifiers) - tableName, err := p.parseQualifiedName() - if err != nil { - return nil, p.qualifiedNameError(err, "table name") + // ClickHouse: INSERT INTO FUNCTION table_function(...) + if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("FUNCTION") && + p.peekToken().Token.Type != models.TokenTypeLParen { + p.advance() // Consume FUNCTION + name := p.currentToken.Token.Value + if !p.isWordToken() { + return nil, p.expectedError("table function name") + } + p.advance() + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("(") + } + fn, err := p.parseFunctionCall(name) + if err != nil { + return nil, err + } + stmt.TableFunction = fn + } else { + // Parse table name (supports schema.table qualification and double-quoted identifiers) + tableName, err := p.parseQualifiedName() + if err != nil { + return nil, p.qualifiedNameError(err, "table name") + } + stmt.TableName = tableName } // Parse column list if present - columns := make([]ast.Expression, 0) + stmt.Columns = make([]ast.Expression, 0) if p.isType(models.TokenTypeLParen) && !p.isInsertQueryParenStart() { - p.advance() // Consume ( - - for { - // Parse column name (supports double-quoted identifiers) - if !p.isIdentifier() { - return nil, p.expectedError("column name") - } - columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) - p.advance() - - // Check if there are more columns - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma - } - - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") + cols, err := p.parseInsertColumnList() + if err != nil { + return nil, err } - p.advance() // Consume ) + stmt.Columns = cols } // Parse SQL Server OUTPUT clause (between column list and VALUES). // Accept OUTPUT regardless of dialect — the keyword is unambiguous here // and allows dialect-agnostic parsing of T-SQL INSERT statements. - var outputCols []ast.Expression if equalsUpper(p.currentToken.Token.Value, "OUTPUT") { - p.advance() // Consume OUTPUT var err error - outputCols, err = p.parseOutputColumns() + stmt.Output, stmt.OutputInto, err = p.parseDMLOutputClause() if err != nil { return nil, err } } - // Parse VALUES or SELECT - var values [][]ast.Expression - var query ast.QueryExpression - var defaultValues, setSyntax bool + if p.dialect == string(keywords.DialectClickHouse) { + // ClickHouse: INSERT INTO t [(cols)] SETTINGS k = v, ... VALUES ... + if p.isSettingsKeyword() { + settings, err := p.parseInsertSettings() + if err != nil { + return nil, err + } + stmt.Settings = settings + } + // ClickHouse-only: INSERT INTO t [(cols)] FORMAT with the + // data payload being external (network / file). There is no VALUES + // or SELECT to follow. + if p.isTokenMatch("FORMAT") { + if err := p.parseInsertFormat(stmt); err != nil { + return nil, err + } + return stmt, nil + } + } - // ClickHouse-only: INSERT INTO t [(cols)] FORMAT with the data - // payload being external (network / file). Short-circuit here — there is - // no VALUES or SELECT to follow. - if p.dialect == string(keywords.DialectClickHouse) && - strings.EqualFold(p.currentToken.Token.Value, "FORMAT") { - p.advance() // Consume FORMAT - if p.isIdentifier() || p.isType(models.TokenTypeKeyword) { - p.advance() // Consume format name + // PostgreSQL: OVERRIDING {SYSTEM | USER} VALUE + if p.isTokenMatch("OVERRIDING") { + p.advance() // Consume OVERRIDING + switch { + case p.isTokenMatch("SYSTEM"): + stmt.Overriding = "SYSTEM" + case p.isTokenMatch("USER"): + stmt.Overriding = "USER" + default: + return nil, p.expectedError("SYSTEM or USER") + } + p.advance() + if !p.isTokenMatch("VALUE") { + return nil, p.expectedError("VALUE") } - return &ast.InsertStatement{ - TableName: tableName, - Columns: columns, - }, nil + p.advance() // Consume VALUE } switch { case p.isType(models.TokenTypeSelect), p.isType(models.TokenTypeWith), p.isInsertQueryParenStart(): // INSERT ... SELECT, INSERT ... WITH cte AS (...) SELECT (#537), or a // parenthesized query INSERT INTO t [(cols)] (SELECT ...) (#610). - stmt, err := p.parseInsertSourceQuery() + qe, err := p.parseInsertQuery() if err != nil { return nil, err } - qe, ok := stmt.(ast.QueryExpression) - if !ok { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("expected SELECT or set operation in INSERT ... SELECT, got %T", stmt), - p.currentLocation(), - "", - ).WithCause(ErrUnexpectedStatement) - } - query = qe + stmt.Query = qe case p.isType(models.TokenTypeValues): p.advance() // Consume VALUES - - // Parse value rows - supports multi-row INSERT: VALUES (a, b), (c, d), (e, f) - values = make([][]ast.Expression, 0) - for { - if !p.isType(models.TokenTypeLParen) { - if len(values) == 0 { - return nil, p.expectedError("(") - } - break - } - p.advance() // Consume ( - - // Parse one row of values - row := make([]ast.Expression, 0) - for { - // Parse value using parseExpression to support all expression types - // including function calls like NOW(), UUID(), etc. - expr, err := p.parseExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse value at position %d in VALUES row %d: %v", len(row)+1, len(values)+1, err), - p.currentLocation(), - "", - ).WithCause(err) - } - row = append(row, expr) - - // Check if there are more values in this row - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma - } - - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") - } - p.advance() // Consume ) - - values = append(values, row) - - // Check if there are more rows (comma after closing paren) - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma between rows + values, err := p.parseInsertValueRows() + if err != nil { + return nil, err } + stmt.Values = values case p.isType(models.TokenTypeDefault) && p.peekToken().Token.Type == models.TokenTypeValues: // INSERT INTO t DEFAULT VALUES (PostgreSQL, SQL Server, SQLite). - if len(columns) > 0 { + if len(stmt.Columns) > 0 { return nil, goerrors.InvalidSyntaxError( "DEFAULT VALUES cannot follow a column list", p.currentLocation(), @@ -179,59 +158,48 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { } p.advance() // Consume DEFAULT p.advance() // Consume VALUES - defaultValues = true - case p.isType(models.TokenTypeSet) && (p.dialect == string(keywords.DialectMySQL) || p.isMariaDB()): + stmt.DefaultValues = true + case p.isType(models.TokenTypeSet) && p.isMySQLFamily(): // MySQL/MariaDB INSERT INTO t SET col = expr, ... - stored as // Columns plus one Values row; SetSyntax keeps the source form. - if len(columns) > 0 { + if len(stmt.Columns) > 0 { return nil, p.expectedError("VALUES or SELECT after a column list") } p.advance() // Consume SET - row := make([]ast.Expression, 0) - for { - if !p.isIdentifier() { - return nil, p.expectedError("column name") - } - columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) - p.advance() - if !p.isType(models.TokenTypeEq) { - return nil, p.expectedError("=") - } - p.advance() // Consume = - expr, err := p.parseExpression() - if err != nil { - return nil, err - } - row = append(row, expr) - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma + cols, row, err := p.parseInsertSetList() + if err != nil { + return nil, err } - values = [][]ast.Expression{row} - setSyntax = true + stmt.Columns = cols + stmt.Values = [][]ast.Expression{row} + stmt.SetSyntax = true default: return nil, p.expectedError("VALUES, SELECT, or WITH") } + // MySQL 8.0.19+: VALUES (...) AS row_alias [(col_alias, ...)] + if p.isType(models.TokenTypeAs) && p.isMySQLFamily() && stmt.Query == nil { + if err := p.parseInsertRowAlias(stmt); err != nil { + return nil, err + } + } + // Parse ON CONFLICT clause (PostgreSQL) or ON DUPLICATE KEY UPDATE (MySQL) - var onConflict *ast.OnConflict - var onDuplicateKey *ast.UpsertClause if p.isType(models.TokenTypeOn) { - nextLit := strings.ToUpper(p.peekToken().Token.Value) - if nextLit == "CONFLICT" { + next := p.peekToken().Token.Value + if equalsUpper(next, "CONFLICT") { p.advance() // Consume ON p.advance() // Consume CONFLICT var err error - onConflict, err = p.parseOnConflictClause() + stmt.OnConflict, err = p.parseOnConflictClause() if err != nil { return nil, err } - } else if nextLit == "DUPLICATE" { + } else if equalsUpper(next, "DUPLICATE") { p.advance() // Consume ON p.advance() // Consume DUPLICATE // Expect KEY - if strings.ToUpper(p.currentToken.Token.Value) != "KEY" && !p.isType(models.TokenTypeKey) { + if !p.isTokenMatch("KEY") && !p.isType(models.TokenTypeKey) { return nil, p.expectedError("KEY") } p.advance() // Consume KEY @@ -241,48 +209,227 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { } p.advance() // Consume UPDATE var err error - onDuplicateKey, err = p.parseOnDuplicateKeyUpdateClause() + stmt.OnDuplicateKey, err = p.parseOnDuplicateKeyUpdateClause() if err != nil { return nil, err } } } - // Parse RETURNING clause if present (PostgreSQL) - var returning []ast.Expression + // Parse RETURNING clause if present (PostgreSQL, SQLite, MariaDB, Oracle INTO) if p.isReturningKeyword() { - p.advance() // Consume RETURNING var err error - returning, err = p.parseReturningColumns() + stmt.Returning, stmt.ReturningInto, err = p.parseReturningClause() if err != nil { return nil, err } } // ClickHouse INSERT ... VALUES (...) FORMAT trailing format hint. - // Parse-only; the trailing payload is not consumed. - if p.dialect == string(keywords.DialectClickHouse) && - strings.EqualFold(p.currentToken.Token.Value, "FORMAT") { - p.advance() // Consume FORMAT - if p.isIdentifier() || p.isType(models.TokenTypeKeyword) || - p.isType(models.TokenTypeValues) { - p.advance() // Consume format name (may tokenize as VALUES keyword) - } - } - - // Create INSERT statement - return &ast.InsertStatement{ - TableName: tableName, - Columns: columns, - Output: outputCols, - Values: values, - Query: query, - OnConflict: onConflict, - OnDuplicateKey: onDuplicateKey, - Returning: returning, - DefaultValues: defaultValues, - SetSyntax: setSyntax, - }, nil + if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("FORMAT") { + if err := p.parseInsertFormat(stmt); err != nil { + return nil, err + } + } + return stmt, nil +} + +// parseInsertSettings parses the ClickHouse "SETTINGS name = expr, ..." of +// an INSERT; the current token must be SETTINGS. Unlike the SELECT settings +// list it leaves a following FORMAT clause to the caller, which stores it. +func (p *Parser) parseInsertSettings() ([]ast.QuerySetting, error) { + p.advance() // Consume SETTINGS + var settings []ast.QuerySetting + for { + if !p.isQuerySettingName() { + return nil, p.expectedError("setting name") + } + pos := p.currentLocation() + name := p.currentToken.Token.Value + p.advance() + if !p.isType(models.TokenTypeEq) { + return nil, p.expectedError("=") + } + p.advance() // Consume = + value, err := p.parseExpression() + if err != nil { + return nil, err + } + settings = append(settings, ast.QuerySetting{Name: name, Value: value, Pos: pos}) + if !p.isType(models.TokenTypeComma) { + return settings, nil + } + p.advance() // Consume , + } +} + +// parseInsertFormat parses the ClickHouse "FORMAT name" of an INSERT; the +// current token must be FORMAT. The name may tokenize as a keyword (Values). +func (p *Parser) parseInsertFormat(stmt *ast.InsertStatement) error { + p.advance() // Consume FORMAT + if !p.isWordToken() { + return p.expectedError("format name after FORMAT") + } + stmt.DataFormat = p.currentToken.Token.Value + p.advance() + return nil +} + +// parseInsertQuery parses the query source of INSERT ... SELECT. +func (p *Parser) parseInsertQuery() (ast.QueryExpression, error) { + stmt, err := p.parseInsertSourceQuery() + if err != nil { + return nil, err + } + qe, ok := stmt.(ast.QueryExpression) + if !ok { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("expected SELECT or set operation in INSERT ... SELECT, got %T", stmt), + p.currentLocation(), + "", + ).WithCause(ErrUnexpectedStatement) + } + return qe, nil +} + +// parseInsertColumnList parses "(col, ...)" of an INSERT (or an OUTPUT INTO +// target, a REPLACE, a multi-table INTO). The current token must be '('. +// An empty list "()" (MySQL) is accepted. Keyword words are accepted as +// column names, since a column list holds only names. +func (p *Parser) parseInsertColumnList() ([]ast.Expression, error) { + p.advance() // Consume ( + columns := make([]ast.Expression, 0) + if p.isType(models.TokenTypeRParen) && p.isMySQLFamily() { + p.advance() // Consume ) + return columns, nil + } + for { + if !p.isWordToken() { + return nil, p.expectedError("column name") + } + columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) + p.advance() + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // Consume comma + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume ) + return columns, nil +} + +// parseInsertValueRows parses the rows after VALUES: (a, b), (c, d), ... +// Items may be DEFAULT; an empty row "()" (MySQL) is accepted. +func (p *Parser) parseInsertValueRows() ([][]ast.Expression, error) { + values := make([][]ast.Expression, 0) + for { + if !p.isType(models.TokenTypeLParen) { + if len(values) == 0 { + return nil, p.expectedError("(") + } + break + } + p.advance() // Consume ( + + // Parse one row of values + row := make([]ast.Expression, 0) + if !(p.isType(models.TokenTypeRParen) && p.isMySQLFamily()) { + for { + // Parse value using parseExpression to support all expression types + // including function calls like NOW(), UUID(), etc. + expr, err := p.parseDMLValue() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse value at position %d in VALUES row %d: %v", len(row)+1, len(values)+1, err), + p.currentLocation(), + "", + ).WithCause(err) + } + row = append(row, expr) + + // Check if there are more values in this row + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // Consume comma + } + } + + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // Consume ) + + values = append(values, row) + + // Check if there are more rows (comma after closing paren) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // Consume comma between rows + } + return values, nil +} + +// parseInsertSetList parses the MySQL "col = expr, ..." list of INSERT/REPLACE +// ... SET; the current token is the first column. +func (p *Parser) parseInsertSetList() ([]ast.Expression, []ast.Expression, error) { + var columns []ast.Expression + row := make([]ast.Expression, 0) + for { + if !p.isWordToken() { + return nil, nil, p.expectedError("column name") + } + columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) + p.advance() + if !p.isType(models.TokenTypeEq) { + return nil, nil, p.expectedError("=") + } + p.advance() // Consume = + expr, err := p.parseDMLValue() + if err != nil { + return nil, nil, err + } + row = append(row, expr) + if !p.isType(models.TokenTypeComma) { + return columns, row, nil + } + p.advance() // Consume comma + } +} + +// parseInsertRowAlias parses the MySQL row alias "AS name [(cols)]"; the +// current token must be AS. +func (p *Parser) parseInsertRowAlias(stmt *ast.InsertStatement) error { + p.advance() // Consume AS + if !p.isWordToken() { + return p.expectedError("row alias after AS") + } + stmt.RowAlias = p.currentToken.Token.Value + p.advance() + if !p.isType(models.TokenTypeLParen) { + return nil + } + p.advance() // Consume ( + for { + if !p.isWordToken() { + return p.expectedError("column alias") + } + stmt.RowAliasColumns = append(stmt.RowAliasColumns, p.currentToken.Token.Value) + p.advance() + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // Consume , + } + if !p.isType(models.TokenTypeRParen) { + return p.expectedError(")") + } + p.advance() // Consume ) + return nil } // isInsertQueryParenStart reports whether the current '(' opens a @@ -325,41 +472,10 @@ func (p *Parser) parseInsertSourceQuery() (ast.Statement, error) { return stmt, nil } -// parseReturningColumns parses the columns in a RETURNING clause -// Supports: column names, *, qualified names (table.column), expressions -func (p *Parser) parseReturningColumns() ([]ast.Expression, error) { - var columns []ast.Expression - - for { - // Check for * (return all columns) - if p.isType(models.TokenTypeMul) { - columns = append(columns, &ast.Identifier{Name: "*"}) - p.advance() - } else { - // Parse expression (can be column name, qualified name, or expression) - expr, err := p.parseExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse RETURNING column: %v", err), - p.currentLocation(), - "", - ).WithCause(err) - } - columns = append(columns, expr) - } - - // Check for comma to continue parsing more columns - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma - } - - return columns, nil -} - -// parseOnConflictClause parses the ON CONFLICT clause (PostgreSQL UPSERT) -// Syntax: ON CONFLICT [(columns)] | ON CONSTRAINT name DO NOTHING | DO UPDATE SET ... +// parseOnConflictClause parses the ON CONFLICT clause (PostgreSQL / SQLite UPSERT) +// Syntax: ON CONFLICT [(target, ...) [WHERE pred] | ON CONSTRAINT name] +// DO NOTHING | DO UPDATE SET ... [WHERE ...]. A target is a column or an +// index expression such as lower(email). func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { onConflict := &ast.OnConflict{} @@ -369,11 +485,27 @@ func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { var targets []ast.Expression for { - if !p.isIdentifier() { - return nil, p.expectedError("column name in ON CONFLICT target") + var target ast.Expression + if p.isWordToken() && !p.isType(models.TokenTypeDefault) { + switch p.peekToken().Token.Type { + case models.TokenTypeComma, models.TokenTypeRParen: + // A plain column name (which may be a keyword: key, value). + target = &ast.Identifier{Name: p.currentToken.Token.Value} + p.advance() + } } - targets = append(targets, &ast.Identifier{Name: p.currentToken.Token.Value}) - p.advance() + if target == nil { + expr, err := p.parseExpression() + if err != nil { + return nil, goerrors.InvalidSyntaxError( + fmt.Sprintf("failed to parse ON CONFLICT target: %v", err), + p.currentLocation(), + "", + ).WithCause(err) + } + target = expr + } + targets = append(targets, target) if !p.isType(models.TokenTypeComma) { break @@ -386,7 +518,17 @@ func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { } p.advance() // Consume ) onConflict.Target = targets - } else if p.isType(models.TokenTypeOn) && p.peekToken().Token.Value == "CONSTRAINT" { + + // Partial-index predicate: ON CONFLICT (a) WHERE b > 0 DO ... + if p.isType(models.TokenTypeWhere) { + p.advance() // Consume WHERE + where, err := p.parseExpression() + if err != nil { + return nil, err + } + onConflict.TargetWhere = where + } + } else if p.isType(models.TokenTypeOn) && equalsUpper(p.peekToken().Token.Value, "CONSTRAINT") { // ON CONSTRAINT constraint_name p.advance() // Consume ON p.advance() // Consume CONSTRAINT @@ -398,13 +540,13 @@ func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { } // Parse DO keyword - if p.currentToken.Token.Value != "DO" { + if !p.isTokenMatch("DO") { return nil, p.expectedError("DO") } p.advance() // Consume DO // Parse action: NOTHING or UPDATE - if p.currentToken.Token.Value == "NOTHING" { + if p.isTokenMatch("NOTHING") { onConflict.Action = ast.OnConflictAction{DoNothing: true} p.advance() // Consume NOTHING } else if p.isType(models.TokenTypeUpdate) { @@ -416,39 +558,9 @@ func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { } p.advance() // Consume SET - // Parse update assignments - var updates []ast.UpdateExpression - for { - if !p.isIdentifier() { - return nil, p.expectedError("column name") - } - columnName := p.currentToken.Token.Value - p.advance() - - if !p.isType(models.TokenTypeEq) { - return nil, p.expectedError("=") - } - p.advance() // Consume = - - // Parse value expression (supports EXCLUDED.column references) - value, err := p.parseExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse ON CONFLICT UPDATE value: %v", err), - p.currentLocation(), - "", - ).WithCause(err) - } - - updates = append(updates, ast.UpdateExpression{ - Column: &ast.Identifier{Name: columnName}, - Value: value, - }) - - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma + updates, err := p.parseAssignments("ON CONFLICT UPDATE") + if err != nil { + return nil, err } onConflict.Action.DoUpdate = updates @@ -472,57 +584,11 @@ func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { return onConflict, nil } -// parseOutputColumns parses comma-separated OUTPUT column expressions -// e.g., INSERTED.id, INSERTED.name, DELETED.*, inserted.* -func (p *Parser) parseOutputColumns() ([]ast.Expression, error) { - var cols []ast.Expression - for { - expr, err := p.parseExpression() - if err != nil { - return nil, err - } - cols = append(cols, expr) - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma - } - return cols, nil -} - // parseOnDuplicateKeyUpdateClause parses the assignments in ON DUPLICATE KEY UPDATE func (p *Parser) parseOnDuplicateKeyUpdateClause() (*ast.UpsertClause, error) { - upsert := &ast.UpsertClause{} - for { - if !p.isIdentifier() { - return nil, p.expectedError("column name in ON DUPLICATE KEY UPDATE") - } - columnName := p.currentToken.Token.Value - p.advance() - - if !p.isType(models.TokenTypeEq) { - return nil, p.expectedError("=") - } - p.advance() - - value, err := p.parseExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse ON DUPLICATE KEY UPDATE value: %v", err), - p.currentLocation(), - "", - ).WithCause(err) - } - - upsert.Updates = append(upsert.Updates, ast.UpdateExpression{ - Column: &ast.Identifier{Name: columnName}, - Value: value, - }) - - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() // Consume comma + updates, err := p.parseAssignments("ON DUPLICATE KEY UPDATE") + if err != nil { + return nil, err } - return upsert, nil + return &ast.UpsertClause{Updates: updates}, nil } diff --git a/pkg/sql/parser/dml_insert_multi.go b/pkg/sql/parser/dml_insert_multi.go new file mode 100644 index 00000000..56769122 --- /dev/null +++ b/pkg/sql/parser/dml_insert_multi.go @@ -0,0 +1,116 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - dml_insert_multi.go +// Oracle multi-table INSERT ALL / INSERT FIRST. + +package parser + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// parseMultiTableInsert parses an Oracle multi-table insert; the current +// token is ALL or FIRST: +// +// INSERT ALL INTO t [(cols)] [VALUES (...)] ... subquery +// INSERT {ALL | FIRST} WHEN cond THEN INTO ... [WHEN ...] [ELSE INTO ...] subquery +func (p *Parser) parseMultiTableInsert(stmt *ast.InsertStatement) (ast.Statement, error) { + m := &ast.MultiTableInsert{First: p.isTokenMatch("FIRST")} + p.advance() // Consume ALL / FIRST + + if p.isType(models.TokenTypeWhen) { + for p.isType(models.TokenTypeWhen) { + p.advance() // Consume WHEN + cond, err := p.parseExpression() + if err != nil { + return nil, err + } + if !p.isType(models.TokenTypeThen) { + return nil, p.expectedError("THEN") + } + p.advance() // Consume THEN + into, err := p.parseMultiInsertIntos() + if err != nil { + return nil, err + } + m.Branches = append(m.Branches, ast.MultiInsertBranch{When: cond, Into: into}) + } + if p.isType(models.TokenTypeElse) { + p.advance() // Consume ELSE + into, err := p.parseMultiInsertIntos() + if err != nil { + return nil, err + } + m.Branches = append(m.Branches, ast.MultiInsertBranch{Else: true, Into: into}) + } + } else { + if m.First { + return nil, p.expectedError("WHEN after INSERT FIRST") + } + into, err := p.parseMultiInsertIntos() + if err != nil { + return nil, err + } + m.Branches = append(m.Branches, ast.MultiInsertBranch{Into: into}) + } + + if !p.isType(models.TokenTypeSelect) && !p.isType(models.TokenTypeWith) && !p.isInsertQueryParenStart() { + return nil, p.expectedError("SELECT") + } + qe, err := p.parseInsertQuery() + if err != nil { + return nil, err + } + stmt.Query = qe + stmt.MultiTable = m + return stmt, nil +} + +// parseMultiInsertIntos parses one or more "INTO t [(cols)] [VALUES (...)]". +func (p *Parser) parseMultiInsertIntos() ([]ast.MultiInsertInto, error) { + var intos []ast.MultiInsertInto + for p.isType(models.TokenTypeInto) { + p.advance() // Consume INTO + name, err := p.parseQualifiedName() + if err != nil { + return nil, p.qualifiedNameError(err, "table name") + } + in := ast.MultiInsertInto{Table: name} + if p.isType(models.TokenTypeLParen) { + cols, err := p.parseInsertColumnList() + if err != nil { + return nil, err + } + in.Columns = cols + } + if p.isType(models.TokenTypeValues) { + p.advance() // Consume VALUES + rows, err := p.parseInsertValueRows() + if err != nil { + return nil, err + } + if len(rows) != 1 { + return nil, p.expectedError("one VALUES row per INTO clause") + } + in.Values = rows[0] + } + intos = append(intos, in) + } + if len(intos) == 0 { + return nil, p.expectedError("INTO") + } + return intos, nil +} diff --git a/pkg/sql/parser/dml_merge.go b/pkg/sql/parser/dml_merge.go index f9dd921e..c3969255 100644 --- a/pkg/sql/parser/dml_merge.go +++ b/pkg/sql/parser/dml_merge.go @@ -19,7 +19,6 @@ package parser import ( "fmt" - "strings" goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/models" @@ -67,12 +66,22 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) { } p.advance() // Consume USING - // Parse source table (could be a table or subquery) - sourceRef, err := p.parseTableReference() - if err != nil { - return nil, goerrors.WrapError(goerrors.ErrCodeInvalidSyntax, "error parsing MERGE source", models.Location{}, "", err) + // Parse source: a table, or a derived table (SELECT ...) [AS] alias. + if p.isType(models.TokenTypeLParen) { + sourceRef, err := p.parseFromTableReference() + if err != nil { + return nil, goerrors.WrapError(goerrors.ErrCodeInvalidSyntax, "error parsing MERGE source", models.Location{}, "", err) + } + // The renderers write the alias from SourceAlias. + stmt.SourceAlias, sourceRef.Alias = sourceRef.Alias, "" + stmt.SourceTable = sourceRef + } else { + sourceRef, err := p.parseTableReference() + if err != nil { + return nil, goerrors.WrapError(goerrors.ErrCodeInvalidSyntax, "error parsing MERGE source", models.Location{}, "", err) + } + stmt.SourceTable = *sourceRef } - stmt.SourceTable = *sourceRef // Parse optional source alias if p.isType(models.TokenTypeAs) { @@ -82,7 +91,7 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) { } stmt.SourceAlias = p.currentToken.Token.Value p.advance() - } else if p.canBeAlias() && !p.isType(models.TokenTypeOn) && p.currentToken.Token.Value != "ON" { + } else if stmt.SourceAlias == "" && p.canBeAlias() && !p.isType(models.TokenTypeOn) && p.currentToken.Token.Value != "ON" { stmt.SourceAlias = p.currentToken.Token.Value p.advance() } @@ -113,15 +122,27 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) { } // Parse optional OUTPUT clause (SQL Server) - if p.dialect == string(keywords.DialectSQLServer) && strings.ToUpper(p.currentToken.Token.Value) == "OUTPUT" { + if p.dialect == string(keywords.DialectSQLServer) && equalsUpper(p.currentToken.Token.Value, "OUTPUT") { p.advance() // Consume OUTPUT - cols, err := p.parseOutputColumns() + cols, err := p.parseSelectColumnList() if err != nil { return nil, err } stmt.Output = cols } + // PostgreSQL 17: RETURNING + if p.isReturningKeyword() { + returning, into, err := p.parseReturningClause() + if err != nil { + return nil, err + } + if len(into) > 0 { + return nil, goerrors.UnsupportedFeatureError("RETURNING ... INTO in MERGE", p.currentLocation(), "") + } + stmt.Returning = returning + } + return stmt, nil } @@ -142,14 +163,18 @@ func (p *Parser) parseMergeWhenClause() (*ast.MergeWhenClause, error) { } p.advance() // Consume MATCHED - // Check for BY SOURCE + // Check for BY SOURCE / BY TARGET if p.isType(models.TokenTypeBy) { p.advance() // Consume BY - if !p.isType(models.TokenTypeSource) && p.currentToken.Token.Value != "SOURCE" { - return nil, p.expectedError("SOURCE after BY") + switch { + case p.isType(models.TokenTypeSource) || p.isTokenMatch("SOURCE"): + clause.Type = "NOT_MATCHED_BY_SOURCE" + case p.isType(models.TokenTypeTarget) || p.isTokenMatch("TARGET"): + clause.Type = "NOT_MATCHED_BY_TARGET" + default: + return nil, p.expectedError("SOURCE or TARGET after BY") } - p.advance() // Consume SOURCE - clause.Type = "NOT_MATCHED_BY_SOURCE" + p.advance() // Consume SOURCE / TARGET } else { clause.Type = "NOT_MATCHED" } @@ -199,7 +224,7 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { // Parse SET clauses for { - if !p.isIdentifier() && !p.canBeAlias() { + if !p.isIdentifier() && !p.canBeAlias() && !p.isWordToken() { return nil, p.expectedError("column name") } // Handle qualified column names (e.g., t.name) @@ -223,7 +248,7 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { } p.advance() // Consume = - value, err := p.parseExpression() + value, err := p.parseDMLValue() if err != nil { return nil, goerrors.WrapError(goerrors.ErrCodeInvalidSyntax, "error parsing SET value", models.Location{}, "", err) } @@ -235,6 +260,9 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { } p.advance() // Consume comma } + if err := p.parseMergeActionWhere(action, true); err != nil { + return nil, err + } } else if p.isType(models.TokenTypeInsert) { if clauseType == "MATCHED" || clauseType == "NOT_MATCHED_BY_SOURCE" { return nil, goerrors.InvalidSyntaxError(fmt.Sprintf("INSERT not allowed in WHEN %s clause", clauseType), models.Location{}, "") @@ -279,7 +307,7 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { p.advance() // Consume ( for { - value, err := p.parseExpression() + value, err := p.parseDMLValue() if err != nil { return nil, goerrors.WrapError(goerrors.ErrCodeInvalidSyntax, "error parsing INSERT value", models.Location{}, "", err) } @@ -298,15 +326,48 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { } else { return nil, p.expectedError("VALUES or DEFAULT VALUES") } + if err := p.parseMergeActionWhere(action, false); err != nil { + return nil, err + } } else if p.isType(models.TokenTypeDelete) { if clauseType == "NOT_MATCHED" { return nil, goerrors.InvalidSyntaxError("DELETE not allowed in WHEN NOT MATCHED clause", models.Location{}, "") } action.ActionType = "DELETE" p.advance() // Consume DELETE + } else if p.isTokenMatch("DO") && equalsUpper(p.peekToken().Token.Value, "NOTHING") { + // PostgreSQL 15+: WHEN ... THEN DO NOTHING + action.ActionType = "DO NOTHING" + p.advance() // Consume DO + p.advance() // Consume NOTHING } else { - return nil, p.expectedError("UPDATE, INSERT, or DELETE") + return nil, p.expectedError("UPDATE, INSERT, DELETE, or DO NOTHING") } return action, nil } + +// parseMergeActionWhere parses the Oracle action predicates that follow a +// MERGE UPDATE SET list (WHERE c, then DELETE WHERE c) or a MERGE INSERT +// VALUES list (WHERE c). A WHERE here cannot start anything else: WHEN +// clauses begin with WHEN. +func (p *Parser) parseMergeActionWhere(action *ast.MergeAction, isUpdate bool) error { + if p.isType(models.TokenTypeWhere) { + p.advance() // Consume WHERE + where, err := p.parseExpression() + if err != nil { + return err + } + action.Where = where + } + if isUpdate && p.isType(models.TokenTypeDelete) && p.peekToken().Token.Type == models.TokenTypeWhere { + p.advance() // Consume DELETE + p.advance() // Consume WHERE + where, err := p.parseExpression() + if err != nil { + return err + } + action.DeleteWhere = where + } + return nil +} diff --git a/pkg/sql/parser/dml_replace.go b/pkg/sql/parser/dml_replace.go new file mode 100644 index 00000000..580f3d5b --- /dev/null +++ b/pkg/sql/parser/dml_replace.go @@ -0,0 +1,97 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - dml_replace.go +// MySQL / MariaDB / SQLite REPLACE statement parsing. + +package parser + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// parseReplaceStatement parses a REPLACE statement (the REPLACE token has +// been consumed): +// +// REPLACE [INTO] t [(cols)] VALUES (...), ... [RETURNING ...] +// REPLACE [INTO] t [(cols)] SELECT ... [RETURNING ...] +// REPLACE [INTO] t SET col = expr, ... [RETURNING ...] +// +// INTO is optional in MySQL and MariaDB; RETURNING is MariaDB 10.5+. +func (p *Parser) parseReplaceStatement() (ast.Statement, error) { + if p.isType(models.TokenTypeInto) { + p.advance() // Consume INTO + } else if !p.isMySQLFamily() { + return nil, p.expectedError("INTO") + } + + tableName, err := p.parseQualifiedName() + if err != nil { + return nil, p.qualifiedNameError(err, "table name") + } + + var columns []ast.Expression + if p.isType(models.TokenTypeLParen) && !p.isInsertQueryParenStart() { + columns, err = p.parseInsertColumnList() + if err != nil { + return nil, err + } + } + + var ( + values [][]ast.Expression + query ast.QueryExpression + setSyntax bool + ) + switch { + case p.isType(models.TokenTypeValues): + p.advance() // Consume VALUES + values, err = p.parseInsertValueRows() + case p.isType(models.TokenTypeSelect), p.isType(models.TokenTypeWith), p.isInsertQueryParenStart(): + query, err = p.parseInsertQuery() + case p.isType(models.TokenTypeSet) && len(columns) == 0: + p.advance() // Consume SET + var row []ast.Expression + columns, row, err = p.parseInsertSetList() + values = [][]ast.Expression{row} + setSyntax = true + default: + return nil, p.expectedError("VALUES, SELECT, or SET") + } + if err != nil { + return nil, err + } + + var returning []ast.Expression + if p.isReturningKeyword() { + var into []ast.Expression + returning, into, err = p.parseReturningClause() + if err != nil { + return nil, err + } + if len(into) > 0 { + return nil, p.expectedError("end of RETURNING list") + } + } + + replStmt := ast.GetReplaceStatement() + replStmt.TableName = tableName + replStmt.Columns = append(replStmt.Columns, columns...) + replStmt.Values = append(replStmt.Values, values...) + replStmt.Query = query + replStmt.SetSyntax = setSyntax + replStmt.Returning = append(replStmt.Returning, returning...) + return replStmt, nil +} diff --git a/pkg/sql/parser/dml_update.go b/pkg/sql/parser/dml_update.go index 2f37e544..7e9469bd 100644 --- a/pkg/sql/parser/dml_update.go +++ b/pkg/sql/parser/dml_update.go @@ -18,21 +18,42 @@ package parser import ( - "strings" - goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" ) -// parseUpdateStatement parses an UPDATE statement +// parseUpdateStatement parses an UPDATE statement: +// +// UPDATE [modifiers] [TOP (n)] [ONLY] target [alias] [, target ...] [JOIN ...] +// SET assignments [OUTPUT ...] [FROM ...] [WHERE ...] +// [ORDER BY ...] [LIMIT n] [RETURNING ... [INTO ...]] func (p *Parser) parseUpdateStatement() (ast.Statement, error) { // We've already consumed the UPDATE token in matchType + stmt := &ast.UpdateStatement{} + stmt.Modifiers = p.parseDMLModifiers(dmlUpdate) + top, err := p.parseTopClause() + if err != nil { + return nil, err + } + stmt.Top = top + if p.isDMLOnly() { + stmt.Only = true + p.advance() // Consume ONLY + } - // Parse table name (supports schema.table qualification and double-quoted identifiers) - tableName, err := p.parseQualifiedName() + // Target(s). MySQL/MariaDB allow a multi-table target list with JOINs. + multi := p.isMySQLFamily() + first, err := p.parseDMLTargets(&stmt.TableName, &stmt.Alias, &stmt.DMLTargetFields, multi) if err != nil { - return nil, p.qualifiedNameError(err, "table name") + return nil, err + } + if multi && p.isJoinKeyword() { + joins, err := p.parseJoinClauses(first, nil) + if err != nil { + return nil, err + } + stmt.Joins = joins } // Parse SET @@ -41,66 +62,28 @@ func (p *Parser) parseUpdateStatement() (ast.Statement, error) { } p.advance() // Consume SET - // Parse assignments - updates := make([]ast.UpdateExpression, 0) - for { - // Parse column name (supports double-quoted identifiers) - if !p.isIdentifier() { - return nil, p.expectedError("column name") - } - columnName := p.currentToken.Token.Value - p.advance() - - if !p.isType(models.TokenTypeEq) { - return nil, p.expectedError("=") - } - p.advance() // Consume = - - // Parse value expression - var expr ast.Expression - if p.isStringLiteral() { - expr = &ast.LiteralValue{Value: p.currentToken.Token.Value, Type: "string"} - p.advance() - } else if p.isNumericLiteral() { - litType := "int" - if strings.ContainsAny(p.currentToken.Token.Value, ".eE") { - litType = "float" - } - expr = &ast.LiteralValue{Value: p.currentToken.Token.Value, Type: litType} - p.advance() - } else if p.isBooleanLiteral() { - expr = &ast.LiteralValue{Value: p.currentToken.Token.Value, Type: "bool"} - p.advance() - } else { - var err error - expr, err = p.parseExpression() - if err != nil { - return nil, err - } - } - - // Create update expression - columnExpr := &ast.Identifier{Name: columnName} - updateExpr := ast.UpdateExpression{ - Column: columnExpr, - Value: expr, - } - updates = append(updates, updateExpr) + stmt.Assignments, err = p.parseAssignments("SET") + if err != nil { + return nil, err + } - // Check if there are more assignments - if !p.isType(models.TokenTypeComma) { - break + // SQL Server: OUTPUT precedes FROM and WHERE. + if p.isDMLOutputKeyword() { + stmt.Output, stmt.OutputInto, err = p.parseDMLOutputClause() + if err != nil { + return nil, err } - p.advance() // Consume comma } // PostgreSQL / SQL Server / SQLite: UPDATE t SET ... FROM s [, ...] (#610). - var from []ast.TableReference if p.isType(models.TokenTypeFrom) { + if len(stmt.Joins) > 0 { + return nil, goerrors.UnsupportedFeatureError("FROM after a joined UPDATE target", p.currentLocation(), "") + } p.advance() // Consume FROM prev := p.dmlReturning p.dmlReturning = true - from, err = p.parseUpdateFromList() + stmt.From, stmt.Joins, err = p.parseUpdateFromList() p.dmlReturning = prev if err != nil { return nil, err @@ -108,53 +91,38 @@ func (p *Parser) parseUpdateStatement() (ast.Statement, error) { } // Parse WHERE clause if present - var whereClause ast.Expression if p.isType(models.TokenTypeWhere) { p.advance() // Consume WHERE - var err error - whereClause, err = p.parseExpression() + stmt.Where, err = p.parseExpression() if err != nil { return nil, err } } - // Parse LIMIT clause if present (MySQL) - if p.isType(models.TokenTypeLimit) { - p.advance() // Consume LIMIT - if p.isNumericLiteral() { - p.advance() // Consume limit value (MySQL UPDATE LIMIT) - } + // MySQL / MariaDB / SQLite: ORDER BY ... LIMIT n + if err := p.parseDMLOrderLimit(&stmt.DMLTargetFields); err != nil { + return nil, err } - // Parse RETURNING clause if present (PostgreSQL) - var returning []ast.Expression - if p.isType(models.TokenTypeReturning) || p.currentToken.Token.Value == "RETURNING" { - p.advance() // Consume RETURNING - var err error - returning, err = p.parseReturningColumns() + // Parse RETURNING clause if present (PostgreSQL, SQLite, Oracle INTO) + if p.isReturningKeyword() { + stmt.Returning, stmt.ReturningInto, err = p.parseReturningClause() if err != nil { return nil, err } } - - // Create UPDATE statement - return &ast.UpdateStatement{ - TableName: tableName, - Assignments: updates, - From: from, - Where: whereClause, - Returning: returning, - }, nil + return stmt, nil } // parseUpdateFromList parses the comma-separated table references of -// UPDATE ... FROM. JOINs are rejected: UpdateStatement has no join list. -func (p *Parser) parseUpdateFromList() ([]ast.TableReference, error) { +// UPDATE ... FROM and the JOIN clauses that follow them (SQL Server, +// PostgreSQL). +func (p *Parser) parseUpdateFromList() ([]ast.TableReference, []ast.JoinClause, error) { var refs []ast.TableReference for { ref, err := p.parseFromTableReference() if err != nil { - return nil, err + return nil, nil, err } refs = append(refs, ref) if !p.isType(models.TokenTypeComma) { @@ -162,8 +130,12 @@ func (p *Parser) parseUpdateFromList() ([]ast.TableReference, error) { } p.advance() // Consume comma } - if p.isJoinKeyword() { - return nil, goerrors.UnsupportedFeatureError("JOIN in UPDATE ... FROM", p.currentLocation(), "") + if !p.isJoinKeyword() { + return refs, nil, nil + } + joins, err := p.parseJoinClauses(refs[len(refs)-1], nil) + if err != nil { + return nil, nil, err } - return refs, nil + return refs, joins, nil } diff --git a/pkg/sql/parser/insert_grammar_gaps_test.go b/pkg/sql/parser/insert_grammar_gaps_test.go index 68a72f84..6380e0d1 100644 --- a/pkg/sql/parser/insert_grammar_gaps_test.go +++ b/pkg/sql/parser/insert_grammar_gaps_test.go @@ -434,7 +434,7 @@ func TestInsertGrammarGapsErrors(t *testing.T) { {"bare default", "", "INSERT INTO t1 (a) DEFAULT", "VALUES, SELECT, or WITH"}, {"unclosed paren source", "", "INSERT INTO t (SELECT * FROM s", ")"}, {"paren non-query source", "", "INSERT INTO t (a) (1)", "VALUES, SELECT, or WITH"}, - {"update from join", "postgresql", "UPDATE t SET a = s.b FROM s JOIN u ON s.id = u.id WHERE t.id = s.id", "JOIN in UPDATE ... FROM"}, + {"update joined target then from", "mysql", "UPDATE t JOIN u ON t.id = u.id SET a = 1 FROM s", "FROM after a joined UPDATE target"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/sql/parser/mysql.go b/pkg/sql/parser/mysql.go index 77cff7df..c28de747 100644 --- a/pkg/sql/parser/mysql.go +++ b/pkg/sql/parser/mysql.go @@ -246,89 +246,3 @@ func joinExplainOptions(parts []string) string { } return sb.String() } - -// parseReplaceStatement parses MySQL REPLACE INTO statement -func (p *Parser) parseReplaceStatement() (ast.Statement, error) { - // Expect INTO - if !p.isType(models.TokenTypeInto) { - return nil, p.expectedError("INTO") - } - p.advance() - - // Parse table name - tableName, err := p.parseQualifiedName() - if err != nil { - return nil, p.qualifiedNameError(err, "table name") - } - - // Parse column list if present - columns := make([]ast.Expression, 0) - if p.isType(models.TokenTypeLParen) { - p.advance() - for { - if !p.isIdentifier() { - return nil, p.expectedError("column name") - } - columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) - p.advance() - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() - } - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") - } - p.advance() - } - - // Parse VALUES - if !p.isType(models.TokenTypeValues) { - return nil, p.expectedError("VALUES") - } - p.advance() - - values := make([][]ast.Expression, 0) - for { - if !p.isType(models.TokenTypeLParen) { - if len(values) == 0 { - return nil, p.expectedError("(") - } - break - } - p.advance() - - row := make([]ast.Expression, 0) - for { - expr, err := p.parseExpression() - if err != nil { - return nil, goerrors.InvalidSyntaxError( - fmt.Sprintf("failed to parse value in REPLACE: %v", err), - p.currentLocation(), - "", - ).WithCause(err) - } - row = append(row, expr) - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() - } - if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(")") - } - p.advance() - values = append(values, row) - - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() - } - - replStmt := ast.GetReplaceStatement() - replStmt.TableName = tableName - replStmt.Columns = append(replStmt.Columns, columns...) - replStmt.Values = append(replStmt.Values, values...) - return replStmt, nil -} diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index be9163ad..deab3fa3 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -136,10 +136,8 @@ clickhouse/07_observability.sql#40 parse clickhouse/08_etl_pipelines.sql#14 parse clickhouse/08_etl_pipelines.sql#16 drop clickhouse/08_etl_pipelines.sql#17 parse -clickhouse/08_etl_pipelines.sql#2 parse clickhouse/08_etl_pipelines.sql#20 parse clickhouse/08_etl_pipelines.sql#27 parse -clickhouse/08_etl_pipelines.sql#3 parse clickhouse/08_etl_pipelines.sql#32 drop clickhouse/08_etl_pipelines.sql#34 parse clickhouse/08_etl_pipelines.sql#35 parse @@ -179,15 +177,10 @@ clickhouse/10_misc_real.sql#6 parse clickhouse/10_misc_real.sql#7 drop clickhouse/10_misc_real.sql#8 drop clickhouse/10_misc_real.sql#9 drop -mariadb/01_app_crud.sql#13 parse -mariadb/01_app_crud.sql#14 parse -mariadb/01_app_crud.sql#16 parse -mariadb/01_app_crud.sql#17 parse mariadb/01_app_crud.sql#26 parse mariadb/01_app_crud.sql#27 parse mariadb/01_app_crud.sql#28 parse mariadb/01_app_crud.sql#3 parse -mariadb/01_app_crud.sql#7 parse mariadb/02_reporting.sql#1 drop mariadb/02_reporting.sql#13 parse mariadb/02_reporting.sql#17 parse @@ -254,8 +247,6 @@ mariadb/04_window_analytics.sql#18 parse mariadb/04_window_analytics.sql#4 drop mariadb/04_window_analytics.sql#5 parse mariadb/05_mariadb_specific.sql#11 parse -mariadb/05_mariadb_specific.sql#14 parse -mariadb/05_mariadb_specific.sql#17 parse mariadb/05_mariadb_specific.sql#26 drop mariadb/05_mariadb_specific.sql#27 drop mariadb/05_mariadb_specific.sql#28 parse @@ -284,11 +275,8 @@ mariadb/05_mariadb_specific.sql#67 parse mariadb/05_mariadb_specific.sql#68 parse mariadb/05_mariadb_specific.sql#69 parse mariadb/05_mariadb_specific.sql#9 parse -mariadb/06_etl_admin.sql#12 parse -mariadb/06_etl_admin.sql#13 parse mariadb/06_etl_admin.sql#15 parse mariadb/06_etl_admin.sql#16 parse -mariadb/06_etl_admin.sql#2 parse mariadb/06_etl_admin.sql#22 parse mariadb/06_etl_admin.sql#24 parse mariadb/06_etl_admin.sql#25 parse @@ -298,11 +286,7 @@ mariadb/06_etl_admin.sql#37 parse mariadb/06_etl_admin.sql#38 parse mariadb/06_etl_admin.sql#39 parse mariadb/06_etl_admin.sql#40 parse -mariadb/06_etl_admin.sql#5 parse -mariadb/06_etl_admin.sql#6 parse mariadb/06_etl_admin.sql#7 drop -mariadb/06_etl_admin.sql#8 parse -mariadb/06_etl_admin.sql#9 parse mariadb/07_stored_programs.sql#0 parse mariadb/07_stored_programs.sql#1 parse mariadb/07_stored_programs.sql#10 parse @@ -351,13 +335,6 @@ mariadb/09_mysql_compat_misc.sql#5 parse mariadb/09_mysql_compat_misc.sql#50 parse mariadb/09_mysql_compat_misc.sql#54 parse mariadb/09_mysql_compat_misc.sql#55 parse -mysql/01_app_crud.sql#11 parse -mysql/01_app_crud.sql#17 drop -mysql/01_app_crud.sql#18 parse -mysql/01_app_crud.sql#19 parse -mysql/01_app_crud.sql#21 parse -mysql/01_app_crud.sql#22 parse -mysql/01_app_crud.sql#23 parse mysql/01_app_crud.sql#30 parse mysql/01_app_crud.sql#6 parse mysql/02_reporting.sql#1 drop @@ -399,7 +376,6 @@ mysql/03_ddl_migrations.sql#43 parse mysql/03_ddl_migrations.sql#6 parse mysql/03_ddl_migrations.sql#7 reparse mysql/03_ddl_migrations.sql#9 parse -mysql/04_window_analytics.sql#14 parse mysql/04_window_analytics.sql#20 parse mysql/04_window_analytics.sql#21 parse mysql/04_window_analytics.sql#7 drop @@ -453,20 +429,14 @@ mysql/05_dialect_specific.sql#69 parse mysql/05_dialect_specific.sql#8 parse mysql/05_dialect_specific.sql#9 reparse mysql/06_wordpress.sql#12 parse -mysql/06_wordpress.sql#13 parse mysql/06_wordpress.sql#19 parse mysql/06_wordpress.sql#20 parse mysql/06_wordpress.sql#3 parse mysql/07_laravel_magento.sql#10 parse mysql/07_laravel_magento.sql#11 parse -mysql/07_laravel_magento.sql#15 parse mysql/07_laravel_magento.sql#7 parse mysql/07_laravel_magento.sql#8 parse -mysql/08_etl_admin.sql#10 parse -mysql/08_etl_admin.sql#11 parse -mysql/08_etl_admin.sql#12 parse mysql/08_etl_admin.sql#19 parse -mysql/08_etl_admin.sql#2 parse mysql/08_etl_admin.sql#20 parse mysql/08_etl_admin.sql#21 parse mysql/08_etl_admin.sql#22 parse @@ -477,17 +447,12 @@ mysql/08_etl_admin.sql#26 parse mysql/08_etl_admin.sql#27 parse mysql/08_etl_admin.sql#28 parse mysql/08_etl_admin.sql#29 parse -mysql/08_etl_admin.sql#3 parse mysql/08_etl_admin.sql#30 parse mysql/08_etl_admin.sql#31 parse mysql/08_etl_admin.sql#34 parse mysql/08_etl_admin.sql#35 parse mysql/08_etl_admin.sql#36 parse -mysql/08_etl_admin.sql#4 parse -mysql/08_etl_admin.sql#6 parse -mysql/08_etl_admin.sql#7 parse -mysql/08_etl_admin.sql#8 parse -mysql/08_etl_admin.sql#9 parse +mysql/08_etl_admin.sql#8 drop mysql/09_deep_nesting.sql#1 parse mysql/09_deep_nesting.sql#11 parse mysql/09_deep_nesting.sql#22 drop @@ -548,9 +513,6 @@ mysql/12_orm_misc.sql#23 parse mysql/12_orm_misc.sql#24 parse mysql/12_orm_misc.sql#4 parse mysql/12_orm_misc.sql#6 parse -oracle/01_app_crud.sql#10 parse -oracle/01_app_crud.sql#24 parse -oracle/01_app_crud.sql#25 parse oracle/01_app_crud.sql#30 parse oracle/01_app_crud.sql#31 idempotent oracle/01_app_crud.sql#33 parse @@ -633,29 +595,13 @@ oracle/05_dialect_specific.sql#6 drop oracle/05_dialect_specific.sql#7 drop oracle/05_dialect_specific.sql#8 drop oracle/05_dialect_specific.sql#9 parse -oracle/06_etl_merge.sql#1 parse oracle/06_etl_merge.sql#10 parse -oracle/06_etl_merge.sql#12 parse -oracle/06_etl_merge.sql#15 parse -oracle/06_etl_merge.sql#16 parse -oracle/06_etl_merge.sql#17 parse -oracle/06_etl_merge.sql#19 parse -oracle/06_etl_merge.sql#2 parse -oracle/06_etl_merge.sql#20 parse -oracle/06_etl_merge.sql#21 parse -oracle/06_etl_merge.sql#22 parse -oracle/06_etl_merge.sql#23 parse oracle/06_etl_merge.sql#24 reparse oracle/06_etl_merge.sql#25 parse -oracle/06_etl_merge.sql#3 parse oracle/06_etl_merge.sql#31 parse oracle/06_etl_merge.sql#32 parse oracle/06_etl_merge.sql#33 parse oracle/06_etl_merge.sql#36 parse -oracle/06_etl_merge.sql#4 parse -oracle/06_etl_merge.sql#6 parse -oracle/06_etl_merge.sql#7 parse -oracle/06_etl_merge.sql#8 parse oracle/07_admin_ops.sql#0 parse oracle/07_admin_ops.sql#1 parse oracle/07_admin_ops.sql#10 parse @@ -701,8 +647,6 @@ oracle/08_complex_nested.sql#15 parse oracle/08_complex_nested.sql#17 parse oracle/08_complex_nested.sql#19 parse postgresql/01_app_crud.sql#1 parse -postgresql/01_app_crud.sql#11 parse -postgresql/01_app_crud.sql#13 parse postgresql/01_app_crud.sql#20 parse postgresql/01_app_crud.sql#8 parse postgresql/02_reporting.sql#27 drop @@ -778,18 +722,11 @@ postgresql/05_dialect_specific.sql#53 parse postgresql/05_dialect_specific.sql#54 parse postgresql/05_dialect_specific.sql#55 parse postgresql/05_dialect_specific.sql#56 parse -postgresql/06_cte_dml_etl.sql#11 parse -postgresql/06_cte_dml_etl.sql#12 parse -postgresql/06_cte_dml_etl.sql#14 parse postgresql/06_cte_dml_etl.sql#15 parse -postgresql/06_cte_dml_etl.sql#16 parse -postgresql/06_cte_dml_etl.sql#18 parse -postgresql/06_cte_dml_etl.sql#22 parse postgresql/06_cte_dml_etl.sql#3 drop postgresql/06_cte_dml_etl.sql#5 parse postgresql/06_cte_dml_etl.sql#6 parse postgresql/06_cte_dml_etl.sql#7 parse -postgresql/06_cte_dml_etl.sql#9 parse postgresql/07_admin_ops.sql#0 parse postgresql/07_admin_ops.sql#1 parse postgresql/07_admin_ops.sql#10 parse @@ -829,9 +766,7 @@ postgresql/08_supabase_rls_misc.sql#3 parse postgresql/08_supabase_rls_misc.sql#4 parse postgresql/08_supabase_rls_misc.sql#5 parse postgresql/08_supabase_rls_misc.sql#6 parse -snowflake/01_app_crud.sql#10 parse snowflake/01_app_crud.sql#18 parse -snowflake/01_app_crud.sql#19 parse snowflake/02_reporting.sql#21 drop snowflake/02_reporting.sql#4 parse snowflake/03_ddl_migrations.sql#1 parse @@ -887,7 +822,6 @@ snowflake/05_dialect_specific.sql#49 parse snowflake/05_dialect_specific.sql#5 parse snowflake/05_dialect_specific.sql#50 parse snowflake/05_dialect_specific.sql#8 parse -snowflake/06_etl_merge.sql#10 parse snowflake/06_etl_merge.sql#13 reparse snowflake/06_etl_merge.sql#14 reparse snowflake/06_etl_merge.sql#15 parse @@ -895,7 +829,6 @@ snowflake/06_etl_merge.sql#16 reparse snowflake/06_etl_merge.sql#17 reparse snowflake/06_etl_merge.sql#18 parse snowflake/06_etl_merge.sql#19 parse -snowflake/06_etl_merge.sql#2 parse snowflake/06_etl_merge.sql#20 reparse snowflake/06_etl_merge.sql#21 parse snowflake/06_etl_merge.sql#22 reparse @@ -980,17 +913,8 @@ sqlite/01_app_crud.sql#4 parse sqlite/01_app_crud.sql#7 parse sqlite/01_app_crud.sql#8 parse sqlite/02_upsert_replace.sql#0 drop -sqlite/02_upsert_replace.sql#13 parse -sqlite/02_upsert_replace.sql#16 parse sqlite/02_upsert_replace.sql#17 parse -sqlite/02_upsert_replace.sql#19 parse sqlite/02_upsert_replace.sql#2 parse -sqlite/02_upsert_replace.sql#3 parse -sqlite/02_upsert_replace.sql#4 parse -sqlite/02_upsert_replace.sql#5 parse -sqlite/02_upsert_replace.sql#6 parse -sqlite/02_upsert_replace.sql#7 parse -sqlite/02_upsert_replace.sql#8 parse sqlite/03_ddl_migrations.sql#1 drop sqlite/03_ddl_migrations.sql#11 parse sqlite/03_ddl_migrations.sql#13 parse @@ -1055,7 +979,6 @@ sqlite/08_reporting.sql#13 parse sqlite/08_reporting.sql#15 parse sqlite/08_reporting.sql#16 parse sqlite/09_etl_admin.sql#1 drop -sqlite/09_etl_admin.sql#10 parse sqlite/09_etl_admin.sql#17 parse sqlite/09_etl_admin.sql#18 parse sqlite/09_etl_admin.sql#19 parse @@ -1071,11 +994,9 @@ sqlite/09_etl_admin.sql#37 parse sqlite/09_etl_admin.sql#38 parse sqlite/09_etl_admin.sql#39 parse sqlite/09_etl_admin.sql#40 parse -sqlite/09_etl_admin.sql#8 parse sqlite/10_mobile_offline_sync.sql#0 drop sqlite/10_mobile_offline_sync.sql#15 parse sqlite/10_mobile_offline_sync.sql#16 parse -sqlite/10_mobile_offline_sync.sql#18 parse sqlite/10_mobile_offline_sync.sql#20 drop sqlite/10_mobile_offline_sync.sql#21 parse sqlite/10_mobile_offline_sync.sql#4 parse @@ -1096,11 +1017,7 @@ sqlite/12_orm_generated.sql#16 parse sqlite/12_orm_generated.sql#8 parse sqlite/12_orm_generated.sql#9 drop sqlserver/01_app_crud.sql#1 parse -sqlserver/01_app_crud.sql#10 parse sqlserver/01_app_crud.sql#11 parse -sqlserver/01_app_crud.sql#12 parse -sqlserver/01_app_crud.sql#13 parse -sqlserver/01_app_crud.sql#14 parse sqlserver/01_app_crud.sql#16 parse sqlserver/01_app_crud.sql#18 parse sqlserver/01_app_crud.sql#2 parse @@ -1168,17 +1085,10 @@ sqlserver/05_dialect_specific.sql#6 parse sqlserver/05_dialect_specific.sql#7 parse sqlserver/05_dialect_specific.sql#8 parse sqlserver/05_dialect_specific.sql#9 parse -sqlserver/06_merge_etl.sql#0 parse -sqlserver/06_merge_etl.sql#1 parse sqlserver/06_merge_etl.sql#10 parse sqlserver/06_merge_etl.sql#11 parse -sqlserver/06_merge_etl.sql#12 parse -sqlserver/06_merge_etl.sql#13 parse sqlserver/06_merge_etl.sql#14 parse sqlserver/06_merge_etl.sql#16 parse -sqlserver/06_merge_etl.sql#18 parse -sqlserver/06_merge_etl.sql#2 parse -sqlserver/06_merge_etl.sql#3 drop sqlserver/06_merge_etl.sql#4 parse sqlserver/07_ctes_deep.sql#0 parse sqlserver/07_ctes_deep.sql#1 parse From eb4c736cf3395434a8dad8060f6f95d0e21234a4 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 18:14:38 +0530 Subject: [PATCH 05/95] fix(parser): parse utility statements and keep identifier quoting parseStatement was a closed switch, so BEGIN, COMMIT, SET, USE, CALL, GRANT, SHOW ... LIKE, T-SQL DECLARE/EXEC/IF and the rest of the utility family failed with "expected statement". Backtick- and bracket-quoted keywords lost their quoting (`key` became the KEY token, [Group] formatted as a bare GROUP), and keywords such as share, filter and owner were rejected as aliases or columns. - Add ast.TransactionStatement, SetStatement, UseStatement, CallStatement and a verbatim UtilityStatement whose SQL() reproduces the statement, rebuilt from its tokens with delimiters and escapes restored. Dispatch them by value after the typed statements, only at a statement boundary so the tail of a half-parsed statement is never split off. BEGIN/END are gated per dialect (T-SQL and PL/SQL blocks, END only in PostgreSQL and SQLite). - SHOW forms ShowStatement cannot model, Snowflake USE and stage operations are now verbatim statements that re-parse (they formatted as an "-- UNSUPPORTED" comment); MariaDB ANALYZE is an ExplainStatement. - DescribeStatement keeps DESC vs DESCRIBE and the object kind; PRAGMA accepts a schema-qualified name. - Delimited identifiers are never remapped to keywords; Identifier gains QuoteStyle so SQL() re-quotes with the source delimiters (`from`, [Group]); @/@@ variables are never quoted. - Accept keyword words as aliases after AS and the role-option keywords (OWNER, URL, ...) as column names; allow a delimited implicit alias. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_guard_test.go | 2 +- pkg/formatter/render_ddl.go | 2 +- pkg/gosqlx/utility_format_roundtrip_test.go | 80 ++ pkg/sql/ast/ast.go | 11 +- pkg/sql/ast/ast_statements.go | 5 + pkg/sql/ast/pool.go | 2 + pkg/sql/ast/quote_names.go | 67 ++ pkg/sql/ast/quoted_identifier_render_test.go | 7 +- pkg/sql/ast/sql.go | 27 +- pkg/sql/ast/utility_stmt.go | 233 ++++++ pkg/sql/parser/expressions_literal.go | 18 +- pkg/sql/parser/keyword_identifiers_test.go | 178 +++++ pkg/sql/parser/mysql.go | 10 +- pkg/sql/parser/mysql_issue521_review_test.go | 4 +- pkg/sql/parser/parser.go | 96 +-- pkg/sql/parser/pragma.go | 10 + pkg/sql/parser/preprocess.go | 11 + pkg/sql/parser/select.go | 4 +- pkg/sql/parser/utility_stmt.go | 743 +++++++++++++++++++ pkg/sql/parser/utility_stmt_test.go | 520 +++++++++++++ testdata/realworld/known_failures.txt | 291 -------- 21 files changed, 1932 insertions(+), 389 deletions(-) create mode 100644 pkg/gosqlx/utility_format_roundtrip_test.go create mode 100644 pkg/sql/ast/utility_stmt.go create mode 100644 pkg/sql/parser/keyword_identifiers_test.go create mode 100644 pkg/sql/parser/utility_stmt.go create mode 100644 pkg/sql/parser/utility_stmt_test.go diff --git a/cmd/gosqlx/cmd/format_guard_test.go b/cmd/gosqlx/cmd/format_guard_test.go index aac8cb43..8cb55cc7 100644 --- a/cmd/gosqlx/cmd/format_guard_test.go +++ b/cmd/gosqlx/cmd/format_guard_test.go @@ -75,7 +75,7 @@ func TestFormatSQL_AllowsHarmlessQuotes(t *testing.T) { {name: "alter reserved word", in: `ALTER TABLE t ADD COLUMN "order" INT`, want: `ALTER TABLE t ADD COLUMN "order" INT`}, {name: "alter reserved word postgresql", dialect: "postgresql", in: `ALTER TABLE t ADD COLUMN "order" INT`, want: `ALTER TABLE t ADD COLUMN "order" INT`}, {name: "mysql backtick plain", dialect: "mysql", in: "ALTER TABLE `Users` ADD COLUMN `c` INT", want: "ALTER TABLE Users ADD COLUMN c INT"}, - {name: "sqlserver bracket kept quoted", dialect: "sqlserver", in: "SELECT [Mixed Name] FROM t", want: `SELECT "Mixed Name" FROM t`}, + {name: "sqlserver bracket kept quoted", dialect: "sqlserver", in: "SELECT [Mixed Name] FROM t", want: "SELECT [Mixed Name] FROM t"}, {name: "oracle uppercase quoted", dialect: "oracle", in: `SELECT "ABC" FROM t`, want: "SELECT ABC FROM t"}, {name: "string literal kept", in: "SELECT a FROM t WHERE b = 'it''s'", want: "SELECT a FROM t WHERE b = 'it''s'"}, {name: "mysql escaped literal", dialect: "mysql", in: `SELECT 'a\nb'`, want: `SELECT 'a\nb'`}, diff --git a/pkg/formatter/render_ddl.go b/pkg/formatter/render_ddl.go index 4f21a8f1..de3cf7cc 100644 --- a/pkg/formatter/render_ddl.go +++ b/pkg/formatter/render_ddl.go @@ -121,7 +121,7 @@ func renderDescribe(s *ast.DescribeStatement, opts ast.FormatOptions) string { f := newNodeFormatter(opts) sb := f.sb - sb.WriteString(f.kw("DESCRIBE")) + sb.WriteString(f.kw(s.Prefix())) if s.TableName != "" { sb.WriteString(" ") sb.WriteString(ast.QuoteQualifiedName(s.TableName)) diff --git a/pkg/gosqlx/utility_format_roundtrip_test.go b/pkg/gosqlx/utility_format_roundtrip_test.go new file mode 100644 index 00000000..c08e8a07 --- /dev/null +++ b/pkg/gosqlx/utility_format_roundtrip_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// Utility statements, keyword aliases and delimited identifiers format to +// SQL that re-parses and re-formats to itself, without losing or changing +// any word of the input. +func TestFormatRoundTrip_UtilityAndIdentifiers(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + want string // formatted output + }{ + {keywords.DialectPostgreSQL, "BEGIN", "BEGIN"}, + {keywords.DialectPostgreSQL, "SET search_path TO public, analytics", "SET search_path TO public, analytics"}, + {keywords.DialectPostgreSQL, "GRANT SELECT, INSERT ON users TO app_rw", "GRANT SELECT, INSERT ON users TO app_rw"}, + {keywords.DialectPostgreSQL, "COMMENT ON COLUMN users.email IS 'Login email'", "COMMENT ON COLUMN users.email IS 'Login email'"}, + {keywords.DialectMySQL, "START TRANSACTION", "START TRANSACTION"}, + {keywords.DialectMySQL, "USE app", "USE app"}, + {keywords.DialectMySQL, "SHOW VARIABLES LIKE 'max_%'", "SHOW VARIABLES LIKE 'max_%'"}, + {keywords.DialectMySQL, "CALL refresh_stats(2024, 'full')", "CALL refresh_stats(2024, 'full')"}, + {keywords.DialectMySQL, "DESC users", "DESC users"}, + {keywords.DialectSnowflake, "DESCRIBE STAGE my_stage", "DESCRIBE STAGE my_stage"}, + {keywords.DialectSnowflake, "USE WAREHOUSE compute_wh", "USE WAREHOUSE compute_wh"}, + {keywords.DialectSnowflake, "LIST @my_stage", "LIST @my_stage"}, + {keywords.DialectSQLServer, "DECLARE @i INT = 0", "DECLARE @i INT = 0"}, + {keywords.DialectSQLServer, "EXEC dbo.p @Id = 42", "EXEC dbo.p @Id = 42"}, + {keywords.DialectSQLServer, "SET NOCOUNT ON", "SET NOCOUNT ON"}, + {keywords.DialectClickHouse, "SET max_threads = 8", "SET max_threads = 8"}, + {keywords.DialectSQLite, "PRAGMA main.synchronous = NORMAL", "PRAGMA main.synchronous = NORMAL"}, + {keywords.DialectSQLite, "END TRANSACTION", "END TRANSACTION"}, + {keywords.DialectPostgreSQL, "SELECT a AS share FROM t", "SELECT a AS \"share\"\nFROM t"}, + {keywords.DialectPostgreSQL, "SELECT owner FROM t", "SELECT owner\nFROM t"}, + {keywords.DialectMySQL, "SELECT `from` FROM t", "SELECT `from`\nFROM t"}, + {keywords.DialectMySQL, "SELECT `key` FROM t", "SELECT `key`\nFROM t"}, + {keywords.DialectMySQL, "SELECT `my col` FROM t", "SELECT `my col`\nFROM t"}, + {keywords.DialectSQLServer, "SELECT [Group] FROM t", "SELECT [Group]\nFROM t"}, + {keywords.DialectSQLServer, "SELECT @@VERSION", "SELECT @@VERSION"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + tree, err := ParseWithDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatalf("parse: %v", err) + } + f1 := FormatAST(tree) + if f1 != tt.want { + t.Errorf("format = %q, want %q", f1, tt.want) + } + tree2, err := ParseWithDialect(f1, tt.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", f1, err) + } + if f2 := FormatAST(tree2); f2 != f1 { + t.Errorf("not idempotent: %q != %q", f2, f1) + } + if lost := realworldDroppedWords(tt.sql, f1); len(lost) > 0 { + t.Errorf("formatter lost %v", lost) + } + }) + } +} diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 9b4dd92a..c6f29c4b 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -131,9 +131,16 @@ type Identifier struct { // identifier. SQL() re-quotes it when Name is a reserved word ("select") // so it re-parses as a name. Case alone ("Mixed") is not re-quoted, to // stay consistent with aliases and column lists, which record no quoting. - // It is set only for ANSI double quotes, not for MySQL backticks or SQL - // Server brackets. + // It is set only for ANSI double quotes; QuoteStyle records MySQL + // backticks and SQL Server brackets. Quoted bool + // QuoteStyle is the opening delimiter the source used for the quoted + // part(s) of the reference: '"', '`' (MySQL, MariaDB, ClickHouse) or + // '[' (SQL Server), and zero when no part was delimited. A reserved + // Name delimited in the source is re-quoted like a Quoted one, and SQL() + // quotes with the same delimiters, so a MySQL `from` is not turned into + // the string "from". + QuoteStyle rune } func (i *Identifier) expressionNode() {} diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index 8eddae16..fb81dbd1 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -729,6 +729,8 @@ type PragmaStatement struct { Name string // Pragma name, e.g. "table_info" Arg string // Optional: parenthesized arg, e.g. "users" Value string // Optional: assigned value, e.g. "WAL" + + Schema string // Optional: schema qualifier, e.g. "main" in PRAGMA main.synchronous } func (p *PragmaStatement) statementNode() {} @@ -749,6 +751,9 @@ func (s ShowStatement) Children() []Node { return nil } // DescribeStatement represents MySQL DESCRIBE/DESC/EXPLAIN table commands type DescribeStatement struct { TableName string + + Keyword string // DESCRIBE, DESC or EXPLAIN as written; "" renders DESCRIBE + ObjectKind string // object kind written before the name (TABLE, VIEW, STAGE, ...), upper-cased } func (d *DescribeStatement) statementNode() {} diff --git a/pkg/sql/ast/pool.go b/pkg/sql/ast/pool.go index eba76114..5aef4d9d 100644 --- a/pkg/sql/ast/pool.go +++ b/pkg/sql/ast/pool.go @@ -827,6 +827,8 @@ func PutDescribeStatement(stmt *DescribeStatement) { } stmt.TableName = "" + stmt.Keyword = "" + stmt.ObjectKind = "" describeStmtPool.Put(stmt) } diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index 2e23398e..ebfa8036 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -1226,3 +1226,70 @@ func ShowTakesFrom(showType string) bool { } return false } + +// writeDottedIdentifierStyle is writeDottedIdentifier quoting with the +// delimiters of style (see writeDelimitedIdentifier). +func writeDottedIdentifierStyle(buf *bytes.Buffer, name string, style rune) { + for { + dot := strings.IndexByte(name, '.') + part := name + if dot >= 0 { + part = name[:dot] + } + if isReservedName(part, true) || identifierNeedsQuoting(part) { + writeDelimitedIdentifier(buf, part, style) + } else { + buf.WriteString(part) + } + if dot < 0 { + return + } + buf.WriteByte('.') + name = name[dot+1:] + } +} + +// writeDelimitedIdentifier writes name as a delimited identifier using the +// delimiters of style: `name` for '`', [name] for '[', and ANSI "name" +// otherwise. The closing delimiter is doubled inside the name. +func writeDelimitedIdentifier(buf *bytes.Buffer, name string, style rune) { + var open, closing byte + switch style { + case '`': + open, closing = '`', '`' + case '[': + open, closing = '[', ']' + default: + writeQuotedIdentifier(buf, name) + return + } + buf.WriteByte(open) + for i := 0; i < len(name); i++ { + if name[i] == closing { + buf.WriteByte(closing) + } + buf.WriteByte(name[i]) + } + buf.WriteByte(closing) +} + +// isVariableName reports whether name is a session or system variable +// reference that is written bare: '@' or '@@' followed by letters, digits, +// '_', '$' and '.' (@x, @@VERSION, @@session.sql_mode). Quoting it would +// turn the variable into an identifier. +func isVariableName(name string) bool { + i := 0 + for i < len(name) && i < 2 && name[i] == '@' { + i++ + } + if i == 0 || i == len(name) || name[i] == '.' { + return false + } + for ; i < len(name); i++ { + c := name[i] + if c != '_' && c != '$' && c != '.' && (c < '0' || c > '9') && (c|0x20 < 'a' || c|0x20 > 'z') { + return false + } + } + return true +} diff --git a/pkg/sql/ast/quoted_identifier_render_test.go b/pkg/sql/ast/quoted_identifier_render_test.go index 06f4779f..ef6e7fb9 100644 --- a/pkg/sql/ast/quoted_identifier_render_test.go +++ b/pkg/sql/ast/quoted_identifier_render_test.go @@ -171,8 +171,8 @@ func TestQuotedIdentifierRender_DialectGuards_Issue611(t *testing.T) { {"snowflake_stage_path", keywords.DialectSnowflake, `SELECT $1 FROM @mystage/data`, `SELECT $1 FROM @mystage/data`}, {"clickhouse_tuple_access", keywords.DialectClickHouse, `SELECT d.1 FROM t`, `SELECT d.1 FROM t`}, {"oracle_dual", keywords.DialectOracle, `SELECT 1 FROM DUAL`, `SELECT 1 FROM DUAL`}, - // Backtick-quoted names are not ANSI-quoted: Quoted stays false and - // the rendering is unchanged. + // Backtick-quoted names are not ANSI-quoted: Quoted stays false, + // QuoteStyle records the backtick, and a plain name renders bare. {"mysql_backtick", keywords.DialectMySQL, "SELECT `id` FROM t", `SELECT id FROM t`}, } for _, tc := range cases { @@ -191,6 +191,9 @@ func TestQuotedIdentifierRender_DialectGuards_Issue611(t *testing.T) { if id.Quoted { t.Errorf("backtick identifier: Quoted = true, want false") } + if id.QuoteStyle != '`' { + t.Errorf("backtick identifier: QuoteStyle = %q, want '`'", id.QuoteStyle) + } } }) } diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 7372fb1b..640ff826 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -100,6 +100,9 @@ func (i *Identifier) SQL() string { if i == nil { return "" } + if i.Table == "" && !i.Quoted && i.QuoteStyle == 0 && isVariableName(i.Name) { + return i.Name + } if !i.quotedReserved() && !identifierNeedsQuoting(i.Name) && (i.Table == "" || !qualifierNeedsQuoting(i.Table)) { if i.Table == "" { return i.Name @@ -116,21 +119,25 @@ func (i *Identifier) writeSQL(buf *bytes.Buffer) { if i == nil { return } + if i.Table == "" && !i.Quoted && i.QuoteStyle == 0 && isVariableName(i.Name) { + buf.WriteString(i.Name) + return + } if i.Table != "" { - writeDottedIdentifier(buf, i.Table) + writeDottedIdentifierStyle(buf, i.Table, i.QuoteStyle) buf.WriteByte('.') } - if i.quotedReserved() { - writeQuotedIdentifier(buf, i.Name) + if i.quotedReserved() || identifierNeedsQuoting(i.Name) { + writeDelimitedIdentifier(buf, i.Name, i.QuoteStyle) return } - writeSafeIdentifier(buf, i.Name) + buf.WriteString(i.Name) } // quotedReserved reports whether i is a Quoted name that is a reserved word, // which must stay quoted to re-parse as a name. func (i *Identifier) quotedReserved() bool { - return i.Quoted && isReservedName(i.Name, false) + return (i.Quoted || i.QuoteStyle != 0) && isReservedName(i.Name, false) } // qualifierNeedsQuoting reports whether some dot-separated part of the @@ -2724,6 +2731,14 @@ func (p *PragmaStatement) SQL() string { sb := getBuf() defer putBuf(sb) sb.WriteString("PRAGMA ") + if p.Schema != "" { + if !isBareNameChars(p.Schema, false) { + writeQuotedIdentifier(sb, p.Schema) + } else { + sb.WriteString(p.Schema) + } + sb.WriteByte('.') + } if p.Name != "" && !isBareNameChars(p.Name, false) { writeQuotedIdentifier(sb, p.Name) } else { @@ -2768,7 +2783,7 @@ func (d *DescribeStatement) SQL() string { if d == nil { return "" } - return "DESCRIBE " + QuoteQualifiedName(d.TableName) + return d.Prefix() + " " + QuoteQualifiedName(d.TableName) } // SQL returns the SQL string for an EXPLAIN / DESCRIBE . diff --git a/pkg/sql/ast/utility_stmt.go b/pkg/sql/ast/utility_stmt.go new file mode 100644 index 00000000..019368f6 --- /dev/null +++ b/pkg/sql/ast/utility_stmt.go @@ -0,0 +1,233 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This file holds the utility-statement family: session, transaction, +// privilege and maintenance statements (BEGIN, COMMIT, SET, USE, CALL, GRANT, +// VACUUM, ...). The parser records the statement text exactly as written +// (rebuilt from its tokens, with comments removed and whitespace collapsed) +// next to whatever structure is cheap to extract, and SQL() returns that text +// so formatting never changes the meaning of a statement GoSQLX does not +// model in full. + +package ast + +import ( + "bytes" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// UtilityStatement is a statement GoSQLX recognizes but does not model in +// detail, such as GRANT, REVOKE, COMMENT ON, VACUUM, ANALYZE, LOCK, KILL, +// PREPARE / EXECUTE, SHOW with filters, or T-SQL DECLARE / IF / PRINT. +// Unlike UnsupportedStatement, it is not a placeholder: Text reproduces the +// statement and re-parses to the same node. +type UtilityStatement struct { + // Kind is the statement's leading keyword, upper-cased: "GRANT", + // "REVOKE", "COMMENT", "SHOW", "VACUUM", "DECLARE", ... + Kind string + // Text is the full statement text as written. + Text string + Pos models.Location +} + +func (u *UtilityStatement) statementNode() {} +func (u UtilityStatement) TokenLiteral() string { return u.Kind } +func (u UtilityStatement) Children() []Node { return nil } + +// SQL returns the statement text as written. +func (u *UtilityStatement) SQL() string { + if u == nil { + return "" + } + if u.Text != "" { + return u.Text + } + return u.Kind +} + +// TransactionStatement is a transaction-control statement: BEGIN, +// START TRANSACTION, COMMIT, ROLLBACK [TO SAVEPOINT name], SAVEPOINT name, +// RELEASE SAVEPOINT name, END (PostgreSQL / SQLite) and the T-SQL +// BEGIN / COMMIT / ROLLBACK / SAVE TRANSACTION forms. +type TransactionStatement struct { + // Action is the normalized operation: "BEGIN", "START TRANSACTION", + // "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE SAVEPOINT" or "END". + Action string + // Savepoint names the savepoint of SAVEPOINT, RELEASE SAVEPOINT, + // ROLLBACK TO and T-SQL SAVE TRANSACTION; empty otherwise. + Savepoint string + // Text is the full statement text as written, including modifiers such + // as ISOLATION LEVEL, READ ONLY, WORK or AND CHAIN. + Text string + Pos models.Location +} + +func (t *TransactionStatement) statementNode() {} +func (t TransactionStatement) TokenLiteral() string { return t.Action } +func (t TransactionStatement) Children() []Node { return nil } + +// SQL returns the statement text as written. A node built without Text +// renders Action followed by the savepoint, if any. +func (t *TransactionStatement) SQL() string { + if t == nil { + return "" + } + if t.Text != "" { + return t.Text + } + if t.Savepoint == "" { + return t.Action + } + if t.Action == "ROLLBACK" { + return "ROLLBACK TO SAVEPOINT " + QuoteName(t.Savepoint) + } + return t.Action + " " + QuoteName(t.Savepoint) +} + +// SetStatement is a SET statement that assigns a session, global or user +// variable or a session property: SET search_path TO a, b; SET @a := 1; +// SET GLOBAL max_connections = 500; SET NAMES utf8mb4; SET NOCOUNT ON; +// SET TRANSACTION ISOLATION LEVEL ...; SET LOCAL statement_timeout = '5s'. +type SetStatement struct { + // Scope is the scope keyword, upper-cased: GLOBAL, SESSION, LOCAL, + // PERSIST or PERSIST_ONLY; empty when none was written. + Scope string + // Name is the variable or property set, as written ("search_path", + // "@a", "@@session.sql_mode", "NAMES", "TRANSACTION", "NOCOUNT"). For a + // list of assignments (SET @a = 1, @b = 2) it is the first one. + Name string + // Text is the full statement text as written. + Text string + Pos models.Location +} + +func (s *SetStatement) statementNode() {} +func (s SetStatement) TokenLiteral() string { return "SET" } +func (s SetStatement) Children() []Node { return nil } + +// SQL returns the statement text as written. +func (s *SetStatement) SQL() string { + if s == nil { + return "" + } + if s.Text != "" { + return s.Text + } + out := "SET" + if s.Scope != "" { + out += " " + s.Scope + } + if s.Name != "" { + out += " " + s.Name + } + return out +} + +// UseStatement switches the current database, schema, warehouse or role: +// USE app (MySQL, SQL Server, ClickHouse), USE [Northwind] (SQL Server), +// USE WAREHOUSE compute_wh / USE SCHEMA db.s (Snowflake). +type UseStatement struct { + // ObjectKind is the Snowflake object kind, upper-cased: WAREHOUSE, + // DATABASE, SCHEMA, ROLE or SECONDARY ROLES; empty for a plain USE. + ObjectKind string + // Name is the (possibly qualified) name switched to. + Name string + // Text is the full statement text as written. + Text string + Pos models.Location +} + +func (u *UseStatement) statementNode() {} +func (u UseStatement) TokenLiteral() string { return "USE" } +func (u UseStatement) Children() []Node { return nil } + +// SQL returns the statement text as written. A node built without Text +// renders USE [kind] name with the name quoted where needed. +func (u *UseStatement) SQL() string { + if u == nil { + return "" + } + if u.Text != "" { + return u.Text + } + out := "USE " + if u.ObjectKind != "" { + out += u.ObjectKind + " " + } + return out + QuoteQualifiedName(u.Name) +} + +// CallStatement invokes a stored procedure: CALL name(arg, ...). +type CallStatement struct { + // Name is the (possibly qualified) procedure name. + Name string + // Arguments are the call arguments; empty for CALL p() and CALL p. + Arguments []Expression + // NoParens records CALL p written without an argument list (MySQL). + NoParens bool + Pos models.Location +} + +func (c *CallStatement) statementNode() {} +func (c CallStatement) TokenLiteral() string { return "CALL" } + +// Children returns the call arguments. +func (c CallStatement) Children() []Node { + if len(c.Arguments) == 0 { + return nil + } + nodes := make([]Node, 0, len(c.Arguments)) + for _, a := range c.Arguments { + if a != nil { + nodes = append(nodes, a) + } + } + return nodes +} + +// SQL returns CALL name(args). +func (c *CallStatement) SQL() string { + if c == nil { + return "" + } + var buf bytes.Buffer + buf.WriteString("CALL ") + buf.WriteString(QuoteQualifiedName(c.Name)) + if c.NoParens && len(c.Arguments) == 0 { + return buf.String() + } + buf.WriteByte('(') + args := make([]string, 0, len(c.Arguments)) + for _, a := range c.Arguments { + args = append(args, exprSQL(a)) + } + buf.WriteString(strings.Join(args, ", ")) + buf.WriteByte(')') + return buf.String() +} + +// Prefix returns the keyword and object kind that precede the table name of +// a DESCRIBE statement, e.g. "DESCRIBE", "DESC" or "DESCRIBE TABLE". +func (d *DescribeStatement) Prefix() string { + kw := d.Keyword + if kw == "" { + kw = "DESCRIBE" + } + if d.ObjectKind == "" { + return kw + } + return kw + " " + d.ObjectKind +} diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 63c00919..19b458a8 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -163,13 +163,13 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { return &ast.FunctionCall{Name: identName, Pos: identPos}, nil } - if p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeDoubleQuotedString) || ((p.dialect == string(keywords.DialectSQLServer) || p.dialect == string(keywords.DialectClickHouse)) && p.isNonReservedKeyword()) { + if p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeDoubleQuotedString) || p.isKeywordColumnName() || ((p.dialect == string(keywords.DialectSQLServer) || p.dialect == string(keywords.DialectClickHouse)) && p.isNonReservedKeyword()) { // Handle identifiers and function calls // Double-quoted strings are treated as identifiers in SQL (e.g., "column_name") // Non-reserved keywords (TARGET, SOURCE, etc.) can also be used as identifiers identPos := p.currentLocation() identName := p.currentToken.Token.Value - identQuoted := p.isType(models.TokenTypeDoubleQuotedString) + identQuote := p.currentIdentQuote() p.advance() // Check for function call (identifier followed by parentheses) @@ -201,7 +201,7 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { } // Handle regular identifier or qualified identifier (table.column or table.*) - ident := &ast.Identifier{Name: identName, Pos: identPos, Quoted: identQuoted} + ident := &ast.Identifier{Name: identName, Pos: identPos, Quoted: identQuote == '"', QuoteStyle: identQuote} // Check for qualified identifier (table.column) or qualified asterisk (table.*) if p.isType(models.TokenTypePeriod) { @@ -224,7 +224,11 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { // be copied from Name before Name is overwritten. ident.Table = ident.Name ident.Name = p.currentToken.Token.Value - ident.Quoted = p.isType(models.TokenTypeDoubleQuotedString) + q := p.currentIdentQuote() + ident.Quoted = q == '"' + if q != 0 { + ident.QuoteStyle = q + } p.advance() // Multi-part reference: db.table.column (MySQL/MariaDB), @@ -568,7 +572,11 @@ func (p *Parser) parseMultiPartColumnRef(ident *ast.Identifier) error { } case p.isQualifiedNamePart(): parts[n] = p.currentToken.Token.Value - lastQuoted = p.isType(models.TokenTypeDoubleQuotedString) + q := p.currentIdentQuote() + lastQuoted = q == '"' + if q != 0 { + ident.QuoteStyle = q + } n++ p.advance() default: diff --git a/pkg/sql/parser/keyword_identifiers_test.go b/pkg/sql/parser/keyword_identifiers_test.go new file mode 100644 index 00000000..1138abfe --- /dev/null +++ b/pkg/sql/parser/keyword_identifiers_test.go @@ -0,0 +1,178 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// firstColumn returns the first select-list item of a single SELECT. +func utilFirstColumn(t *testing.T, d keywords.SQLDialect, sql string) ast.Expression { + t.Helper() + stmt := utilParseOne(t, d, sql) + sel, ok := stmt.(*ast.SelectStatement) + if !ok { + t.Fatalf("%q: got %T, want *ast.SelectStatement", sql, stmt) + } + if len(sel.Columns) == 0 { + t.Fatalf("%q: no select columns", sql) + } + return sel.Columns[0] +} + +// After AS only a name can follow, so keywords spelled as plain words are +// accepted as select-list aliases; words that start a clause are not. +func TestParser_KeywordAliasAfterAS(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + alias string + }{ + {keywords.DialectPostgreSQL, "SELECT a AS share FROM t", "share"}, + {keywords.DialectPostgreSQL, "SELECT a AS filter FROM t", "filter"}, + {keywords.DialectPostgreSQL, "SELECT a AS key FROM t", "key"}, + {keywords.DialectMariaDB, "SELECT a AS next FROM t", "next"}, + {keywords.DialectMariaDB, "SELECT a AS first FROM t", "first"}, + {keywords.DialectMySQL, "SELECT a AS last FROM t", "last"}, + {keywords.DialectSnowflake, "SELECT a AS final FROM t", "final"}, + {keywords.DialectSnowflake, "SELECT a AS index FROM t", "index"}, + {keywords.DialectMySQL, "SELECT 1 AS `exists`", "exists"}, + {keywords.DialectPostgreSQL, "SELECT d AS date FROM t", "date"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + col := utilFirstColumn(t, tt.dialect, tt.sql) + ae, ok := col.(*ast.AliasedExpression) + if !ok { + t.Fatalf("got %T, want *ast.AliasedExpression", col) + } + if ae.Alias != tt.alias { + t.Errorf("Alias = %q, want %q", ae.Alias, tt.alias) + } + }) + } + + for _, sql := range []string{"SELECT a AS FROM t", "SELECT a AS WHERE x = 1", "SELECT a AS UNION SELECT b"} { + if _, err := ParseWithDialect(sql, keywords.DialectPostgreSQL); err == nil { + t.Errorf("%q parsed, want an error", sql) + } + } +} + +// Role-option words (OWNER, URL, MEMBER, ...) are keyword tokens only so the +// role and ALTER statements can match them; as an operand they are names. +func TestParser_KeywordColumnNames(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + name string + table string + }{ + {keywords.DialectPostgreSQL, "SELECT owner FROM t", "owner", ""}, + {keywords.DialectPostgreSQL, "SELECT t.owner FROM t", "owner", "t"}, + {keywords.DialectMySQL, "SELECT url FROM links", "url", ""}, + {keywords.DialectPostgreSQL, "SELECT member FROM teams WHERE member = 1", "member", ""}, + {keywords.DialectSnowflake, "SELECT policy FROM t", "policy", ""}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + col := utilFirstColumn(t, tt.dialect, tt.sql) + id, ok := col.(*ast.Identifier) + if !ok { + t.Fatalf("got %T, want *ast.Identifier", col) + } + if id.Name != tt.name || id.Table != tt.table { + t.Errorf("name/table = %q/%q, want %q/%q", id.Name, id.Table, tt.name, tt.table) + } + }) + } +} + +// A delimited identifier is a name, never a keyword: `key`, `from` and +// [Group] parse as identifiers, record their delimiter, and render with it. +func TestParser_DelimitedKeywordIdentifiers(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + name string + style rune + want string // Identifier.SQL() + }{ + {keywords.DialectMySQL, "SELECT `key` FROM t", "key", '`', "`key`"}, + {keywords.DialectMySQL, "SELECT `from` FROM t", "from", '`', "`from`"}, + {keywords.DialectMariaDB, "SELECT `default` FROM t", "default", '`', "`default`"}, + {keywords.DialectMySQL, "SELECT `my col` FROM t", "my col", '`', "`my col`"}, + {keywords.DialectMySQL, "SELECT `users`.`select` FROM users", "select", '`', "users.`select`"}, + {keywords.DialectMySQL, "SELECT `id` FROM t", "id", '`', "id"}, + {keywords.DialectSQLServer, "SELECT [Group] FROM t", "Group", '[', "[Group]"}, + {keywords.DialectSQLServer, "SELECT [Order].[x y] FROM t", "x y", '[', "[Order].[x y]"}, + {keywords.DialectPostgreSQL, `SELECT "select" FROM t`, "select", '"', `"select"`}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + col := utilFirstColumn(t, tt.dialect, tt.sql) + id, ok := col.(*ast.Identifier) + if !ok { + t.Fatalf("got %T, want *ast.Identifier", col) + } + if id.Name != tt.name || id.QuoteStyle != tt.style { + t.Errorf("name/style = %q/%q, want %q/%q", id.Name, id.QuoteStyle, tt.name, tt.style) + } + if got := id.SQL(); got != tt.want { + t.Errorf("SQL() = %s, want %s", got, tt.want) + } + // Quoted keeps its meaning: ANSI double quotes only. + if id.Quoted != (tt.style == '"') { + t.Errorf("Quoted = %v for style %q", id.Quoted, tt.style) + } + }) + } +} + +// A delimited identifier may follow a column as an implicit alias. +func TestParser_DelimitedImplicitAlias(t *testing.T) { + col := utilFirstColumn(t, keywords.DialectMariaDB, "SELECT b `z` FROM t") + ae, ok := col.(*ast.AliasedExpression) + if !ok { + t.Fatalf("got %T, want *ast.AliasedExpression", col) + } + if ae.Alias != "z" { + t.Errorf("Alias = %q, want z", ae.Alias) + } +} + +// SQL Server system variables are names that are never quoted. +func TestParser_SystemVariableNotQuoted(t *testing.T) { + col := utilFirstColumn(t, keywords.DialectSQLServer, "SELECT @@VERSION") + id, ok := col.(*ast.Identifier) + if !ok { + t.Fatalf("got %T, want *ast.Identifier", col) + } + if got := id.SQL(); got != "@@VERSION" { + t.Errorf("SQL() = %s, want @@VERSION", got) + } + // A delimited name that happens to start with @ stays delimited. + col = utilFirstColumn(t, keywords.DialectSQLServer, "SELECT [@@VERSION] FROM t") + id, ok = col.(*ast.Identifier) + if !ok { + t.Fatalf("got %T, want *ast.Identifier", col) + } + if got := id.SQL(); got != "[@@VERSION]" { + t.Errorf("SQL() = %s, want [@@VERSION]", got) + } +} diff --git a/pkg/sql/parser/mysql.go b/pkg/sql/parser/mysql.go index 77cff7df..244fb365 100644 --- a/pkg/sql/parser/mysql.go +++ b/pkg/sql/parser/mysql.go @@ -171,6 +171,7 @@ func (p *Parser) parseDescribeStatement(keyword string) (ast.Statement, error) { return explain, nil } + kind := "" // Snowflake: DESCRIBE TABLE , DESCRIBE VIEW , DESCRIBE STAGE // , etc. Also MySQL's DESCRIBE .. Accept and consume a // leading object-kind keyword (TABLE, VIEW, DATABASE, SCHEMA) before the @@ -185,7 +186,12 @@ func (p *Parser) parseDescribeStatement(keyword string) (ast.Statement, error) { strings.EqualFold(p.currentToken.Token.Value, "FUNCTION") || strings.EqualFold(p.currentToken.Token.Value, "PROCEDURE") || strings.EqualFold(p.currentToken.Token.Value, "WAREHOUSE") { - p.advance() + // A name follows the kind; DESCRIBE table (a table named "table") + // has nothing after it. + if next := p.peekToken().Token.Type; next != models.TokenTypeEOF && next != models.TokenTypeSemicolon { + kind = strings.ToUpper(p.currentToken.Token.Value) + p.advance() + } } name, err := p.parseQualifiedName() @@ -194,6 +200,8 @@ func (p *Parser) parseDescribeStatement(keyword string) (ast.Statement, error) { } desc := ast.GetDescribeStatement() desc.TableName = name + desc.Keyword = keyword + desc.ObjectKind = kind return desc, nil } diff --git a/pkg/sql/parser/mysql_issue521_review_test.go b/pkg/sql/parser/mysql_issue521_review_test.go index acea62be..9a9d523f 100644 --- a/pkg/sql/parser/mysql_issue521_review_test.go +++ b/pkg/sql/parser/mysql_issue521_review_test.go @@ -52,13 +52,13 @@ func TestIssue521_QuotedMultiPartRoundTrip(t *testing.T) { want string }{ {"mysql backticks with hyphen", keywords.DialectMySQL, - "SELECT `my-db`.`t`.`c` FROM t", `SELECT "my-db".t.c FROM t`}, + "SELECT `my-db`.`t`.`c` FROM t", "SELECT `my-db`.t.c FROM t"}, {"postgres double quotes with space", keywords.DialectPostgreSQL, `SELECT "my db"."t"."c" FROM t`, `SELECT "my db".t.c FROM t`}, {"four parts, quoted middle", keywords.DialectSQLServer, `SELECT c."s-1".t.col FROM t`, `SELECT c."s-1".t.col FROM t`}, {"two parts unchanged", keywords.DialectMySQL, - "SELECT `my-db`.`t` FROM t", `SELECT "my-db".t FROM t`}, + "SELECT `my-db`.`t` FROM t", "SELECT `my-db`.t FROM t"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index d3c6a4af..f8dfc94a 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -848,8 +848,7 @@ func (p *Parser) parseStatement() (ast.Statement, error) { p.advance() return p.parseTruncateStatement() case models.TokenTypeShow: - p.advance() - return p.parseShowStatement() + return p.parseShowOrUtility() case models.TokenTypeDescribe, models.TokenTypeExplain, models.TokenTypeDesc: // DESC is the ORDER-BY sort-direction token but also a synonym for // DESCRIBE at statement position (Oracle, Snowflake, MySQL). @@ -884,12 +883,6 @@ func (p *Parser) parseStatement() (ast.Statement, error) { p.advance() return p.parsePragmaStatement() } - // Snowflake session-context switches: USE [WAREHOUSE|DATABASE|SCHEMA|ROLE] . - // USE is not tokenized as a keyword; dispatch by value in the Snowflake dialect. - if p.dialect == string(keywords.DialectSnowflake) && - strings.EqualFold(p.currentToken.Token.Value, "USE") { - return p.parseSnowflakeUseStatement() - } // Snowflake stage operations: COPY INTO, PUT, GET, LIST, REMOVE. // All tokenize as identifiers; parse-only stubs that consume the // rest of the statement body. @@ -901,38 +894,15 @@ func (p *Parser) parseStatement() (ast.Statement, error) { } } } - return nil, p.expectedError("statement") -} - -// parseSnowflakeUseStatement parses: -// -// USE [WAREHOUSE | DATABASE | SCHEMA | ROLE] -// -// The object-kind keyword is optional (plain "USE " switches the current -// database). Returned as an UnsupportedStatement until a dedicated UseStatement -// node is introduced. -func (p *Parser) parseSnowflakeUseStatement() (ast.Statement, error) { - p.advance() // Consume USE - var rawParts []string - rawParts = append(rawParts, "USE") - // Optional object kind. - switch strings.ToUpper(p.currentToken.Token.Value) { - case "WAREHOUSE", "DATABASE", "SCHEMA", "ROLE": - rawParts = append(rawParts, strings.ToUpper(p.currentToken.Token.Value)) - p.advance() + // Utility statements (BEGIN, COMMIT, SET, USE, CALL, GRANT, ...) are + // dispatched by value, after every typed statement has been ruled out. + if stmt, ok, err := p.parseUtilityStatement(); ok { + return stmt, err } - name, err := p.parseQualifiedName() - if err != nil { - return nil, p.qualifiedNameError(err, "name after USE") - } - rawParts = append(rawParts, name) - stmt := ast.GetUnsupportedStatement() - stmt.Kind = "USE" - stmt.RawSQL = strings.Join(rawParts, " ") - return stmt, nil + return nil, p.expectedError("statement") } -// parseSnowflakeStageStatement parses Snowflake stage operations as stubs: +// parseSnowflakeStageStatement parses Snowflake stage operations: // // COPY INTO FROM [options] // PUT file:// @ @@ -940,45 +910,11 @@ func (p *Parser) parseSnowflakeUseStatement() (ast.Statement, error) { // LIST @ (or LS) // REMOVE @/ // -// The statement is consumed token-by-token (tracking balanced parens) until -// ';' or EOF and returned as an UnsupportedStatement tagged with the -// operation kind. No full AST modeling yet; follow-up work. +// The statement is kept verbatim as a UtilityStatement tagged with the +// operation kind; its text re-parses to the same statement. func (p *Parser) parseSnowflakeStageStatement(kind string) (ast.Statement, error) { - p.advance() // Consume leading kind token - - var rawParts []string - rawParts = append(rawParts, kind) - - // COPY INTO: consume the INTO keyword if present. - if kind == "COPY" && p.isType(models.TokenTypeInto) { - rawParts = append(rawParts, "INTO") - p.advance() - } - - // Consume the rest of the statement body, capturing tokens for RawSQL. - depth := 0 - for { - t := p.currentToken.Token.Type - if t == models.TokenTypeEOF { - break - } - if t == models.TokenTypeSemicolon && depth == 0 { - break - } - if p.currentToken.Token.Value != "" { - rawParts = append(rawParts, p.currentToken.Token.Value) - } - if t == models.TokenTypeLParen { - depth++ - } else if t == models.TokenTypeRParen { - depth-- - } - p.advance() - } - stub := ast.GetUnsupportedStatement() - stub.Kind = kind - stub.RawSQL = strings.Join(rawParts, " ") - return stub, nil + stmt, _, err := p.parseVerbatimStatement(kind) + return stmt, err } // NewParser creates a new parser with optional configuration. @@ -1138,7 +1074,8 @@ func (p *Parser) parseIdent() *ast.Identifier { return nil } pos := p.currentLocation() - ident := &ast.Identifier{Name: p.currentToken.Token.Value, Pos: pos, Quoted: p.isType(models.TokenTypeDoubleQuotedString)} + q := p.currentIdentQuote() + ident := &ast.Identifier{Name: p.currentToken.Token.Value, Pos: pos, Quoted: q == '"', QuoteStyle: q} p.advance() return ident } @@ -1276,6 +1213,13 @@ func (p *Parser) isNonReservedKeyword() bool { switch p.currentToken.Token.Type { case models.TokenTypeTarget, models.TokenTypeSource, models.TokenTypeMatched: return true + case models.TokenTypeOwner, models.TokenTypeUrl, models.TokenTypeMember, + models.TokenTypeConnector, models.TokenTypePolicy, models.TokenTypeUntil, + models.TokenTypeValid, models.TokenTypeDcproperties, models.TokenTypeNologin, + models.TokenTypeNocreatedb, models.TokenTypeNocreaterole, models.TokenTypeNosuperuser: + // Role-option words (ALTER ... OWNER TO, VALID UNTIL) are keyword + // tokens only so those statements can match them by type. + return true case models.TokenTypeTable, models.TokenTypeIndex, models.TokenTypeView, models.TokenTypeKey, models.TokenTypeColumn, models.TokenTypeDatabase, models.TokenTypePartition, models.TokenTypeRows, models.TokenTypeDefault: diff --git a/pkg/sql/parser/pragma.go b/pkg/sql/parser/pragma.go index 932477b9..9357b956 100644 --- a/pkg/sql/parser/pragma.go +++ b/pkg/sql/parser/pragma.go @@ -40,6 +40,16 @@ func (p *Parser) parsePragmaStatement() (*ast.PragmaStatement, error) { } stmt.Name = p.currentToken.Token.Value p.advance() + // Schema-qualified pragma: PRAGMA main.synchronous = NORMAL. + if p.isType(models.TokenTypePeriod) { + p.advance() // consume . + if !p.isIdentifier() && !p.isNonReservedKeyword() && !p.isType(models.TokenTypeKeyword) { + return nil, p.expectedError("PRAGMA name after schema") + } + stmt.Schema = stmt.Name + stmt.Name = p.currentToken.Token.Value + p.advance() + } switch { case p.isType(models.TokenTypeLParen): diff --git a/pkg/sql/parser/preprocess.go b/pkg/sql/parser/preprocess.go index 507c5a51..fe93f1bf 100644 --- a/pkg/sql/parser/preprocess.go +++ b/pkg/sql/parser/preprocess.go @@ -185,6 +185,17 @@ func normalizeToken(t models.TokenWithSpan) models.TokenWithSpan { // Identifier tokens whose *value* is a keyword that the parser dispatches // on by type need to be remapped. if t.Token.Type == models.TokenTypeIdentifier { + // A delimited identifier (`key`, [Group]) is a name, never a + // keyword. The tokenizer strips backticks without recording them, + // so a single-line span wider than the value marks one; record + // the backtick so later stages can tell it from a bare word. + if t.Token.Quote != 0 { + return t + } + if t.Start.Line != 0 && t.Start.Line == t.End.Line && t.End.Column-t.Start.Column > len(t.Token.Value) { + t.Token.Quote = '`' + return t + } if remapped := identifierKeywordType(t.Token.Value); remapped != models.TokenTypeUnknown { t.Token.Type = remapped } diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index e3fd52bb..a6e77992 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -411,7 +411,7 @@ func (p *Parser) parseSelectColumnList() ([]ast.Expression, error) { // Optional alias: AS name or implicit name (non-identifier expressions only) if p.isType(models.TokenTypeAs) { p.advance() // Consume AS - if !p.isIdentifier() { + if !p.isAliasAfterAS() { return nil, p.expectedError("alias name after AS") } alias := p.currentToken.Token.Value @@ -423,7 +423,7 @@ func (p *Parser) parseSelectColumnList() ([]ast.Expression, error) { // that are known pseudo-columns (ROWNUM, SYSDATE, LEVEL, etc.) where // the alias pattern is idiomatic: SELECT ROWNUM rn FROM ... ident, isIdent := expr.(*ast.Identifier) - allowAlias := !isIdent || (isIdent && ident.Table == "" && p.isOraclePseudoColumn2(ident.Name)) + allowAlias := !isIdent || (isIdent && ident.Table == "" && p.isOraclePseudoColumn2(ident.Name)) || p.isQuotedIdentifierToken() if allowAlias { alias := p.currentToken.Token.Value p.advance() diff --git a/pkg/sql/parser/utility_stmt.go b/pkg/sql/parser/utility_stmt.go new file mode 100644 index 00000000..5a7f2c8b --- /dev/null +++ b/pkg/sql/parser/utility_stmt.go @@ -0,0 +1,743 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// utility_stmt.go parses the utility-statement family: transaction control, +// SET, USE, CALL and the long tail of session, privilege and maintenance +// statements (GRANT, REVOKE, COMMENT ON, VACUUM, LOCK, KILL, PREPARE, T-SQL +// DECLARE / IF / PRINT, ...). parseStatement reaches this code only after its +// type switch found no other statement, so ordinary queries never pay for it. + +package parser + +import ( + "strings" + "unicode/utf8" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// maxUtilityWordLen bounds the leading words matched by utilityWord. +const maxUtilityWordLen = 16 + +// utilityWord returns the upper-cased value of the token at index i when it +// is a bare (unquoted) word that could start or qualify a utility statement, +// and "" otherwise. +func (p *Parser) utilityWord(i int) string { + if i < 0 || i >= len(p.tokens) { + return "" + } + tok := &p.tokens[i] + v := tok.Token.Value + if v == "" || len(v) > maxUtilityWordLen || tok.Token.Quote != 0 || isQuotedSpan(tok) { + return "" + } + switch tok.Token.Type { + case models.TokenTypeDoubleQuotedString, models.TokenTypeString, + models.TokenTypeSingleQuotedString, models.TokenTypeDollarQuotedString, + models.TokenTypeNumber, models.TokenTypePlaceholder: + return "" + } + for j := 0; j < len(v); j++ { + c := v[j] + if c != '_' && (c|0x20 < 'a' || c|0x20 > 'z') { + return "" + } + } + return strings.ToUpper(v) +} + +// isQuotedSpan reports whether a token's source span is wider than its value +// on a single line, which is how a delimited identifier (`x`, [x]) whose +// delimiters the tokenizer stripped is recognized. +func isQuotedSpan(tok *models.TokenWithSpan) bool { + return tok.Start.Line != 0 && tok.Start.Line == tok.End.Line && + tok.End.Column-tok.Start.Column > len(tok.Token.Value) +} + +// parseUtilityStatement parses a utility statement starting at the current +// token. ok is false when the current token does not start one, in which case +// the parser position is unchanged. +func (p *Parser) parseUtilityStatement() (stmt ast.Statement, ok bool, err error) { + if !p.atStatementBoundary() { + return nil, false, nil + } + word := p.utilityWord(p.currentPos) + if word == "" { + return nil, false, nil + } + next := p.utilityWord(p.currentPos + 1) + switch word { + case "BEGIN": + if p.beginIsBlock(next) { + return p.parseVerbatimStatement("BEGIN") + } + return p.parseTransactionStatement("BEGIN") + case "START": + if next != "TRANSACTION" { + return nil, false, nil + } + return p.parseTransactionStatement("START TRANSACTION") + case "COMMIT", "ROLLBACK", "SAVEPOINT": + return p.parseTransactionStatement(word) + case "ABORT": + if p.dialect != string(keywords.DialectPostgreSQL) && p.dialect != "" { + return nil, false, nil + } + return p.parseTransactionStatement("ROLLBACK") + case "RELEASE": + return p.parseTransactionStatement("RELEASE SAVEPOINT") + case "SAVE": + if p.dialect != string(keywords.DialectSQLServer) || (next != "TRAN" && next != "TRANSACTION") { + return nil, false, nil + } + return p.parseTransactionStatement("SAVEPOINT") + case "END": + switch p.dialect { + case string(keywords.DialectPostgreSQL), string(keywords.DialectSQLite), "": + // END [WORK | TRANSACTION] commits the current transaction. + return p.parseTransactionStatement("END") + case string(keywords.DialectSQLServer): + // END, END TRY, END CATCH close a T-SQL block. + return p.parseVerbatimStatement("END") + } + return nil, false, nil + case "SET": + return p.parseSetStatement() + case "USE": + return p.parseUseStatement() + case "CALL": + return p.parseCallStatement() + case "ANALYZE": + // MariaDB ANALYZE [FORMAT=JSON] runs and explains it. + if p.peekStatementAfterOptions() { + p.advance() + s, err := p.parseDescribeStatement("ANALYZE") + return s, true, err + } + return p.parseVerbatimStatement(word) + case "COMMENT": + if next != "ON" { + return nil, false, nil + } + return p.parseVerbatimStatement(word) + case "IF", "WHILE", "PRINT", "THROW", "RAISERROR", "WAITFOR", "DBCC", "BULK", "EXEC", "RETURN", "REVERT": + if p.dialect != string(keywords.DialectSQLServer) { + return nil, false, nil + } + return p.parseVerbatimStatement(word) + case "EXISTS": + // ClickHouse EXISTS [TEMPORARY] TABLE | DATABASE | DICTIONARY name. + if p.dialect != string(keywords.DialectClickHouse) { + return nil, false, nil + } + return p.parseVerbatimStatement(word) + case "GRANT", "REVOKE", "VACUUM", "LOCK", "UNLOCK", "KILL", "FLUSH", "OPTIMIZE", + "CHECK", "REPAIR", "CHECKSUM", "LISTEN", "NOTIFY", "UNLISTEN", "PREPARE", + "EXECUTE", "DEALLOCATE", "DO", "DECLARE", "LOAD", "RENAME", "REINDEX", + "CLUSTER", "CHECKPOINT", "DISCARD", "RESET", "XA", "BACKUP", "FLASHBACK", + "PURGE", "UNDROP", "EXCHANGE", "SYSTEM", "ATTACH", "DETACH", "HANDLER", + "INSTALL", "UNINSTALL", "COPY": + return p.parseVerbatimStatement(word) + case "TABLE": + // TABLE name [ORDER BY ...] [LIMIT ...] (PostgreSQL, MySQL 8). + if next == "" && !p.isIdentifierAt(p.currentPos+1) { + return nil, false, nil + } + return p.parseVerbatimStatement(word) + } + return nil, false, nil +} + +// atStatementBoundary reports whether the current token starts the input or +// follows a ';'. Statements may follow one another without a semicolon, so +// without this check the tail of a statement the parser stopped short in +// (ALTER ... SET DEFAULT, FROM t USE INDEX) would be taken for a utility +// statement and silently split off. +func (p *Parser) atStatementBoundary() bool { + return p.currentPos == 0 || p.tokens[p.currentPos-1].Token.Type == models.TokenTypeSemicolon +} + +// beginIsBlock reports whether BEGIN opens a procedural block rather than a +// transaction: always in Oracle (PL/SQL), and in SQL Server unless it is +// BEGIN TRAN[SACTION] or BEGIN DISTRIBUTED TRAN[SACTION]. +func (p *Parser) beginIsBlock(next string) bool { + switch p.dialect { + case string(keywords.DialectOracle): + return true + case string(keywords.DialectSQLServer): + return next != "TRAN" && next != "TRANSACTION" && next != "DISTRIBUTED" + } + return false +} + +// peekStatementAfterOptions reports whether, after the current keyword, a +// statement follows (possibly after explain-style options such as +// FORMAT=JSON). The parser position is unchanged. +func (p *Parser) peekStatementAfterOptions() bool { + saved, savedTok := p.currentPos, p.currentToken + p.advance() + n := p.explainOptionTokens() + p.currentPos, p.currentToken = saved, savedTok + return n >= 0 +} + +// utilityStatementEnd returns the index of the token that ends the statement +// starting at the current position (a top-level ';' or EOF), or an error when +// its parentheses do not balance. +func (p *Parser) utilityStatementEnd() (int, error) { + depth := 0 + i := p.currentPos + for ; i < len(p.tokens); i++ { + switch p.tokens[i].Token.Type { + case models.TokenTypeLParen: + depth++ + case models.TokenTypeRParen: + depth-- + if depth < 0 { + p.seek(i) + return 0, p.expectedError("end of statement") + } + case models.TokenTypeSemicolon: + if depth == 0 { + return i, nil + } + case models.TokenTypeEOF: + if depth != 0 { + p.seek(i) + return 0, p.expectedError(")") + } + return i, nil + } + } + if depth != 0 { + return 0, goerrors.IncompleteStatementError(p.currentLocation(), "") + } + return i, nil +} + +// consumeUtilityText consumes the rest of the statement and returns its text. +func (p *Parser) consumeUtilityText() (string, error) { + start := p.currentPos + end, err := p.utilityStatementEnd() + if err != nil { + return "", err + } + text := p.renderTokenRange(start, end) + p.seek(end) + return text, nil +} + +// seek moves the parser to token index i (EOF sentinel past the end). +func (p *Parser) seek(i int) { + p.currentPos = i + if i < len(p.tokens) { + p.currentToken = p.tokens[i] + } else { + p.currentToken = models.TokenWithSpan{} + } +} + +// parseVerbatimStatement parses the statement starting at the current token +// as a UtilityStatement of the given kind that keeps its text as written. +func (p *Parser) parseVerbatimStatement(kind string) (ast.Statement, bool, error) { + pos := p.currentLocation() + text, err := p.consumeUtilityText() + if err != nil { + return nil, true, err + } + return &ast.UtilityStatement{Kind: kind, Text: text, Pos: pos}, true, nil +} + +// parseTransactionStatement parses BEGIN / START TRANSACTION / COMMIT / +// ROLLBACK / SAVEPOINT / RELEASE / END, recording the savepoint name where +// the statement has one. +func (p *Parser) parseTransactionStatement(action string) (ast.Statement, bool, error) { + stmt := &ast.TransactionStatement{Action: action, Pos: p.currentLocation()} + start := p.currentPos + end, err := p.utilityStatementEnd() + if err != nil { + return nil, true, err + } + switch action { + case "SAVEPOINT": + // SAVEPOINT name | SAVE TRAN[SACTION] name + i := start + 1 + if w := p.utilityWord(i); w == "TRAN" || w == "TRANSACTION" { + i++ + } + if i < end { + stmt.Savepoint = p.tokens[i].Token.Value + } + case "RELEASE SAVEPOINT": + i := start + 1 + if p.utilityWord(i) == "SAVEPOINT" { + i++ + } + if i < end { + stmt.Savepoint = p.tokens[i].Token.Value + } + case "ROLLBACK": + for i := start + 1; i < end; i++ { + if p.utilityWord(i) != "TO" { + continue + } + i++ + if p.utilityWord(i) == "SAVEPOINT" { + i++ + } + if i < end { + stmt.Savepoint = p.tokens[i].Token.Value + } + break + } + } + stmt.Text = p.renderTokenRange(start, end) + p.seek(end) + return stmt, true, nil +} + +// parseSetStatement parses SET [scope] name ... as a SetStatement that keeps +// its text as written. The scope and the variable name are recorded. +func (p *Parser) parseSetStatement() (ast.Statement, bool, error) { + stmt := &ast.SetStatement{Pos: p.currentLocation()} + start := p.currentPos + end, err := p.utilityStatementEnd() + if err != nil { + return nil, true, err + } + i := start + 1 + switch w := p.utilityWord(i); w { + case "GLOBAL", "SESSION", "LOCAL", "PERSIST", "PERSIST_ONLY": + // SET SESSION = x would assign a variable named session; a scope + // is always followed by a name. + if i+1 < end && !isAssignmentToken(p.tokens[i+1].Token.Type) { + stmt.Scope = w + i++ + } + } + // The name is the run of adjacent tokens up to whitespace or an + // assignment operator: search_path, @a, @@session.sql_mode, NAMES. + if i < end { + j := i + 1 + for j < end && !isAssignmentToken(p.tokens[j].Token.Type) && + p.tokens[j].Token.Type != models.TokenTypeComma && + tokensAdjacent(&p.tokens[j-1], &p.tokens[j]) { + j++ + } + stmt.Name = p.renderTokenRange(i, j) + } + if stmt.Name == "" { + return nil, true, p.expectedError("variable name after SET") + } + stmt.Text = p.renderTokenRange(start, end) + p.seek(end) + return stmt, true, nil +} + +// isAssignmentToken reports whether t is "=", ":=" or a compound assignment +// operator such as "+=". +func isAssignmentToken(t models.TokenType) bool { + switch t { + case models.TokenTypeEq, models.TokenTypeAssignment: + return true + } + return false +} + +// parseUseStatement parses USE [WAREHOUSE | DATABASE | SCHEMA | ROLE] name. +func (p *Parser) parseUseStatement() (ast.Statement, bool, error) { + stmt := &ast.UseStatement{Pos: p.currentLocation()} + start := p.currentPos + end, err := p.utilityStatementEnd() + if err != nil { + return nil, true, err + } + i := start + 1 + if p.dialect == string(keywords.DialectSnowflake) && i+1 < end { + switch w := p.utilityWord(i); w { + case "WAREHOUSE", "DATABASE", "SCHEMA", "ROLE": + stmt.ObjectKind = w + i++ + case "SECONDARY": + if p.utilityWord(i+1) == "ROLES" { + stmt.ObjectKind = "SECONDARY ROLES" + i += 2 + } + } + } + if i >= end { + p.seek(i) + return nil, true, p.expectedError("name after USE") + } + p.seek(i) + if name, err := p.parseQualifiedName(); err == nil && p.currentPos == end { + stmt.Name = name + } else if isQualifiedNameTooLong(err) { + return nil, true, err + } else { + // Not a plain name (USE SECONDARY ROLES ALL): keep the words. + parts := make([]string, 0, end-i) + for ; i < end; i++ { + parts = append(parts, p.tokens[i].Token.Value) + } + stmt.Name = strings.Join(parts, " ") + } + stmt.Text = p.renderTokenRange(start, end) + p.seek(end) + return stmt, true, nil +} + +// parseCallStatement parses CALL name[(args)]. Arguments are parsed as +// expressions; a call whose arguments the expression parser does not accept +// (named arguments, OUT parameters in some dialects) is kept verbatim. +func (p *Parser) parseCallStatement() (ast.Statement, bool, error) { + savedPos, savedTok := p.currentPos, p.currentToken + if stmt, err := p.parseCallStatementStructured(); err == nil { + return stmt, true, nil + } + p.currentPos, p.currentToken = savedPos, savedTok + return p.parseVerbatimStatement("CALL") +} + +func (p *Parser) parseCallStatementStructured() (*ast.CallStatement, error) { + stmt := &ast.CallStatement{Pos: p.currentLocation()} + p.advance() // CALL + name, err := p.parseQualifiedName() + if err != nil { + return nil, err + } + stmt.Name = name + if !p.isType(models.TokenTypeLParen) { + stmt.NoParens = true + } else { + p.advance() // ( + for !p.isType(models.TokenTypeRParen) { + arg, err := p.parseExpression() + if err != nil { + return nil, err + } + stmt.Arguments = append(stmt.Arguments, arg) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // , + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() // ) + } + if !p.isType(models.TokenTypeSemicolon) && !p.isType(models.TokenTypeEOF) { + return nil, p.expectedError("end of CALL statement") + } + return stmt, nil +} + +// parseShowOrUtility parses SHOW. The forms ShowStatement models are +// returned as such; any other SHOW (SHOW VARIABLES LIKE '...', SHOW FULL +// PROCESSLIST, SHOW TABLES IN SCHEMA s, SHOW GRANTS FOR u) is kept verbatim. +func (p *Parser) parseShowOrUtility() (ast.Statement, error) { + savedPos, savedTok := p.currentPos, p.currentToken + boundary := p.atStatementBoundary() + p.advance() // SHOW + stmt, err := p.parseShowStatement() + if err == nil && (p.isType(models.TokenTypeSemicolon) || p.isType(models.TokenTypeEOF)) { + return stmt, nil + } + if isQualifiedNameTooLong(err) || !boundary { + return stmt, err + } + if show, ok := stmt.(*ast.ShowStatement); ok { + ast.PutShowStatement(show) + } + p.currentPos, p.currentToken = savedPos, savedTok + s, _, err := p.parseVerbatimStatement("SHOW") + return s, err +} + +// tokensAdjacent reports whether b starts exactly where a ends in the source, +// i.e. no whitespace or comment separated them. Tokens without position +// information are never adjacent. +func tokensAdjacent(a, b *models.TokenWithSpan) bool { + return a.End.Line != 0 && a.End == b.Start +} + +// renderTokenRange rebuilds the source text of tokens [start, end): each +// token is written in source form (quotes and escapes restored for the +// current dialect) and tokens that were separated in the source are +// separated by one space. +func (p *Parser) renderTokenRange(start, end int) string { + var sb strings.Builder + for i := start; i < end && i < len(p.tokens); i++ { + tok := &p.tokens[i] + if tok.Token.Type == models.TokenTypeEOF { + break + } + if i > start && p.needsSpaceBefore(&p.tokens[i-1], tok) { + sb.WriteByte(' ') + } + p.writeTokenSource(&sb, tok) + } + return sb.String() +} + +// needsSpaceBefore decides whether to separate b from the preceding token a. +// With source positions the source layout is followed; without them, a space +// is written except around punctuation that never needs one. +func (p *Parser) needsSpaceBefore(a, b *models.TokenWithSpan) bool { + if a.End.Line != 0 && b.Start.Line != 0 { + return a.End != b.Start + } + switch b.Token.Type { + case models.TokenTypeComma, models.TokenTypeRParen, models.TokenTypePeriod, + models.TokenTypeSemicolon, models.TokenTypeRBracket: + return false + } + switch a.Token.Type { + case models.TokenTypeLParen, models.TokenTypePeriod, models.TokenTypeLBracket: + return false + } + return true +} + +// writeTokenSource writes a token in source form. +func (p *Parser) writeTokenSource(sb *strings.Builder, tok *models.TokenWithSpan) { + t := tok.Token + switch t.Type { + case models.TokenTypeString, models.TokenTypeSingleQuotedString: + p.writeQuotedLiteral(sb, tok, "") + return + case models.TokenTypeNationalStringLiteral: + p.writeQuotedLiteral(sb, tok, "N") + return + case models.TokenTypeEscapedStringLiteral: + p.writeQuotedLiteral(sb, tok, "E") + return + case models.TokenTypeUnicodeStringLiteral: + p.writeQuotedLiteral(sb, tok, "U&") + return + case models.TokenTypeHexStringLiteral: + p.writeQuotedLiteral(sb, tok, "X") + return + case models.TokenTypeByteStringLiteral: + p.writeQuotedLiteral(sb, tok, "B") + return + case models.TokenTypeTripleSingleQuotedString: + sb.WriteString("'''") + sb.WriteString(t.Value) + sb.WriteString("'''") + return + case models.TokenTypeTripleDoubleQuotedString: + sb.WriteString(`"""`) + sb.WriteString(t.Value) + sb.WriteString(`"""`) + return + case models.TokenTypeDollarQuotedString: + tag := "$$" + for n := 0; strings.Contains(t.Value, tag); n++ { + tag = "$q" + strings.Repeat("q", n) + "$" + } + sb.WriteString(tag) + sb.WriteString(t.Value) + sb.WriteString(tag) + return + case models.TokenTypeDoubleQuotedString: + writeDelimited(sb, '"', '"', t.Value) + return + } + switch t.Quote { + case '[': + writeDelimited(sb, '[', ']', t.Value) + return + case '`': + writeDelimited(sb, '`', '`', t.Value) + return + case '"': + writeDelimited(sb, '"', '"', t.Value) + return + case '\'': + p.writeQuotedLiteral(sb, tok, "") + return + } + if t.Type == models.TokenTypeIdentifier && isQuotedSpan(tok) { + writeDelimited(sb, '`', '`', t.Value) + return + } + sb.WriteString(t.Value) +} + +// writeDelimited writes v between open and close, doubling close inside. +func writeDelimited(sb *strings.Builder, open, close byte, v string) { + sb.WriteByte(open) + for i := 0; i < len(v); i++ { + if v[i] == close { + sb.WriteByte(close) + } + sb.WriteByte(v[i]) + } + sb.WriteByte(close) +} + +// writeQuotedLiteral writes a single-quoted string literal with an optional +// prefix (N, E, X, ...), doubling quotes and escaping backslashes when the +// tokenizer decoded backslash escapes in it (see literalHadBackslashEscapes). +func (p *Parser) writeQuotedLiteral(sb *strings.Builder, tok *models.TokenWithSpan, prefix string) { + v := tok.Token.Value + backslash := strings.ContainsAny(v, "\\\n\r\t\x00\x1a") && p.literalHadBackslashEscapes(tok, len(prefix)) + sb.WriteString(prefix) + sb.WriteByte('\'') + for i := 0; i < len(v); i++ { + c := v[i] + if c == '\'' { + sb.WriteString("''") + continue + } + if backslash { + switch c { + case '\\': + sb.WriteString(`\\`) + continue + case '\n': + sb.WriteString(`\n`) + continue + case '\r': + sb.WriteString(`\r`) + continue + case '\t': + sb.WriteString(`\t`) + continue + case 0: + sb.WriteString(`\0`) + continue + case 0x1a: + sb.WriteString(`\Z`) + continue + } + } + sb.WriteByte(c) + } + sb.WriteByte('\'') +} + +// literalHadBackslashEscapes reports whether the tokenizer decodes backslash +// escapes in a string literal token. When the token sits on one line its +// source width tells directly: a literal read without escapes is exactly as +// wide as its quoted value (a decoded \n makes it narrower and keeps it on +// one line). Otherwise the dialect decides, as in the tokenizer. +func (p *Parser) literalHadBackslashEscapes(tok *models.TokenWithSpan, prefixLen int) bool { + v := tok.Token.Value + if tok.Start.Line != 0 && tok.Start.Line == tok.End.Line { + width := tok.End.Column - tok.Start.Column - prefixLen - 2 + quotes := strings.Count(v, "'") + return width != len(v)+quotes && width != utf8.RuneCountInString(v)+quotes + } + return p.backslashEscapes() +} + +// backslashEscapes mirrors the tokenizer: MySQL, MariaDB, PostgreSQL and the +// default dialect decode backslash escapes inside string literals. +func (p *Parser) backslashEscapes() bool { + switch p.dialect { + case string(keywords.DialectMySQL), string(keywords.DialectMariaDB), + string(keywords.DialectPostgreSQL), "": + return true + } + return false +} + +// isIdentifierAt reports whether the token at index i is an identifier +// (bare, quoted or delimited). +func (p *Parser) isIdentifierAt(i int) bool { + if i < 0 || i >= len(p.tokens) { + return false + } + switch p.tokens[i].Token.Type { + case models.TokenTypeIdentifier, models.TokenTypeDoubleQuotedString: + return true + } + return false +} + +// currentIdentQuote returns the opening delimiter of the current token when +// it is a delimited identifier ('"', '`' or '['), and 0 for a bare word. +func (p *Parser) currentIdentQuote() rune { + if p.isType(models.TokenTypeDoubleQuotedString) { + return '"' + } + switch q := p.currentToken.Token.Quote; q { + case '`', '[', '"': + if p.isType(models.TokenTypeIdentifier) { + return q + } + } + return 0 +} + +// isAliasAfterAS reports whether the current token can name a select-list +// alias right after AS. Besides identifiers and the non-reserved keywords, +// any keyword spelled as a plain word is accepted (SELECT a AS share, +// AS filter, AS key, AS first, AS final): after AS nothing but a name can +// follow. Words that start a clause or a set operation are still rejected, +// so SELECT a AS FROM t keeps its error. +func (p *Parser) isAliasAfterAS() bool { + if p.isIdentifier() || p.isNonReservedKeyword() { + return true + } + t := p.currentToken.Token.Type + if !t.IsKeyword() { + return false + } + switch t { + case models.TokenTypeFrom, models.TokenTypeWhere, models.TokenTypeGroup, + models.TokenTypeHaving, models.TokenTypeOrder, models.TokenTypeLimit, + models.TokenTypeOffset, models.TokenTypeFetch, models.TokenTypeUnion, + models.TokenTypeExcept, models.TokenTypeIntersect, models.TokenTypeSelect, + models.TokenTypeInto, models.TokenTypeOn, models.TokenTypeJoin, + models.TokenTypeInner, models.TokenTypeLeft, models.TokenTypeRight, + models.TokenTypeFull, models.TokenTypeCross, models.TokenTypeNatural, + models.TokenTypeWith, models.TokenTypeAs, models.TokenTypeAnd, + models.TokenTypeOr, models.TokenTypeNot, models.TokenTypeUsing: + return false + } + v := p.currentToken.Token.Value + if v == "" { + return false + } + for i := 0; i < len(v); i++ { + c := v[i] + if c != '_' && (c|0x20 < 'a' || c|0x20 > 'z') && (i == 0 || c < '0' || c > '9') { + return false + } + } + return true +} + +// isKeywordColumnName reports whether the current token is a keyword that +// PostgreSQL and the other dialects accept unquoted as a column or table name +// (SELECT owner FROM t). These words are keyword tokens only so that role and +// ALTER statements can match them by type; as an operand they are plain +// names. +func (p *Parser) isKeywordColumnName() bool { + switch p.currentToken.Token.Type { + case models.TokenTypeOwner, models.TokenTypeUrl, models.TokenTypeMember, + models.TokenTypeConnector, models.TokenTypePolicy, models.TokenTypeUntil, + models.TokenTypeValid, models.TokenTypeDcproperties, models.TokenTypeNologin, + models.TokenTypeNocreatedb, models.TokenTypeNocreaterole, models.TokenTypeNosuperuser: + return true + } + return false +} diff --git a/pkg/sql/parser/utility_stmt_test.go b/pkg/sql/parser/utility_stmt_test.go new file mode 100644 index 00000000..1ebdec66 --- /dev/null +++ b/pkg/sql/parser/utility_stmt_test.go @@ -0,0 +1,520 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "fmt" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// parseOne parses sql in dialect d and returns its single statement. +func utilParseOne(t *testing.T, d keywords.SQLDialect, sql string) ast.Statement { + t.Helper() + tree, err := ParseWithDialect(sql, d) + if err != nil { + t.Fatalf("parse %q (%s): %v", sql, d, err) + } + if len(tree.Statements) != 1 { + t.Fatalf("parse %q (%s): got %d statements, want 1", sql, d, len(tree.Statements)) + } + return tree.Statements[0] +} + +// sqlOf returns the SQL() rendering of a statement. +func utilSQLOf(t *testing.T, s ast.Statement) string { + t.Helper() + sq, ok := s.(interface{ SQL() string }) + if !ok { + t.Fatalf("%T has no SQL()", s) + } + return sq.SQL() +} + +// Transaction control parses into TransactionStatement in every dialect that +// has it, keeps the statement text and records the savepoint. +func TestParser_TransactionStatement(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + action string + savepoint string + }{ + {keywords.DialectPostgreSQL, "BEGIN", "BEGIN", ""}, + {keywords.DialectPostgreSQL, "BEGIN ISOLATION LEVEL SERIALIZABLE", "BEGIN", ""}, + {keywords.DialectPostgreSQL, "BEGIN TRANSACTION READ ONLY", "BEGIN", ""}, + {keywords.DialectPostgreSQL, "COMMIT", "COMMIT", ""}, + {keywords.DialectPostgreSQL, "ROLLBACK", "ROLLBACK", ""}, + {keywords.DialectPostgreSQL, "ROLLBACK TO SAVEPOINT sp1", "ROLLBACK", "sp1"}, + {keywords.DialectPostgreSQL, "SAVEPOINT sp1", "SAVEPOINT", "sp1"}, + {keywords.DialectPostgreSQL, "RELEASE SAVEPOINT sp1", "RELEASE SAVEPOINT", "sp1"}, + {keywords.DialectPostgreSQL, "RELEASE sp1", "RELEASE SAVEPOINT", "sp1"}, + {keywords.DialectPostgreSQL, "END", "END", ""}, + {keywords.DialectPostgreSQL, "ABORT", "ROLLBACK", ""}, + {keywords.DialectMySQL, "START TRANSACTION", "START TRANSACTION", ""}, + {keywords.DialectMariaDB, "START TRANSACTION WITH CONSISTENT SNAPSHOT", "START TRANSACTION", ""}, + {keywords.DialectMySQL, "BEGIN", "BEGIN", ""}, + {keywords.DialectMySQL, "ROLLBACK TO SAVEPOINT sp1", "ROLLBACK", "sp1"}, + {keywords.DialectSQLite, "BEGIN IMMEDIATE", "BEGIN", ""}, + {keywords.DialectSQLite, "BEGIN DEFERRED TRANSACTION", "BEGIN", ""}, + {keywords.DialectSQLite, "END TRANSACTION", "END", ""}, + {keywords.DialectSQLite, "ROLLBACK TRANSACTION TO sp2", "ROLLBACK", "sp2"}, + {keywords.DialectOracle, "COMMIT WORK", "COMMIT", ""}, + {keywords.DialectSnowflake, "BEGIN", "BEGIN", ""}, + {keywords.DialectSQLServer, "BEGIN TRANSACTION", "BEGIN", ""}, + {keywords.DialectSQLServer, "BEGIN TRAN", "BEGIN", ""}, + {keywords.DialectSQLServer, "COMMIT TRANSACTION", "COMMIT", ""}, + {keywords.DialectSQLServer, "SAVE TRANSACTION sp1", "SAVEPOINT", "sp1"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + tx, ok := stmt.(*ast.TransactionStatement) + if !ok { + t.Fatalf("got %T, want *ast.TransactionStatement", stmt) + } + if tx.Action != tt.action || tx.Savepoint != tt.savepoint { + t.Errorf("action/savepoint = %q/%q, want %q/%q", tx.Action, tx.Savepoint, tt.action, tt.savepoint) + } + if got := utilSQLOf(t, stmt); got != tt.sql { + t.Errorf("SQL() = %q, want %q", got, tt.sql) + } + }) + } +} + +// BEGIN and END mean different things per dialect: a T-SQL BEGIN TRY or a +// PL/SQL BEGIN opens a block, and END is a transaction statement only in +// PostgreSQL and SQLite. +func TestParser_BeginEndDialectGating(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + want string // %T, or "error" + }{ + {keywords.DialectSQLServer, "BEGIN TRY SELECT 1 / 0", "*ast.UtilityStatement"}, + {keywords.DialectSQLServer, "END CATCH", "*ast.UtilityStatement"}, + {keywords.DialectSQLServer, "END", "*ast.UtilityStatement"}, + {keywords.DialectOracle, "BEGIN NULL; END", "error"}, // the block body is split at ';' + {keywords.DialectOracle, "BEGIN dbms_stats.gather_schema_stats('APP')", "*ast.UtilityStatement"}, + {keywords.DialectPostgreSQL, "END", "*ast.TransactionStatement"}, + {keywords.DialectSQLite, "END", "*ast.TransactionStatement"}, + {keywords.DialectMySQL, "END", "error"}, + {keywords.DialectOracle, "END", "error"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + tree, err := ParseWithDialect(tt.sql, tt.dialect) + if tt.want == "error" { + if err == nil && len(tree.Statements) == 1 { + t.Fatalf("parsed as %T, want an error", tree.Statements[0]) + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(tree.Statements) != 1 { + t.Fatalf("got %d statements, want 1", len(tree.Statements)) + } + if got := fmt.Sprintf("%T", tree.Statements[0]); got != tt.want { + t.Errorf("got %s, want %s", got, tt.want) + } + }) + } +} + +// SET keeps its text and records the scope and the variable name. +func TestParser_SetStatement(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + scope string + name string + }{ + {keywords.DialectPostgreSQL, "SET search_path TO public, analytics", "", "search_path"}, + {keywords.DialectPostgreSQL, "SET LOCAL statement_timeout = '5s'", "LOCAL", "statement_timeout"}, + {keywords.DialectPostgreSQL, "SET SESSION TIME ZONE 'UTC'", "SESSION", "TIME"}, + {keywords.DialectMySQL, "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", "", "NAMES"}, + {keywords.DialectMySQL, "SET @a = 1, @b := 2", "", "@a"}, + {keywords.DialectMySQL, "SET @@session.sql_mode = 'TRADITIONAL'", "", "@@session.sql_mode"}, + {keywords.DialectMySQL, "SET GLOBAL max_connections = 500", "GLOBAL", "max_connections"}, + {keywords.DialectMySQL, "SET @total = (SELECT COUNT(*) FROM users)", "", "@total"}, + {keywords.DialectMariaDB, "SET STATEMENT max_statement_time = 5 FOR SELECT * FROM orders", "", "STATEMENT"}, + {keywords.DialectSQLServer, "SET NOCOUNT ON", "", "NOCOUNT"}, + {keywords.DialectSQLServer, "SET @i = @i + 1", "", "@i"}, + {keywords.DialectSQLServer, "SET TRANSACTION ISOLATION LEVEL READ COMMITTED", "", "TRANSACTION"}, + {keywords.DialectClickHouse, "SET max_threads = 8", "", "max_threads"}, + {keywords.DialectSnowflake, "SET my_var = 10", "", "my_var"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + set, ok := stmt.(*ast.SetStatement) + if !ok { + t.Fatalf("got %T, want *ast.SetStatement", stmt) + } + if set.Scope != tt.scope || set.Name != tt.name { + t.Errorf("scope/name = %q/%q, want %q/%q", set.Scope, set.Name, tt.scope, tt.name) + } + if got := utilSQLOf(t, stmt); got != tt.sql { + t.Errorf("SQL() = %q, want %q", got, tt.sql) + } + }) + } +} + +// USE parses in every dialect, with the Snowflake object kinds. +func TestParser_UseStatement(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + kind string + name string + }{ + {keywords.DialectMySQL, "USE app", "", "app"}, + {keywords.DialectMySQL, "USE `my-app`", "", "my-app"}, + {keywords.DialectSQLServer, "USE [Northwind]", "", "Northwind"}, + {keywords.DialectClickHouse, "USE analytics", "", "analytics"}, + {keywords.DialectSnowflake, "USE WAREHOUSE compute_wh", "WAREHOUSE", "compute_wh"}, + {keywords.DialectSnowflake, "USE SCHEMA analytics.public", "SCHEMA", "analytics.public"}, + {keywords.DialectSnowflake, "USE analytics.public", "", "analytics.public"}, + {keywords.DialectSnowflake, "USE SECONDARY ROLES ALL", "SECONDARY ROLES", "ALL"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + use, ok := stmt.(*ast.UseStatement) + if !ok { + t.Fatalf("got %T, want *ast.UseStatement", stmt) + } + if use.ObjectKind != tt.kind || use.Name != tt.name { + t.Errorf("kind/name = %q/%q, want %q/%q", use.ObjectKind, use.Name, tt.kind, tt.name) + } + if got := utilSQLOf(t, stmt); got != tt.sql { + t.Errorf("SQL() = %q, want %q", got, tt.sql) + } + }) + } +} + +// CALL parses its arguments as expressions; forms the expression parser does +// not accept are kept verbatim. +func TestParser_CallStatement(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + name string + args int + want string // SQL() + }{ + {keywords.DialectMySQL, "CALL refresh_stats(2024, 'full')", "refresh_stats", 2, "CALL refresh_stats(2024, 'full')"}, + {keywords.DialectMySQL, "CALL sp_noargs()", "sp_noargs", 0, "CALL sp_noargs()"}, + {keywords.DialectMySQL, "CALL sp_noargs", "sp_noargs", 0, "CALL sp_noargs"}, + {keywords.DialectMariaDB, "CALL sp_touch_user(42)", "sp_touch_user", 1, "CALL sp_touch_user(42)"}, + {keywords.DialectSnowflake, "CALL db.schema.sp_load('2024-01-01')", "db.schema.sp_load", 1, "CALL db.schema.sp_load('2024-01-01')"}, + {keywords.DialectPostgreSQL, "CALL my_proc(1, 'a', NULL)", "my_proc", 3, "CALL my_proc(1, 'a', NULL)"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + call, ok := stmt.(*ast.CallStatement) + if !ok { + t.Fatalf("got %T, want *ast.CallStatement", stmt) + } + if call.Name != tt.name || len(call.Arguments) != tt.args { + t.Errorf("name/args = %q/%d, want %q/%d", call.Name, len(call.Arguments), tt.name, tt.args) + } + if got := utilSQLOf(t, stmt); got != tt.want { + t.Errorf("SQL() = %q, want %q", got, tt.want) + } + }) + } + + // Named arguments are not expressions; the statement is kept verbatim. + stmt := utilParseOne(t, keywords.DialectPostgreSQL, "CALL p(a => 1)") + u, ok := stmt.(*ast.UtilityStatement) + if !ok { + t.Fatalf("CALL p(a => 1): got %T, want *ast.UtilityStatement", stmt) + } + if u.Kind != "CALL" || u.SQL() != "CALL p(a => 1)" { + t.Errorf("CALL p(a => 1): kind/SQL = %q/%q", u.Kind, u.SQL()) + } +} + +// The long tail of utility statements is kept verbatim: SQL() reproduces the +// statement exactly (comments removed, whitespace collapsed), never as an +// "-- UNSUPPORTED" comment. +func TestParser_UtilityStatementVerbatim(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + kind string + want string // SQL(); "" means the input itself + }{ + {keywords.DialectPostgreSQL, "GRANT SELECT, INSERT ON users TO app_rw", "GRANT", ""}, + {keywords.DialectPostgreSQL, "REVOKE UPDATE ON orders FROM readonly", "REVOKE", ""}, + {keywords.DialectPostgreSQL, "COMMENT ON TABLE users IS 'it''s users'", "COMMENT", ""}, + {keywords.DialectPostgreSQL, "VACUUM (VERBOSE, ANALYZE) users", "VACUUM", ""}, + {keywords.DialectPostgreSQL, "ANALYZE users", "ANALYZE", ""}, + {keywords.DialectPostgreSQL, "LISTEN channel", "LISTEN", ""}, + {keywords.DialectPostgreSQL, "NOTIFY channel, 'hello'", "NOTIFY", ""}, + {keywords.DialectPostgreSQL, "LOCK TABLE users IN ACCESS EXCLUSIVE MODE", "LOCK", ""}, + {keywords.DialectPostgreSQL, "DO $$ BEGIN RAISE NOTICE 'hi'; END $$", "DO", ""}, + {keywords.DialectPostgreSQL, "PREPARE stmt (int) AS SELECT * FROM users WHERE id = $1", "PREPARE", ""}, + {keywords.DialectPostgreSQL, "EXECUTE stmt(5)", "EXECUTE", ""}, + {keywords.DialectPostgreSQL, "DEALLOCATE stmt", "DEALLOCATE", ""}, + {keywords.DialectPostgreSQL, "RESET ALL", "RESET", ""}, + {keywords.DialectPostgreSQL, "TABLE users", "TABLE", ""}, + {keywords.DialectPostgreSQL, "GRANT SELECT\n ON users -- why\n TO app", "GRANT", "GRANT SELECT ON users TO app"}, + {keywords.DialectMySQL, "GRANT ALL PRIVILEGES ON *.* TO 'admin'@'localhost' WITH GRANT OPTION", "GRANT", ""}, + {keywords.DialectMySQL, "LOAD DATA INFILE '/tmp/data.csv' INTO TABLE users FIELDS TERMINATED BY ',' LINES TERMINATED BY '\\n'", "LOAD", ""}, + {keywords.DialectMySQL, "KILL 123", "KILL", ""}, + {keywords.DialectMySQL, "LOCK TABLES users WRITE, orders READ", "LOCK", ""}, + {keywords.DialectMySQL, "UNLOCK TABLES", "UNLOCK", ""}, + {keywords.DialectMySQL, "FLUSH PRIVILEGES", "FLUSH", ""}, + {keywords.DialectMySQL, "OPTIMIZE TABLE users", "OPTIMIZE", ""}, + {keywords.DialectMySQL, "CHECK TABLE users", "CHECK", ""}, + {keywords.DialectMySQL, "RENAME TABLE a TO b, c TO d", "RENAME", ""}, + {keywords.DialectMySQL, "PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'", "PREPARE", ""}, + {keywords.DialectMySQL, "EXECUTE stmt USING @id", "EXECUTE", ""}, + {keywords.DialectMySQL, "XA START 'a'", "XA", ""}, + {keywords.DialectMySQL, "TABLE users ORDER BY id LIMIT 5", "TABLE", ""}, + {keywords.DialectMySQL, "SHOW VARIABLES LIKE 'max_%'", "SHOW", ""}, + {keywords.DialectMySQL, "SHOW FULL PROCESSLIST", "SHOW", ""}, + {keywords.DialectMySQL, "SHOW GRANTS FOR 'app'@'%'", "SHOW", ""}, + {keywords.DialectSnowflake, "SHOW TABLES IN SCHEMA analytics.public", "SHOW", ""}, + {keywords.DialectSnowflake, "GRANT SELECT ON FUTURE TABLES IN SCHEMA analytics.public TO ROLE analyst", "GRANT", ""}, + {keywords.DialectSnowflake, "COPY INTO raw_events FROM @my_stage/events/ FILE_FORMAT = (TYPE = 'JSON')", "COPY", ""}, + {keywords.DialectSnowflake, "PUT file:///tmp/data.csv @my_stage AUTO_COMPRESS = TRUE", "PUT", ""}, + {keywords.DialectSnowflake, "LIST @my_stage", "LIST", ""}, + {keywords.DialectSnowflake, "EXECUTE IMMEDIATE 'SELECT 1'", "EXECUTE", ""}, + {keywords.DialectSnowflake, "UNDROP TABLE users", "UNDROP", ""}, + {keywords.DialectClickHouse, "SYSTEM FLUSH LOGS", "SYSTEM", ""}, + {keywords.DialectClickHouse, "OPTIMIZE TABLE events PARTITION 202401 FINAL", "OPTIMIZE", ""}, + {keywords.DialectClickHouse, "KILL QUERY WHERE query_id = 'abc' SYNC", "KILL", ""}, + {keywords.DialectClickHouse, "EXISTS TABLE events", "EXISTS", ""}, + {keywords.DialectClickHouse, "SHOW TABLES FROM system LIKE 'query%'", "SHOW", ""}, + {keywords.DialectClickHouse, "EXCHANGE TABLES a AND b", "EXCHANGE", ""}, + {keywords.DialectSQLite, "ATTACH DATABASE 'archive.db' AS archive", "ATTACH", ""}, + {keywords.DialectSQLite, "DETACH DATABASE archive", "DETACH", ""}, + {keywords.DialectSQLite, "VACUUM INTO 'backup.db'", "VACUUM", ""}, + {keywords.DialectSQLite, "REINDEX", "REINDEX", ""}, + {keywords.DialectOracle, "FLASHBACK TABLE orders TO BEFORE DROP", "FLASHBACK", ""}, + {keywords.DialectOracle, "GRANT CREATE SESSION, CREATE TABLE TO app_role", "GRANT", ""}, + {keywords.DialectSQLServer, "DECLARE @i INT = 0", "DECLARE", ""}, + {keywords.DialectSQLServer, "DECLARE @t TABLE (Id INT PRIMARY KEY, Name NVARCHAR(50))", "DECLARE", ""}, + {keywords.DialectSQLServer, "EXEC dbo.p @Id = 42", "EXEC", ""}, + {keywords.DialectSQLServer, "EXEC sp_executesql N'SELECT 1 WHERE x = ''y''', N'@Id INT', @Id = 5", "EXEC", ""}, + {keywords.DialectSQLServer, "IF @i > 0 SELECT 1 ELSE SELECT 0", "IF", ""}, + {keywords.DialectSQLServer, "PRINT N'Done'", "PRINT", ""}, + {keywords.DialectSQLServer, "THROW 50001, N'Custom error', 1", "THROW", ""}, + {keywords.DialectSQLServer, "BULK INSERT dbo.Staging FROM 'C:\\data\\file.csv' WITH (FIRSTROW = 2)", "BULK", ""}, + {keywords.DialectSQLServer, "GRANT SELECT ON dbo.Customers TO [AppUser]", "GRANT", ""}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + u, ok := stmt.(*ast.UtilityStatement) + if !ok { + t.Fatalf("got %T, want *ast.UtilityStatement", stmt) + } + if u.Kind != tt.kind { + t.Errorf("Kind = %q, want %q", u.Kind, tt.kind) + } + want := tt.want + if want == "" { + want = tt.sql + } + if got := u.SQL(); got != want { + t.Errorf("SQL() = %q, want %q", got, want) + } + // The rendering re-parses to the same statement. + again := utilParseOne(t, tt.dialect, u.SQL()) + if got := utilSQLOf(t, again); got != want { + t.Errorf("re-parsed SQL() = %q, want %q", got, want) + } + }) + } +} + +// Utility statements are recognized only at a statement boundary. The tail +// of a statement the parser stopped short in must not be split off as a +// second statement that happens to start with a utility keyword. +func TestParser_UtilityStatementBoundary(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectMySQL, "SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE"}, + {keywords.DialectMySQL, "SELECT 1 SET @a = 1"}, + {keywords.DialectPostgreSQL, "SELECT a FROM t WHERE a = 1 COMMIT"}, + {keywords.DialectSQLServer, "SELECT 1 PRINT 'x'"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + if tree, err := ParseWithDialect(tt.sql, tt.dialect); err == nil { + t.Fatalf("parsed as %d statements, want an error", len(tree.Statements)) + } + }) + } + + // After a semicolon a utility statement is fine. + tree, err := ParseWithDialect("SELECT 1; COMMIT", keywords.DialectPostgreSQL) + if err != nil { + t.Fatalf("SELECT 1; COMMIT: %v", err) + } + if len(tree.Statements) != 2 { + t.Fatalf("SELECT 1; COMMIT: got %d statements, want 2", len(tree.Statements)) + } + if _, ok := tree.Statements[1].(*ast.TransactionStatement); !ok { + t.Errorf("SELECT 1; COMMIT: second statement is %T", tree.Statements[1]) + } +} + +// Statements that are not utility statements in a dialect, or that are +// malformed, are still errors. +func TestParser_UtilityStatementErrors(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectPostgreSQL, "IF x > 0 SELECT 1"}, // T-SQL only + {keywords.DialectPostgreSQL, "PRINT 'x'"}, // T-SQL only + {keywords.DialectPostgreSQL, "EXISTS TABLE t"}, // ClickHouse only + {keywords.DialectMySQL, "COMMENT TABLE t"}, // COMMENT needs ON + {keywords.DialectMySQL, "START t"}, // START needs TRANSACTION + {keywords.DialectPostgreSQL, "GRANT SELECT ON t TO (u"}, + {keywords.DialectPostgreSQL, "GRANT SELECT ON t) TO u"}, + {keywords.DialectPostgreSQL, "SET"}, + {keywords.DialectMySQL, "USE"}, + {keywords.DialectPostgreSQL, "TABLE"}, + {keywords.DialectPostgreSQL, "SELEC 1"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + if tree, err := ParseWithDialect(tt.sql, tt.dialect); err == nil { + t.Fatalf("parsed as %T, want an error", tree.Statements[0]) + } + }) + } +} + +// MariaDB ANALYZE explains the statement like EXPLAIN ANALYZE. +func TestParser_AnalyzeStatement(t *testing.T) { + stmt := utilParseOne(t, keywords.DialectMariaDB, "ANALYZE FORMAT=JSON SELECT * FROM t1") + ex, ok := stmt.(*ast.ExplainStatement) + if !ok { + t.Fatalf("got %T, want *ast.ExplainStatement", stmt) + } + if ex.Keyword != "ANALYZE" { + t.Errorf("Keyword = %q, want ANALYZE", ex.Keyword) + } + if _, ok := ex.Statement.(*ast.SelectStatement); !ok { + t.Errorf("explained statement is %T, want *ast.SelectStatement", ex.Statement) + } + // ANALYZE TABLE is a maintenance statement. + stmt = utilParseOne(t, keywords.DialectMariaDB, "ANALYZE TABLE orders") + if u, ok := stmt.(*ast.UtilityStatement); !ok || u.Kind != "ANALYZE" { + t.Errorf("ANALYZE TABLE orders: got %#v", stmt) + } +} + +// DESCRIBE keeps the keyword it was written with and its object kind. +func TestParser_DescribeKeywordAndKind(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + keyword string + kind string + table string + }{ + {keywords.DialectMySQL, "DESC users", "DESC", "", "users"}, + {keywords.DialectMySQL, "DESCRIBE users", "DESCRIBE", "", "users"}, + {keywords.DialectClickHouse, "DESCRIBE TABLE events", "DESCRIBE", "TABLE", "events"}, + {keywords.DialectSnowflake, "DESC VIEW v_orders", "DESC", "VIEW", "v_orders"}, + {keywords.DialectSnowflake, "DESCRIBE STAGE my_stage", "DESCRIBE", "STAGE", "my_stage"}, + {keywords.DialectMySQL, "DESCRIBE table", "DESCRIBE", "", "table"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + d, ok := stmt.(*ast.DescribeStatement) + if !ok { + t.Fatalf("got %T, want *ast.DescribeStatement", stmt) + } + if d.Keyword != tt.keyword || d.ObjectKind != tt.kind || d.TableName != tt.table { + t.Errorf("keyword/kind/table = %q/%q/%q, want %q/%q/%q", + d.Keyword, d.ObjectKind, d.TableName, tt.keyword, tt.kind, tt.table) + } + if got := d.SQL(); got != tt.sql { + t.Errorf("SQL() = %q, want %q", got, tt.sql) + } + }) + } +} + +// PRAGMA accepts a schema-qualified name. +func TestParser_PragmaSchemaQualified(t *testing.T) { + stmt := utilParseOne(t, keywords.DialectSQLite, "PRAGMA main.synchronous = NORMAL") + pr, ok := stmt.(*ast.PragmaStatement) + if !ok { + t.Fatalf("got %T, want *ast.PragmaStatement", stmt) + } + if pr.Schema != "main" || pr.Name != "synchronous" || pr.Value != "NORMAL" { + t.Errorf("schema/name/value = %q/%q/%q", pr.Schema, pr.Name, pr.Value) + } + if got := pr.SQL(); got != "PRAGMA main.synchronous = NORMAL" { + t.Errorf("SQL() = %q", got) + } +} + +// Verbatim text restores each token's delimiters and escapes: string +// literals re-escape backslashes only where the tokenizer decoded them, and +// delimited identifiers keep their own quotes. +func TestParser_UtilityStatementTokenText(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectMySQL, `SET @p = 'C:\\tmp\\x'`}, + {keywords.DialectMySQL, "GRANT SELECT ON `my db`.`t``x` TO 'u'@'%'"}, + {keywords.DialectSQLServer, `SET @p = 'C:\tmp\x'`}, + {keywords.DialectSQLServer, "GRANT SELECT ON [dbo].[my]]t] TO [u]"}, + {keywords.DialectSQLServer, "PRINT N'it''s'"}, + {keywords.DialectPostgreSQL, `COMMENT ON TABLE "My Table" IS 'x'`}, + {keywords.DialectPostgreSQL, "DO $q$ SELECT '$$' $q$"}, + {keywords.DialectSnowflake, `SET p = 'C:\tmp'`}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + stmt := utilParseOne(t, tt.dialect, tt.sql) + got := utilSQLOf(t, stmt) + if tt.sql == "DO $q$ SELECT '$$' $q$" { + // The dollar-quote tag is not kept by the tokenizer; any tag + // that does not occur in the body is equivalent. + if got != "DO $q$ SELECT '$$' $q$" { + t.Errorf("SQL() = %q", got) + } + return + } + if got != tt.sql { + t.Errorf("SQL() = %q, want %q", got, tt.sql) + } + }) + } +} diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index be9163ad..885486d3 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -28,8 +28,6 @@ clickhouse/03_ddl_migrations.sql#2 drop clickhouse/03_ddl_migrations.sql#20 parse clickhouse/03_ddl_migrations.sql#21 parse clickhouse/03_ddl_migrations.sql#23 parse -clickhouse/03_ddl_migrations.sql#24 parse -clickhouse/03_ddl_migrations.sql#25 parse clickhouse/03_ddl_migrations.sql#26 parse clickhouse/03_ddl_migrations.sql#27 parse clickhouse/03_ddl_migrations.sql#28 parse @@ -49,9 +47,6 @@ clickhouse/03_ddl_migrations.sql#40 parse clickhouse/03_ddl_migrations.sql#41 parse clickhouse/03_ddl_migrations.sql#42 parse clickhouse/03_ddl_migrations.sql#43 parse -clickhouse/03_ddl_migrations.sql#44 parse -clickhouse/03_ddl_migrations.sql#45 parse -clickhouse/03_ddl_migrations.sql#46 parse clickhouse/03_ddl_migrations.sql#5 drop clickhouse/03_ddl_migrations.sql#6 drop clickhouse/03_ddl_migrations.sql#7 drop @@ -80,7 +75,6 @@ clickhouse/05_dialect_specific.sql#40 drop clickhouse/05_dialect_specific.sql#5 drop clickhouse/05_dialect_specific.sql#50 parse clickhouse/05_dialect_specific.sql#55 parse -clickhouse/05_dialect_specific.sql#56 parse clickhouse/05_dialect_specific.sql#6 drop clickhouse/05_dialect_specific.sql#62 drop clickhouse/05_dialect_specific.sql#64 parse @@ -99,7 +93,6 @@ clickhouse/06_materialized_views.sql#16 parse clickhouse/06_materialized_views.sql#17 parse clickhouse/06_materialized_views.sql#18 parse clickhouse/06_materialized_views.sql#2 drop -clickhouse/06_materialized_views.sql#22 parse clickhouse/06_materialized_views.sql#23 parse clickhouse/06_materialized_views.sql#24 parse clickhouse/06_materialized_views.sql#25 parse @@ -108,9 +101,6 @@ clickhouse/06_materialized_views.sql#28 parse clickhouse/06_materialized_views.sql#29 parse clickhouse/06_materialized_views.sql#3 drop clickhouse/06_materialized_views.sql#30 parse -clickhouse/06_materialized_views.sql#31 parse -clickhouse/06_materialized_views.sql#32 parse -clickhouse/06_materialized_views.sql#33 parse clickhouse/06_materialized_views.sql#34 parse clickhouse/06_materialized_views.sql#35 parse clickhouse/06_materialized_views.sql#4 parse @@ -120,19 +110,6 @@ clickhouse/06_materialized_views.sql#8 drop clickhouse/06_materialized_views.sql#9 parse clickhouse/07_observability.sql#0 drop clickhouse/07_observability.sql#16 parse -clickhouse/07_observability.sql#27 parse -clickhouse/07_observability.sql#29 drop -clickhouse/07_observability.sql#30 drop -clickhouse/07_observability.sql#31 parse -clickhouse/07_observability.sql#32 parse -clickhouse/07_observability.sql#33 parse -clickhouse/07_observability.sql#34 parse -clickhouse/07_observability.sql#35 parse -clickhouse/07_observability.sql#36 parse -clickhouse/07_observability.sql#37 parse -clickhouse/07_observability.sql#38 parse -clickhouse/07_observability.sql#39 parse -clickhouse/07_observability.sql#40 parse clickhouse/08_etl_pipelines.sql#14 parse clickhouse/08_etl_pipelines.sql#16 drop clickhouse/08_etl_pipelines.sql#17 parse @@ -146,7 +123,6 @@ clickhouse/08_etl_pipelines.sql#35 parse clickhouse/08_etl_pipelines.sql#36 parse clickhouse/08_etl_pipelines.sql#37 parse clickhouse/08_etl_pipelines.sql#38 parse -clickhouse/08_etl_pipelines.sql#39 parse clickhouse/09_deep_nesting.sql#10 drop clickhouse/09_deep_nesting.sql#16 drop clickhouse/09_deep_nesting.sql#17 parse @@ -184,8 +160,6 @@ mariadb/01_app_crud.sql#14 parse mariadb/01_app_crud.sql#16 parse mariadb/01_app_crud.sql#17 parse mariadb/01_app_crud.sql#26 parse -mariadb/01_app_crud.sql#27 parse -mariadb/01_app_crud.sql#28 parse mariadb/01_app_crud.sql#3 parse mariadb/01_app_crud.sql#7 parse mariadb/02_reporting.sql#1 drop @@ -202,10 +176,6 @@ mariadb/02_reporting.sql#3 drop mariadb/02_reporting.sql#30 parse mariadb/02_reporting.sql#31 parse mariadb/02_reporting.sql#33 parse -mariadb/02_reporting.sql#38 parse -mariadb/02_reporting.sql#39 parse -mariadb/02_reporting.sql#40 parse -mariadb/02_reporting.sql#41 parse mariadb/03_ddl_migrations.sql#0 parse mariadb/03_ddl_migrations.sql#1 parse mariadb/03_ddl_migrations.sql#10 drop @@ -225,7 +195,6 @@ mariadb/03_ddl_migrations.sql#27 parse mariadb/03_ddl_migrations.sql#29 parse mariadb/03_ddl_migrations.sql#3 parse mariadb/03_ddl_migrations.sql#31 parse -mariadb/03_ddl_migrations.sql#32 parse mariadb/03_ddl_migrations.sql#36 parse mariadb/03_ddl_migrations.sql#37 parse mariadb/03_ddl_migrations.sql#38 parse @@ -233,26 +202,12 @@ mariadb/03_ddl_migrations.sql#39 parse mariadb/03_ddl_migrations.sql#4 parse mariadb/03_ddl_migrations.sql#40 parse mariadb/03_ddl_migrations.sql#41 parse -mariadb/03_ddl_migrations.sql#42 parse -mariadb/03_ddl_migrations.sql#43 parse -mariadb/03_ddl_migrations.sql#44 parse -mariadb/03_ddl_migrations.sql#45 parse -mariadb/03_ddl_migrations.sql#46 parse -mariadb/03_ddl_migrations.sql#47 parse -mariadb/03_ddl_migrations.sql#48 parse -mariadb/03_ddl_migrations.sql#49 parse mariadb/03_ddl_migrations.sql#5 parse -mariadb/03_ddl_migrations.sql#50 parse -mariadb/03_ddl_migrations.sql#51 parse -mariadb/03_ddl_migrations.sql#52 parse -mariadb/03_ddl_migrations.sql#53 parse mariadb/03_ddl_migrations.sql#6 parse mariadb/03_ddl_migrations.sql#7 parse mariadb/03_ddl_migrations.sql#8 reparse mariadb/04_window_analytics.sql#14 drop -mariadb/04_window_analytics.sql#18 parse mariadb/04_window_analytics.sql#4 drop -mariadb/04_window_analytics.sql#5 parse mariadb/05_mariadb_specific.sql#11 parse mariadb/05_mariadb_specific.sql#14 parse mariadb/05_mariadb_specific.sql#17 parse @@ -274,30 +229,19 @@ mariadb/05_mariadb_specific.sql#40 parse mariadb/05_mariadb_specific.sql#41 parse mariadb/05_mariadb_specific.sql#51 parse mariadb/05_mariadb_specific.sql#53 parse -mariadb/05_mariadb_specific.sql#57 parse mariadb/05_mariadb_specific.sql#58 parse mariadb/05_mariadb_specific.sql#6 parse mariadb/05_mariadb_specific.sql#63 parse mariadb/05_mariadb_specific.sql#65 parse -mariadb/05_mariadb_specific.sql#66 parse -mariadb/05_mariadb_specific.sql#67 parse -mariadb/05_mariadb_specific.sql#68 parse -mariadb/05_mariadb_specific.sql#69 parse mariadb/05_mariadb_specific.sql#9 parse mariadb/06_etl_admin.sql#12 parse mariadb/06_etl_admin.sql#13 parse -mariadb/06_etl_admin.sql#15 parse mariadb/06_etl_admin.sql#16 parse mariadb/06_etl_admin.sql#2 parse mariadb/06_etl_admin.sql#22 parse -mariadb/06_etl_admin.sql#24 parse -mariadb/06_etl_admin.sql#25 parse mariadb/06_etl_admin.sql#35 parse mariadb/06_etl_admin.sql#36 parse mariadb/06_etl_admin.sql#37 parse -mariadb/06_etl_admin.sql#38 parse -mariadb/06_etl_admin.sql#39 parse -mariadb/06_etl_admin.sql#40 parse mariadb/06_etl_admin.sql#5 parse mariadb/06_etl_admin.sql#6 parse mariadb/06_etl_admin.sql#7 drop @@ -305,23 +249,12 @@ mariadb/06_etl_admin.sql#8 parse mariadb/06_etl_admin.sql#9 parse mariadb/07_stored_programs.sql#0 parse mariadb/07_stored_programs.sql#1 parse -mariadb/07_stored_programs.sql#10 parse -mariadb/07_stored_programs.sql#11 parse -mariadb/07_stored_programs.sql#12 parse -mariadb/07_stored_programs.sql#13 parse -mariadb/07_stored_programs.sql#14 parse -mariadb/07_stored_programs.sql#15 parse -mariadb/07_stored_programs.sql#16 parse -mariadb/07_stored_programs.sql#17 parse mariadb/07_stored_programs.sql#18 parse mariadb/07_stored_programs.sql#2 parse mariadb/07_stored_programs.sql#3 parse mariadb/07_stored_programs.sql#4 parse -mariadb/07_stored_programs.sql#5 parse mariadb/07_stored_programs.sql#6 parse mariadb/07_stored_programs.sql#7 parse -mariadb/07_stored_programs.sql#8 parse -mariadb/07_stored_programs.sql#9 parse mariadb/08_complex_queries.sql#12 parse mariadb/08_complex_queries.sql#18 drop mariadb/08_complex_queries.sql#21 parse @@ -340,17 +273,7 @@ mariadb/09_mysql_compat_misc.sql#25 parse mariadb/09_mysql_compat_misc.sql#33 parse mariadb/09_mysql_compat_misc.sql#36 parse mariadb/09_mysql_compat_misc.sql#37 parse -mariadb/09_mysql_compat_misc.sql#39 parse -mariadb/09_mysql_compat_misc.sql#40 parse -mariadb/09_mysql_compat_misc.sql#41 parse -mariadb/09_mysql_compat_misc.sql#42 parse -mariadb/09_mysql_compat_misc.sql#46 parse -mariadb/09_mysql_compat_misc.sql#47 parse -mariadb/09_mysql_compat_misc.sql#49 parse mariadb/09_mysql_compat_misc.sql#5 parse -mariadb/09_mysql_compat_misc.sql#50 parse -mariadb/09_mysql_compat_misc.sql#54 parse -mariadb/09_mysql_compat_misc.sql#55 parse mysql/01_app_crud.sql#11 parse mysql/01_app_crud.sql#17 drop mysql/01_app_crud.sql#18 parse @@ -359,7 +282,6 @@ mysql/01_app_crud.sql#21 parse mysql/01_app_crud.sql#22 parse mysql/01_app_crud.sql#23 parse mysql/01_app_crud.sql#30 parse -mysql/01_app_crud.sql#6 parse mysql/02_reporting.sql#1 drop mysql/02_reporting.sql#22 drop mysql/02_reporting.sql#23 drop @@ -391,8 +313,6 @@ mysql/03_ddl_migrations.sql#3 parse mysql/03_ddl_migrations.sql#30 parse mysql/03_ddl_migrations.sql#32 parse mysql/03_ddl_migrations.sql#33 parse -mysql/03_ddl_migrations.sql#37 parse -mysql/03_ddl_migrations.sql#38 parse mysql/03_ddl_migrations.sql#4 parse mysql/03_ddl_migrations.sql#42 parse mysql/03_ddl_migrations.sql#43 parse @@ -401,7 +321,6 @@ mysql/03_ddl_migrations.sql#7 reparse mysql/03_ddl_migrations.sql#9 parse mysql/04_window_analytics.sql#14 parse mysql/04_window_analytics.sql#20 parse -mysql/04_window_analytics.sql#21 parse mysql/04_window_analytics.sql#7 drop mysql/04_window_analytics.sql#8 drop mysql/05_dialect_specific.sql#10 reparse @@ -411,39 +330,7 @@ mysql/05_dialect_specific.sql#13 parse mysql/05_dialect_specific.sql#14 parse mysql/05_dialect_specific.sql#15 parse mysql/05_dialect_specific.sql#18 parse -mysql/05_dialect_specific.sql#19 parse -mysql/05_dialect_specific.sql#20 parse -mysql/05_dialect_specific.sql#21 parse -mysql/05_dialect_specific.sql#22 parse -mysql/05_dialect_specific.sql#23 parse -mysql/05_dialect_specific.sql#24 parse -mysql/05_dialect_specific.sql#25 parse -mysql/05_dialect_specific.sql#26 parse -mysql/05_dialect_specific.sql#27 parse mysql/05_dialect_specific.sql#28 parse -mysql/05_dialect_specific.sql#30 parse -mysql/05_dialect_specific.sql#35 parse -mysql/05_dialect_specific.sql#36 parse -mysql/05_dialect_specific.sql#38 parse -mysql/05_dialect_specific.sql#39 parse -mysql/05_dialect_specific.sql#41 parse -mysql/05_dialect_specific.sql#43 drop -mysql/05_dialect_specific.sql#47 parse -mysql/05_dialect_specific.sql#48 parse -mysql/05_dialect_specific.sql#49 parse -mysql/05_dialect_specific.sql#50 parse -mysql/05_dialect_specific.sql#51 parse -mysql/05_dialect_specific.sql#52 parse -mysql/05_dialect_specific.sql#53 parse -mysql/05_dialect_specific.sql#54 parse -mysql/05_dialect_specific.sql#55 parse -mysql/05_dialect_specific.sql#56 parse -mysql/05_dialect_specific.sql#57 parse -mysql/05_dialect_specific.sql#58 parse -mysql/05_dialect_specific.sql#59 parse -mysql/05_dialect_specific.sql#60 parse -mysql/05_dialect_specific.sql#61 parse -mysql/05_dialect_specific.sql#62 parse mysql/05_dialect_specific.sql#63 parse mysql/05_dialect_specific.sql#64 parse mysql/05_dialect_specific.sql#65 parse @@ -454,10 +341,8 @@ mysql/05_dialect_specific.sql#8 parse mysql/05_dialect_specific.sql#9 reparse mysql/06_wordpress.sql#12 parse mysql/06_wordpress.sql#13 parse -mysql/06_wordpress.sql#19 parse mysql/06_wordpress.sql#20 parse mysql/06_wordpress.sql#3 parse -mysql/07_laravel_magento.sql#10 parse mysql/07_laravel_magento.sql#11 parse mysql/07_laravel_magento.sql#15 parse mysql/07_laravel_magento.sql#7 parse @@ -465,21 +350,13 @@ mysql/07_laravel_magento.sql#8 parse mysql/08_etl_admin.sql#10 parse mysql/08_etl_admin.sql#11 parse mysql/08_etl_admin.sql#12 parse -mysql/08_etl_admin.sql#19 parse mysql/08_etl_admin.sql#2 parse -mysql/08_etl_admin.sql#20 parse -mysql/08_etl_admin.sql#21 parse mysql/08_etl_admin.sql#22 parse mysql/08_etl_admin.sql#23 parse mysql/08_etl_admin.sql#24 parse mysql/08_etl_admin.sql#25 parse mysql/08_etl_admin.sql#26 parse -mysql/08_etl_admin.sql#27 parse -mysql/08_etl_admin.sql#28 parse -mysql/08_etl_admin.sql#29 parse mysql/08_etl_admin.sql#3 parse -mysql/08_etl_admin.sql#30 parse -mysql/08_etl_admin.sql#31 parse mysql/08_etl_admin.sql#34 parse mysql/08_etl_admin.sql#35 parse mysql/08_etl_admin.sql#36 parse @@ -505,7 +382,6 @@ mysql/10_misc_functions.sql#2 parse mysql/10_misc_functions.sql#21 parse mysql/10_misc_functions.sql#22 parse mysql/10_misc_functions.sql#23 parse -mysql/10_misc_functions.sql#25 parse mysql/10_misc_functions.sql#27 parse mysql/10_misc_functions.sql#3 parse mysql/10_misc_functions.sql#8 drop @@ -533,19 +409,11 @@ mysql/11_stored_programs.sql#31 parse mysql/11_stored_programs.sql#32 parse mysql/11_stored_programs.sql#33 parse mysql/11_stored_programs.sql#34 parse -mysql/11_stored_programs.sql#35 parse mysql/11_stored_programs.sql#5 parse mysql/11_stored_programs.sql#6 parse mysql/11_stored_programs.sql#7 parse mysql/11_stored_programs.sql#8 parse mysql/12_orm_misc.sql#12 parse -mysql/12_orm_misc.sql#16 parse -mysql/12_orm_misc.sql#17 parse -mysql/12_orm_misc.sql#19 parse -mysql/12_orm_misc.sql#20 parse -mysql/12_orm_misc.sql#22 parse -mysql/12_orm_misc.sql#23 parse -mysql/12_orm_misc.sql#24 parse mysql/12_orm_misc.sql#4 parse mysql/12_orm_misc.sql#6 parse oracle/01_app_crud.sql#10 parse @@ -595,20 +463,13 @@ oracle/03_ddl_migrations.sql#45 parse oracle/03_ddl_migrations.sql#47 parse oracle/03_ddl_migrations.sql#5 parse oracle/03_ddl_migrations.sql#50 parse -oracle/03_ddl_migrations.sql#53 parse -oracle/03_ddl_migrations.sql#54 parse -oracle/03_ddl_migrations.sql#55 parse -oracle/03_ddl_migrations.sql#56 parse -oracle/03_ddl_migrations.sql#57 parse oracle/03_ddl_migrations.sql#58 parse oracle/03_ddl_migrations.sql#59 parse oracle/03_ddl_migrations.sql#6 parse -oracle/03_ddl_migrations.sql#60 parse oracle/03_ddl_migrations.sql#7 drop oracle/03_ddl_migrations.sql#8 parse oracle/03_ddl_migrations.sql#9 parse oracle/04_window_analytics.sql#10 parse -oracle/04_window_analytics.sql#11 parse oracle/04_window_analytics.sql#19 parse oracle/04_window_analytics.sql#22 parse oracle/04_window_analytics.sql#23 parse @@ -622,8 +483,6 @@ oracle/05_dialect_specific.sql#17 parse oracle/05_dialect_specific.sql#18 parse oracle/05_dialect_specific.sql#19 parse oracle/05_dialect_specific.sql#2 drop -oracle/05_dialect_specific.sql#20 parse -oracle/05_dialect_specific.sql#21 parse oracle/05_dialect_specific.sql#29 parse oracle/05_dialect_specific.sql#3 parse oracle/05_dialect_specific.sql#32 parse @@ -666,22 +525,8 @@ oracle/07_admin_ops.sql#14 parse oracle/07_admin_ops.sql#15 parse oracle/07_admin_ops.sql#16 parse oracle/07_admin_ops.sql#17 parse -oracle/07_admin_ops.sql#18 parse -oracle/07_admin_ops.sql#19 parse oracle/07_admin_ops.sql#2 parse -oracle/07_admin_ops.sql#20 parse -oracle/07_admin_ops.sql#21 parse -oracle/07_admin_ops.sql#22 parse -oracle/07_admin_ops.sql#23 parse -oracle/07_admin_ops.sql#24 parse -oracle/07_admin_ops.sql#25 parse -oracle/07_admin_ops.sql#26 parse -oracle/07_admin_ops.sql#27 parse -oracle/07_admin_ops.sql#28 parse -oracle/07_admin_ops.sql#29 parse oracle/07_admin_ops.sql#3 parse -oracle/07_admin_ops.sql#30 parse -oracle/07_admin_ops.sql#31 parse oracle/07_admin_ops.sql#34 parse oracle/07_admin_ops.sql#35 parse oracle/07_admin_ops.sql#36 parse @@ -745,11 +590,8 @@ postgresql/03_ddl_migrations.sql#60 parse postgresql/03_ddl_migrations.sql#61 parse postgresql/03_ddl_migrations.sql#62 parse postgresql/03_ddl_migrations.sql#63 parse -postgresql/03_ddl_migrations.sql#64 parse -postgresql/03_ddl_migrations.sql#65 parse postgresql/03_ddl_migrations.sql#7 reparse postgresql/03_ddl_migrations.sql#9 parse -postgresql/04_window_analytics.sql#21 parse postgresql/05_dialect_specific.sql#12 parse postgresql/05_dialect_specific.sql#14 parse postgresql/05_dialect_specific.sql#15 parse @@ -775,7 +617,6 @@ postgresql/05_dialect_specific.sql#49 parse postgresql/05_dialect_specific.sql#51 parse postgresql/05_dialect_specific.sql#52 parse postgresql/05_dialect_specific.sql#53 parse -postgresql/05_dialect_specific.sql#54 parse postgresql/05_dialect_specific.sql#55 parse postgresql/05_dialect_specific.sql#56 parse postgresql/06_cte_dml_etl.sql#11 parse @@ -790,35 +631,11 @@ postgresql/06_cte_dml_etl.sql#5 parse postgresql/06_cte_dml_etl.sql#6 parse postgresql/06_cte_dml_etl.sql#7 parse postgresql/06_cte_dml_etl.sql#9 parse -postgresql/07_admin_ops.sql#0 parse -postgresql/07_admin_ops.sql#1 parse -postgresql/07_admin_ops.sql#10 parse -postgresql/07_admin_ops.sql#11 parse -postgresql/07_admin_ops.sql#12 parse -postgresql/07_admin_ops.sql#17 parse -postgresql/07_admin_ops.sql#18 parse -postgresql/07_admin_ops.sql#19 parse -postgresql/07_admin_ops.sql#2 parse postgresql/07_admin_ops.sql#20 parse -postgresql/07_admin_ops.sql#3 parse -postgresql/07_admin_ops.sql#33 parse -postgresql/07_admin_ops.sql#34 parse -postgresql/07_admin_ops.sql#35 parse -postgresql/07_admin_ops.sql#36 parse -postgresql/07_admin_ops.sql#37 parse -postgresql/07_admin_ops.sql#38 parse -postgresql/07_admin_ops.sql#39 parse -postgresql/07_admin_ops.sql#4 parse -postgresql/07_admin_ops.sql#40 parse postgresql/07_admin_ops.sql#41 parse postgresql/07_admin_ops.sql#42 parse postgresql/07_admin_ops.sql#43 parse postgresql/07_admin_ops.sql#44 parse -postgresql/07_admin_ops.sql#5 parse -postgresql/07_admin_ops.sql#6 parse -postgresql/07_admin_ops.sql#7 parse -postgresql/07_admin_ops.sql#8 parse -postgresql/07_admin_ops.sql#9 parse postgresql/08_supabase_rls_misc.sql#0 parse postgresql/08_supabase_rls_misc.sql#10 parse postgresql/08_supabase_rls_misc.sql#16 parse @@ -844,20 +661,17 @@ snowflake/03_ddl_migrations.sql#24 parse snowflake/03_ddl_migrations.sql#25 parse snowflake/03_ddl_migrations.sql#26 parse snowflake/03_ddl_migrations.sql#3 parse -snowflake/03_ddl_migrations.sql#30 parse snowflake/03_ddl_migrations.sql#31 reparse snowflake/03_ddl_migrations.sql#32 reparse snowflake/03_ddl_migrations.sql#33 reparse snowflake/03_ddl_migrations.sql#34 reparse snowflake/03_ddl_migrations.sql#35 reparse -snowflake/03_ddl_migrations.sql#36 parse snowflake/03_ddl_migrations.sql#5 parse snowflake/03_ddl_migrations.sql#6 parse snowflake/03_ddl_migrations.sql#7 parse snowflake/03_ddl_migrations.sql#8 reparse snowflake/03_ddl_migrations.sql#9 reparse snowflake/04_window_analytics.sql#1 drop -snowflake/04_window_analytics.sql#10 parse snowflake/04_window_analytics.sql#14 drop snowflake/04_window_analytics.sql#2 drop snowflake/04_window_analytics.sql#3 drop @@ -900,48 +714,15 @@ snowflake/06_etl_merge.sql#20 reparse snowflake/06_etl_merge.sql#21 parse snowflake/06_etl_merge.sql#22 reparse snowflake/06_etl_merge.sql#23 parse -snowflake/06_etl_merge.sql#25 reparse -snowflake/06_etl_merge.sql#26 reparse -snowflake/06_etl_merge.sql#27 reparse -snowflake/06_etl_merge.sql#28 reparse -snowflake/06_etl_merge.sql#29 parse -snowflake/06_etl_merge.sql#30 parse -snowflake/06_etl_merge.sql#31 parse -snowflake/06_etl_merge.sql#32 parse -snowflake/06_etl_merge.sql#34 parse -snowflake/06_etl_merge.sql#35 parse -snowflake/06_etl_merge.sql#4 reparse -snowflake/06_etl_merge.sql#5 reparse -snowflake/06_etl_merge.sql#6 reparse -snowflake/06_etl_merge.sql#7 reparse -snowflake/07_admin.sql#0 reparse -snowflake/07_admin.sql#1 reparse -snowflake/07_admin.sql#10 parse -snowflake/07_admin.sql#11 parse -snowflake/07_admin.sql#14 parse -snowflake/07_admin.sql#18 drop -snowflake/07_admin.sql#19 drop -snowflake/07_admin.sql#2 reparse -snowflake/07_admin.sql#20 drop snowflake/07_admin.sql#21 reparse snowflake/07_admin.sql#22 parse snowflake/07_admin.sql#23 parse snowflake/07_admin.sql#24 reparse -snowflake/07_admin.sql#25 parse -snowflake/07_admin.sql#26 parse -snowflake/07_admin.sql#27 parse -snowflake/07_admin.sql#28 parse -snowflake/07_admin.sql#29 parse -snowflake/07_admin.sql#3 reparse -snowflake/07_admin.sql#30 parse -snowflake/07_admin.sql#31 parse snowflake/07_admin.sql#32 parse snowflake/07_admin.sql#33 parse snowflake/07_admin.sql#34 parse snowflake/07_admin.sql#35 parse snowflake/07_admin.sql#36 parse -snowflake/07_admin.sql#37 parse -snowflake/07_admin.sql#4 reparse snowflake/07_admin.sql#43 parse snowflake/07_admin.sql#44 parse snowflake/07_admin.sql#46 parse @@ -950,10 +731,6 @@ snowflake/07_admin.sql#48 parse snowflake/07_admin.sql#49 parse snowflake/07_admin.sql#50 parse snowflake/07_admin.sql#51 parse -snowflake/07_admin.sql#6 parse -snowflake/07_admin.sql#7 parse -snowflake/07_admin.sql#8 parse -snowflake/07_admin.sql#9 parse snowflake/08_pivot_match.sql#1 parse snowflake/08_pivot_match.sql#13 parse snowflake/08_pivot_match.sql#14 parse @@ -966,7 +743,6 @@ snowflake/09_dbt_models.sql#1 parse snowflake/09_dbt_models.sql#12 parse snowflake/09_dbt_models.sql#13 drop snowflake/09_dbt_models.sql#15 parse -snowflake/09_dbt_models.sql#17 parse snowflake/09_dbt_models.sql#2 parse snowflake/10_complex_nested.sql#12 parse snowflake/10_complex_nested.sql#19 drop @@ -996,22 +772,10 @@ sqlite/03_ddl_migrations.sql#11 parse sqlite/03_ddl_migrations.sql#13 parse sqlite/03_ddl_migrations.sql#15 drop sqlite/03_ddl_migrations.sql#23 parse -sqlite/03_ddl_migrations.sql#24 parse sqlite/03_ddl_migrations.sql#25 parse -sqlite/03_ddl_migrations.sql#26 parse sqlite/03_ddl_migrations.sql#27 parse sqlite/03_ddl_migrations.sql#28 parse -sqlite/03_ddl_migrations.sql#29 parse sqlite/03_ddl_migrations.sql#3 parse -sqlite/03_ddl_migrations.sql#30 parse -sqlite/03_ddl_migrations.sql#31 parse -sqlite/03_ddl_migrations.sql#32 parse -sqlite/03_ddl_migrations.sql#33 parse -sqlite/03_ddl_migrations.sql#34 parse -sqlite/03_ddl_migrations.sql#35 parse -sqlite/03_ddl_migrations.sql#36 parse -sqlite/03_ddl_migrations.sql#37 parse -sqlite/03_ddl_migrations.sql#38 parse sqlite/03_ddl_migrations.sql#4 parse sqlite/03_ddl_migrations.sql#5 parse sqlite/03_ddl_migrations.sql#6 parse @@ -1056,21 +820,7 @@ sqlite/08_reporting.sql#15 parse sqlite/08_reporting.sql#16 parse sqlite/09_etl_admin.sql#1 drop sqlite/09_etl_admin.sql#10 parse -sqlite/09_etl_admin.sql#17 parse -sqlite/09_etl_admin.sql#18 parse -sqlite/09_etl_admin.sql#19 parse sqlite/09_etl_admin.sql#2 parse -sqlite/09_etl_admin.sql#28 parse -sqlite/09_etl_admin.sql#29 parse -sqlite/09_etl_admin.sql#30 parse -sqlite/09_etl_admin.sql#33 parse -sqlite/09_etl_admin.sql#34 parse -sqlite/09_etl_admin.sql#35 parse -sqlite/09_etl_admin.sql#36 parse -sqlite/09_etl_admin.sql#37 parse -sqlite/09_etl_admin.sql#38 parse -sqlite/09_etl_admin.sql#39 parse -sqlite/09_etl_admin.sql#40 parse sqlite/09_etl_admin.sql#8 parse sqlite/10_mobile_offline_sync.sql#0 drop sqlite/10_mobile_offline_sync.sql#15 parse @@ -1080,9 +830,7 @@ sqlite/10_mobile_offline_sync.sql#20 drop sqlite/10_mobile_offline_sync.sql#21 parse sqlite/10_mobile_offline_sync.sql#4 parse sqlite/10_mobile_offline_sync.sql#5 parse -sqlite/10_mobile_offline_sync.sql#6 parse sqlite/10_mobile_offline_sync.sql#7 parse -sqlite/10_mobile_offline_sync.sql#8 parse sqlite/11_deep_nesting.sql#14 parse sqlite/11_deep_nesting.sql#19 parse sqlite/11_deep_nesting.sql#30 parse @@ -1187,53 +935,16 @@ sqlserver/07_ctes_deep.sql#14 parse sqlserver/07_ctes_deep.sql#15 parse sqlserver/07_ctes_deep.sql#16 parse sqlserver/07_ctes_deep.sql#2 parse -sqlserver/08_procs_batches.sql#0 parse -sqlserver/08_procs_batches.sql#1 parse -sqlserver/08_procs_batches.sql#10 parse -sqlserver/08_procs_batches.sql#11 parse -sqlserver/08_procs_batches.sql#12 parse -sqlserver/08_procs_batches.sql#13 parse -sqlserver/08_procs_batches.sql#14 parse -sqlserver/08_procs_batches.sql#15 parse -sqlserver/08_procs_batches.sql#16 parse -sqlserver/08_procs_batches.sql#17 parse -sqlserver/08_procs_batches.sql#18 parse -sqlserver/08_procs_batches.sql#19 parse -sqlserver/08_procs_batches.sql#2 parse -sqlserver/08_procs_batches.sql#20 parse -sqlserver/08_procs_batches.sql#21 parse -sqlserver/08_procs_batches.sql#22 parse -sqlserver/08_procs_batches.sql#23 parse -sqlserver/08_procs_batches.sql#24 parse -sqlserver/08_procs_batches.sql#25 parse -sqlserver/08_procs_batches.sql#26 parse -sqlserver/08_procs_batches.sql#27 parse sqlserver/08_procs_batches.sql#28 parse -sqlserver/08_procs_batches.sql#3 parse -sqlserver/08_procs_batches.sql#30 parse sqlserver/08_procs_batches.sql#31 parse -sqlserver/08_procs_batches.sql#32 parse -sqlserver/08_procs_batches.sql#33 parse sqlserver/08_procs_batches.sql#34 parse -sqlserver/08_procs_batches.sql#35 parse sqlserver/08_procs_batches.sql#36 parse sqlserver/08_procs_batches.sql#37 parse -sqlserver/08_procs_batches.sql#38 parse -sqlserver/08_procs_batches.sql#39 parse -sqlserver/08_procs_batches.sql#4 parse -sqlserver/08_procs_batches.sql#40 parse -sqlserver/08_procs_batches.sql#5 parse -sqlserver/08_procs_batches.sql#6 parse -sqlserver/08_procs_batches.sql#7 parse -sqlserver/08_procs_batches.sql#8 parse -sqlserver/08_procs_batches.sql#9 parse sqlserver/09_admin_dmv.sql#1 parse sqlserver/09_admin_dmv.sql#13 parse -sqlserver/09_admin_dmv.sql#15 parse sqlserver/09_admin_dmv.sql#16 parse sqlserver/09_admin_dmv.sql#17 parse sqlserver/09_admin_dmv.sql#18 parse -sqlserver/09_admin_dmv.sql#19 parse sqlserver/09_admin_dmv.sql#3 parse sqlserver/09_admin_dmv.sql#4 parse sqlserver/09_admin_dmv.sql#7 parse @@ -1244,12 +955,10 @@ sqlserver/10_etl_mixed.sql#11 parse sqlserver/10_etl_mixed.sql#12 parse sqlserver/10_etl_mixed.sql#13 parse sqlserver/10_etl_mixed.sql#14 parse -sqlserver/10_etl_mixed.sql#15 parse sqlserver/10_etl_mixed.sql#18 drop sqlserver/10_etl_mixed.sql#19 parse sqlserver/10_etl_mixed.sql#2 parse sqlserver/10_etl_mixed.sql#20 parse sqlserver/10_etl_mixed.sql#21 parse -sqlserver/10_etl_mixed.sql#25 reparse sqlserver/10_etl_mixed.sql#28 parse sqlserver/10_etl_mixed.sql#8 parse From 2e052b4ba1f26ab4698822f4e1c3614521f7af80 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 18:17:16 +0530 Subject: [PATCH 06/95] fix(tokenizer): dialect-correct string escapes and missing lexical forms String literals no longer corrupt on format. The tokenizer and the renderer disagreed about backslashes: PostgreSQL, Oracle, SQLite, SQL Server and generic treat a backslash as an ordinary character, MySQL/MariaDB decode escapes (keeping \% and \_), and ClickHouse/Snowflake decode escapes but keep unknown ones. The tokenizer now follows dialect.StringEscape, and LiteralValue records RawBackslash so the renderer writes a literal back under the same rules instead of doubling every backslash on each format pass (non-idempotent, data corrupting for regexes and paths). Unknown escapes no longer fail tokenizing. Lexical coverage: - operators ^ ^@ <=> <-> << >> == := and { } - 0x hex numbers, leading-dot (.5) and trailing-dot (5.) numbers - prefixed strings N'' X'' B'' E'' q'[]' and MySQL _charset'' (kept on the AST as LiteralValue.Prefix and written back) - $ inside identifiers (Oracle/Snowflake/PostgreSQL), # in Oracle identifiers - SQLite :name, ?NNN and [bracket] identifiers, ClickHouse {p:Type} parameters - a leading UTF-8 byte order mark is skipped The real-world corpus ratchet drops 64 known failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_corpus_test.go | 11 +- cmd/gosqlx/cmd/format_guard_test.go | 41 +- examples/cmd/example_test.go | 4 +- pkg/gosqlx/string_literal_roundtrip_test.go | 296 +++++++++++ pkg/sql/ast/ast.go | 11 + pkg/sql/ast/literal_string_escape_test.go | 85 +++ pkg/sql/ast/quote_names.go | 7 + .../ast/render_ddl_name_quoting_632_test.go | 8 +- pkg/sql/ast/sql.go | 19 +- pkg/sql/ast/sql_string_literal.go | 159 ++++++ pkg/sql/dialect/string_escape.go | 50 ++ pkg/sql/dialect/string_escape_test.go | 44 ++ pkg/sql/parser/dml_update.go | 2 +- pkg/sql/parser/expressions_literal.go | 6 +- pkg/sql/parser/pivot.go | 3 +- pkg/sql/parser/string_literal.go | 95 ++++ pkg/sql/tokenizer/error_location_test.go | 9 +- pkg/sql/tokenizer/lexical_extensions_test.go | 325 ++++++++++++ pkg/sql/tokenizer/oracle_bind_param_test.go | 8 +- pkg/sql/tokenizer/placeholders.go | 86 +++ pkg/sql/tokenizer/pool.go | 2 + pkg/sql/tokenizer/prefixed_string.go | 259 +++++++++ pkg/sql/tokenizer/quoted_zero_copy_test.go | 12 +- pkg/sql/tokenizer/string_escape_modes_test.go | 173 ++++++ .../testdata/tokenizer_literal_golden.json | 499 +++++++++++------- pkg/sql/tokenizer/tokenizer.go | 265 ++++++++-- pkg/sql/tokenizer/tokenizer_coverage_test.go | 6 +- pkg/sql/tokenizer/tsql_test.go | 9 +- pkg/sql/tokenizer/zero_copy_test.go | 1 - testdata/realworld/known_failures.txt | 66 +-- 30 files changed, 2206 insertions(+), 355 deletions(-) create mode 100644 pkg/gosqlx/string_literal_roundtrip_test.go create mode 100644 pkg/sql/ast/literal_string_escape_test.go create mode 100644 pkg/sql/ast/sql_string_literal.go create mode 100644 pkg/sql/dialect/string_escape.go create mode 100644 pkg/sql/dialect/string_escape_test.go create mode 100644 pkg/sql/parser/string_literal.go create mode 100644 pkg/sql/tokenizer/lexical_extensions_test.go create mode 100644 pkg/sql/tokenizer/placeholders.go create mode 100644 pkg/sql/tokenizer/prefixed_string.go create mode 100644 pkg/sql/tokenizer/string_escape_modes_test.go diff --git a/cmd/gosqlx/cmd/format_corpus_test.go b/cmd/gosqlx/cmd/format_corpus_test.go index cb8afde4..6c371d89 100644 --- a/cmd/gosqlx/cmd/format_corpus_test.go +++ b/cmd/gosqlx/cmd/format_corpus_test.go @@ -30,12 +30,11 @@ import ( // rendering gap. For these the CLI must refuse to format (return an error) // rather than emit changed SQL; once the gap is fixed they simply pass. var corpusFormatKnownGaps = map[string]string{ - "corpus/orm/gorm_joins.sql": "follow-up: a name quoted only for case (\"Company\") renders bare; the AST does not record quoting, so the CLI refuses", - "mssql/13_cross_apply.sql": "follow-up: CROSS APPLY rendered as CROSS APPLY JOIN, TOP lost in derived table", - "mssql/14_outer_apply.sql": "follow-up: OUTER APPLY rendered as OUTER APPLY JOIN, TOP lost in derived table", - "mysql/29_full_text_search.sql": "follow-up: MATCH ... AGAINST rendered as AGAINST AGAINST(...)", - "oracle/08_connect_by_path.sql": "follow-up: START WITH / CONNECT BY not rendered", - "oracle/19_regexp_functions.sql": "follow-up: string literal backslashes doubled for a dialect that keeps them literal", + "corpus/orm/gorm_joins.sql": "follow-up: a name quoted only for case (\"Company\") renders bare; the AST does not record quoting, so the CLI refuses", + "mssql/13_cross_apply.sql": "follow-up: CROSS APPLY rendered as CROSS APPLY JOIN, TOP lost in derived table", + "mssql/14_outer_apply.sql": "follow-up: OUTER APPLY rendered as OUTER APPLY JOIN, TOP lost in derived table", + "mysql/29_full_text_search.sql": "follow-up: MATCH ... AGAINST rendered as AGAINST AGAINST(...)", + "oracle/08_connect_by_path.sql": "follow-up: START WITH / CONNECT BY not rendered", } // corpusDialect maps a testdata path to the dialect its SQL is written in. diff --git a/cmd/gosqlx/cmd/format_guard_test.go b/cmd/gosqlx/cmd/format_guard_test.go index aac8cb43..8580985e 100644 --- a/cmd/gosqlx/cmd/format_guard_test.go +++ b/cmd/gosqlx/cmd/format_guard_test.go @@ -93,19 +93,36 @@ func TestFormatSQL_AllowsHarmlessQuotes(t *testing.T) { } } -// TestFormatSQL_RefusesBackslashLiteralChange checks that string literals -// with backslashes are never rewritten in dialects where backslash is not an -// escape character (the renderer doubles them); the CLI must refuse. -func TestFormatSQL_RefusesBackslashLiteralChange(t *testing.T) { - for _, d := range []string{"sqlserver", "oracle", "sqlite", "snowflake", "clickhouse"} { - t.Run(d, func(t *testing.T) { - in := `SELECT 'C:\temp\x'` - out, err := formatWith(in, CLIFormatterOptions{Compact: true, Uppercase: true, Dialect: d}) - if err == nil { - t.Fatalf("formatSQL(%q, %s) = %q, want an error", in, d, out) +// TestFormatSQL_KeepsBackslashLiteral checks that a string literal with +// backslashes is written back with the rules of its dialect: untouched where +// the backslash is an ordinary character, re-escaped (and so equal after +// tokenizing) where it escapes. The renderer used to double every backslash, +// so the guard refused these inputs. +func TestFormatSQL_KeepsBackslashLiteral(t *testing.T) { + const in = `SELECT 'C:\temp\x'` + for _, tt := range []struct { + dialect string + want string // literal text expected in the output + }{ + {"sqlserver", `'C:\temp\x'`}, + {"oracle", `'C:\temp\x'`}, + {"sqlite", `'C:\temp\x'`}, + {"postgresql", `'C:\temp\x'`}, + // \t is a tab; \x keeps its backslash, which the output doubles. + {"snowflake", "'C:\temp\\\\x'"}, + {"clickhouse", "'C:\temp\\\\x'"}, + } { + t.Run(tt.dialect, func(t *testing.T) { + out, err := formatWith(in, CLIFormatterOptions{Compact: true, Uppercase: true, Dialect: tt.dialect}) + if err != nil { + t.Fatalf("formatSQL(%q, %s): %v", in, tt.dialect, err) + } + if !strings.Contains(out, tt.want) { + t.Fatalf("formatSQL(%q, %s) = %q, want it to contain %q", in, tt.dialect, out, tt.want) } - if !strings.Contains(err.Error(), "string literal") { - t.Fatalf("error %q does not name the changed string literal", err) + again, err := formatWith(out, CLIFormatterOptions{Compact: true, Uppercase: true, Dialect: tt.dialect}) + if err != nil || again != out { + t.Fatalf("not idempotent: %q then %q (err %v)", out, again, err) } }) } diff --git a/examples/cmd/example_test.go b/examples/cmd/example_test.go index feeb5d98..a17f3634 100644 --- a/examples/cmd/example_test.go +++ b/examples/cmd/example_test.go @@ -143,8 +143,8 @@ func TestExampleErrorCases(t *testing.T) { query: `SELECT * FROM users WHERE name = 'unterminated;`, }, { - name: "invalid Unicode escape sequence", - query: `SELECT * FROM users WHERE name = '\u123';`, // Invalid Unicode escape + name: "incomplete escape sequence", + query: `SELECT * FROM users WHERE name = 'abc\`, // Backslash at end of input }, } diff --git a/pkg/gosqlx/string_literal_roundtrip_test.go b/pkg/gosqlx/string_literal_roundtrip_test.go new file mode 100644 index 00000000..f8326742 --- /dev/null +++ b/pkg/gosqlx/string_literal_roundtrip_test.go @@ -0,0 +1,296 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// roundTripDialects are the dialects whose string rules differ (see +// dialect.StringEscape); "" is the unconfigured default. +var roundTripDialects = []keywords.SQLDialect{ + "", keywords.DialectGeneric, keywords.DialectPostgreSQL, keywords.DialectMySQL, + keywords.DialectMariaDB, keywords.DialectSQLServer, keywords.DialectOracle, + keywords.DialectSQLite, keywords.DialectSnowflake, keywords.DialectClickHouse, +} + +func parseDialect(sql string, d keywords.SQLDialect) (*ast.AST, error) { + if d == "" { + return gosqlx.Parse(sql) + } + return gosqlx.ParseWithDialect(sql, d) +} + +// selectedLiteral returns the literal of "SELECT ". +func selectedLiteral(t *testing.T, tree *ast.AST) *ast.LiteralValue { + t.Helper() + if len(tree.Statements) != 1 { + t.Fatalf("got %d statements, want 1", len(tree.Statements)) + } + sel, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("got %T, want *ast.SelectStatement", tree.Statements[0]) + } + if len(sel.Columns) != 1 { + t.Fatalf("got %d columns, want 1", len(sel.Columns)) + } + lit, ok := sel.Columns[0].(*ast.LiteralValue) + if !ok { + t.Fatalf("got %T, want *ast.LiteralValue", sel.Columns[0]) + } + return lit +} + +// TestStringBackslashFormatIsIdempotent is the property behind the formatter +// corruption bug: format(parse(format(x))) == format(x) for strings containing +// backslashes, in every dialect, and the literal's value survives the round +// trip. Doubling backslashes on every pass used to corrupt regexes and paths. +func TestStringBackslashFormatIsIdempotent(t *testing.T) { + bodies := []string{ + `C:\dir\file`, + `x\.y`, + `\d+\s*`, + `50\%`, + `a\\b`, + `tab\there`, + `^[a-z]+\.[a-z]{2,}$`, + `a\\\\b`, + `mixed 'quote'' and \ backslash`, + `plain`, + } + for _, d := range roundTripDialects { + for _, body := range bodies { + sql := "SELECT '" + strings.ReplaceAll(body, "'", "''") + "'" + t.Run(fmt.Sprintf("%s/%s", d, body), func(t *testing.T) { + tree, err := parseDialect(sql, d) + if err != nil { + t.Fatalf("parse %q: %v", sql, err) + } + first := selectedLiteral(t, tree).Value + f1 := gosqlx.FormatAST(tree) + + tree2, err := parseDialect(f1, d) + if err != nil { + t.Fatalf("reparse %q: %v", f1, err) + } + if got := selectedLiteral(t, tree2).Value; got != first { + t.Errorf("value changed by formatting: %q -> %q (formatted %q)", first, got, f1) + } + if f2 := gosqlx.FormatAST(tree2); f2 != f1 { + t.Errorf("not idempotent:\n first %q\n second %q", f1, f2) + } + }) + } + } +} + +// TestStringBackslashRendering pins what the formatter writes for backslashes +// in the dialects where it is a plain character (written back untouched) and +// where it escapes (written as an escape). +func TestStringBackslashRendering(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + want string + }{ + {keywords.DialectPostgreSQL, `SELECT a ~ 'x\.y' FROM t`, `x\.y`}, + {keywords.DialectOracle, `SELECT 'a\b' FROM dual`, `'a\b'`}, + {keywords.DialectSQLite, `SELECT 'C:\dir'`, `'C:\dir'`}, + {keywords.DialectGeneric, `SELECT 'a\b'`, `'a\b'`}, + {keywords.DialectPostgreSQL, "SELECT 'line1\nline2'", "'line1\nline2'"}, + {keywords.DialectMySQL, `SELECT * FROM t WHERE a LIKE '50\%'`, `'50\\%'`}, + {keywords.DialectMySQL, `SELECT 'it\'s'`, `'it''s'`}, + {keywords.DialectClickHouse, `SELECT 'a\'b'`, `'a''b'`}, + {keywords.DialectClickHouse, `SELECT 'x\.y'`, `'x\\.y'`}, + {keywords.DialectSnowflake, `SELECT 'a\tb'`, "'a\tb'"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + tree, err := parseDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatal(err) + } + if out := gosqlx.FormatAST(tree); !strings.Contains(out, tt.want) { + t.Errorf("formatted %q does not contain %q", out, tt.want) + } + }) + } +} + +func TestStringEscapeOnlyBackslashDialects(t *testing.T) { + // A trailing backslash is a complete string in the dialects without + // backslash escapes and an unterminated one in the others. + for _, tt := range []struct { + dialect keywords.SQLDialect + ok bool + }{ + {keywords.DialectPostgreSQL, true}, + {keywords.DialectOracle, true}, + {keywords.DialectSQLite, true}, + {keywords.DialectSQLServer, true}, + {keywords.DialectGeneric, true}, + {keywords.DialectMySQL, false}, + {keywords.DialectClickHouse, false}, + {keywords.DialectSnowflake, false}, + } { + _, err := parseDialect(`SELECT 'a\'`, tt.dialect) + if (err == nil) != tt.ok { + t.Errorf("%s: err = %v, want success %v", tt.dialect, err, tt.ok) + } + } +} + +// TestPrefixedLiteralsRoundTrip covers the introducers that carry meaning of +// their own: they must survive formatting and reparse to the same value. +func TestPrefixedLiteralsRoundTrip(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + prefix string + value string + want string // formatted literal + }{ + {keywords.DialectMySQL, `SELECT X'4D'`, "X", "4D", `X'4D'`}, + {keywords.DialectMySQL, `SELECT x'4d'`, "X", "4d", `X'4d'`}, + {keywords.DialectMySQL, `SELECT b'101'`, "B", "101", `B'101'`}, + {keywords.DialectMariaDB, `SELECT X'4D'`, "X", "4D", `X'4D'`}, + {keywords.DialectSQLite, `SELECT x'CAFE'`, "X", "CAFE", `X'CAFE'`}, + {keywords.DialectPostgreSQL, `SELECT B'101'`, "B", "101", `B'101'`}, + {keywords.DialectSnowflake, `SELECT X'4D'`, "X", "4D", `X'4D'`}, + {keywords.DialectOracle, `SELECT N'x' FROM dual`, "N", "x", `N'x'`}, + {keywords.DialectMySQL, `SELECT N'it''s'`, "N", "it's", `N'it''s'`}, + {keywords.DialectSQLServer, `SELECT N'abc'`, "N", "abc", `N'abc'`}, + {keywords.DialectMySQL, `SELECT _utf8mb4'abc'`, "_utf8mb4", "abc", `_utf8mb4'abc'`}, + {keywords.DialectPostgreSQL, `SELECT E'tab\there'`, "E", "tab\there", `E'tab\there'`}, + {keywords.DialectPostgreSQL, `SELECT E'a\\b\'c'`, "E", `a\b'c`, `E'a\\b''c'`}, + {keywords.DialectOracle, `SELECT q'[It's quoted]' FROM dual`, "q", "It's quoted", `q'[It's quoted]'`}, + {keywords.DialectOracle, `SELECT q'!a]'b!' FROM dual`, "q", "a]'b", `q'{a]'b}'`}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + tree, err := parseDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatal(err) + } + lit := selectedLiteral(t, tree) + if lit.Prefix != tt.prefix || lit.Value != tt.value { + t.Fatalf("got prefix %q value %q, want %q %q", lit.Prefix, lit.Value, tt.prefix, tt.value) + } + f1 := gosqlx.FormatAST(tree) + if !strings.Contains(f1, tt.want) { + t.Errorf("formatted %q does not contain %q", f1, tt.want) + } + tree2, err := parseDialect(f1, tt.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", f1, err) + } + lit2 := selectedLiteral(t, tree2) + if !strings.EqualFold(lit2.Prefix, lit.Prefix) || lit2.Value != lit.Value { + t.Errorf("reparse: prefix %q value %q, want %q %q", lit2.Prefix, lit2.Value, lit.Prefix, lit.Value) + } + if f2 := gosqlx.FormatAST(tree2); f2 != f1 { + t.Errorf("not idempotent: %q then %q", f1, f2) + } + }) + } +} + +func TestPrefixedLiteralInExpressions(t *testing.T) { + // The introducer works anywhere an expression does. + for _, sql := range []string{ + `SELECT * FROM t WHERE a = X'4D' AND b = N'x'`, + `INSERT INTO t (a, b) VALUES (X'4D', _utf8mb4'abc')`, + `SELECT * FROM t WHERE a IN (X'01', X'02')`, + `SELECT CASE WHEN a = b'1' THEN N'y' END FROM t`, + } { + tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectMySQL) + if err != nil { + t.Errorf("%q: %v", sql, err) + continue + } + out := gosqlx.FormatAST(tree) + for _, intro := range []string{"X'", "N'", "_utf8mb4'", "B'"} { + if strings.Contains(sql, intro) && !strings.Contains(out, intro) { + t.Errorf("%q: formatted %q lost the introducer %q", sql, out, intro) + } + } + tree2, err := gosqlx.ParseWithDialect(out, keywords.DialectMySQL) + if err != nil { + t.Errorf("%q: reparse of %q: %v", sql, out, err) + continue + } + if out2 := gosqlx.FormatAST(tree2); out2 != out { + t.Errorf("%q: not idempotent: %q then %q", sql, out, out2) + } + } +} + +func TestLexicalLiteralsFormat(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + want string + }{ + {keywords.DialectMySQL, `SELECT 0x1F`, "0x1F"}, + {keywords.DialectMySQL, `SELECT 0XAB, 1`, "0XAB"}, + {keywords.DialectClickHouse, `SELECT 0x1F`, "0x1F"}, + {keywords.DialectMySQL, `SELECT .5`, ".5"}, + {keywords.DialectPostgreSQL, `SELECT .5 + 1`, ".5 + 1"}, + {keywords.DialectOracle, `SELECT 5. FROM dual`, "5."}, + {keywords.DialectSQLite, `SELECT 5.`, "5."}, + {keywords.DialectOracle, `SELECT * FROM v$session`, "v$session"}, + {keywords.DialectOracle, `SELECT x FROM sys.gv$sql`, "sys.gv$sql"}, + {keywords.DialectSnowflake, `SELECT SYSTEM$TYPEOF(1)`, "SYSTEM$TYPEOF(1)"}, + {keywords.DialectSnowflake, `SELECT METADATA$FILENAME FROM t`, "METADATA$FILENAME"}, + {keywords.DialectPostgreSQL, `SELECT a$b FROM t`, "a$b"}, + {keywords.DialectSQLite, `SELECT a FROM t WHERE a = :name`, ":name"}, + {keywords.DialectSQLite, `SELECT ?1, ?2`, "?1, ?2"}, + {keywords.DialectSQLite, `SELECT [col] FROM [my table]`, `"my table"`}, + {keywords.DialectClickHouse, `SELECT a FROM t WHERE x = {p:UInt64}`, "{p:UInt64}"}, + } + for _, tt := range tests { + t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { + tree, err := parseDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatal(err) + } + f1 := gosqlx.FormatAST(tree) + if !strings.Contains(f1, tt.want) { + t.Errorf("formatted %q does not contain %q", f1, tt.want) + } + tree2, err := parseDialect(f1, tt.dialect) + if err != nil { + t.Fatalf("reparse %q: %v", f1, err) + } + if f2 := gosqlx.FormatAST(tree2); f2 != f1 { + t.Errorf("not idempotent: %q then %q", f1, f2) + } + }) + } +} + +func TestLeadingBOMParses(t *testing.T) { + for _, d := range []keywords.SQLDialect{"", keywords.DialectGeneric, keywords.DialectMySQL, keywords.DialectSQLServer} { + if _, err := parseDialect("\xEF\xBB\xBFSELECT 1", d); err != nil { + t.Errorf("%q: %v", d, err) + } + } +} diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 9b4dd92a..70e219a0 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -144,6 +144,17 @@ func (i Identifier) Children() []Node { return nil } type LiteralValue struct { Value interface{} Type string // INTEGER, FLOAT, STRING, BOOLEAN, NULL, etc. + // Prefix is the introducer written before a string literal's opening + // quote, as it appeared in the source: N for a national string, X or B for + // a hex or bit string, or a MySQL character-set introducer such as + // _utf8mb4. Empty for a plain string. Only meaningful when Type is STRING. + Prefix string + // RawBackslash is set when the literal came from a dialect in which a + // backslash inside a string is an ordinary character (PostgreSQL standard + // strings, Oracle, SQLite, SQL Server). Such a literal is written back + // without doubling backslashes or turning line breaks into escapes. + // Only meaningful when Type is STRING. + RawBackslash bool } func (l *LiteralValue) expressionNode() {} diff --git a/pkg/sql/ast/literal_string_escape_test.go b/pkg/sql/ast/literal_string_escape_test.go new file mode 100644 index 00000000..6bc2fac0 --- /dev/null +++ b/pkg/sql/ast/literal_string_escape_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast_test + +import ( + "bytes" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func TestLiteralValueStringRendering(t *testing.T) { + tests := []struct { + name string + lit ast.LiteralValue + want string + }{ + {"plain", ast.LiteralValue{Value: "abc", Type: "string"}, `'abc'`}, + {"quote doubled", ast.LiteralValue{Value: "it's", Type: "STRING"}, `'it''s'`}, + {"backslash doubled by default", ast.LiteralValue{Value: `a\b`, Type: "string"}, `'a\\b'`}, + {"newline escaped by default", ast.LiteralValue{Value: "a\nb", Type: "string"}, `'a\nb'`}, + {"raw backslash kept", ast.LiteralValue{Value: `a\b`, Type: "string", RawBackslash: true}, `'a\b'`}, + {"raw regex kept", ast.LiteralValue{Value: `x\.y`, Type: "string", RawBackslash: true}, `'x\.y'`}, + {"raw quote still doubled", ast.LiteralValue{Value: `it's \`, Type: "string", RawBackslash: true}, `'it''s \'`}, + {"raw newline literal", ast.LiteralValue{Value: "a\nb", Type: "string", RawBackslash: true}, "'a\nb'"}, + {"raw ctrl-z literal", ast.LiteralValue{Value: "a\x1ab", Type: "string", RawBackslash: true}, "'a\x1ab'"}, + {"raw NUL dropped", ast.LiteralValue{Value: "a\x00b", Type: "string", RawBackslash: true}, `'ab'`}, + {"raw invalid UTF-8 replaced", ast.LiteralValue{Value: "a\xffb", Type: "string", RawBackslash: true}, "'a�b'"}, + {"national", ast.LiteralValue{Value: "x", Type: "string", Prefix: "N"}, `N'x'`}, + {"hex", ast.LiteralValue{Value: "4D", Type: "string", Prefix: "X"}, `X'4D'`}, + {"bit", ast.LiteralValue{Value: "101", Type: "string", Prefix: "B"}, `B'101'`}, + {"charset introducer", ast.LiteralValue{Value: "abc", Type: "string", Prefix: "_utf8mb4"}, `_utf8mb4'abc'`}, + {"prefix with raw backslash", ast.LiteralValue{Value: `a\b`, Type: "string", Prefix: "N", RawBackslash: true}, `N'a\b'`}, + {"unsafe prefix dropped", ast.LiteralValue{Value: "x", Type: "string", Prefix: "x'; DROP TABLE t; --"}, `'x'`}, + {"E-string escapes", ast.LiteralValue{Value: "a\tb\\c\nd'e", Type: "string", Prefix: "E"}, `E'a\tb\\c\nd''e'`}, + {"E-string ctrl-z is raw", ast.LiteralValue{Value: "\x1a", Type: "string", Prefix: "E"}, "E'\x1a'"}, + {"q-quote default delimiter", ast.LiteralValue{Value: "It's", Type: "string", Prefix: "q"}, `q'[It's]'`}, + {"q-quote picks another delimiter", ast.LiteralValue{Value: "a]'b", Type: "string", Prefix: "q"}, `q'{a]'b}'`}, + {"q-quote falls back to a plain string", ast.LiteralValue{Value: "a\x00b", Type: "string", Prefix: "q"}, `'ab'`}, + {"prefix ignored for numbers", ast.LiteralValue{Value: "5", Type: "int", Prefix: "N"}, `5`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lit := tt.lit + if got := lit.SQL(); got != tt.want { + t.Errorf("SQL() = %q, want %q", got, tt.want) + } + // writeSQL (used by statement rendering) must agree with SQL(). + sel := &ast.SelectStatement{Columns: []ast.Expression{&lit}} + var buf bytes.Buffer + buf.WriteString(sel.SQL()) + if want := "SELECT " + tt.want; buf.String() != want { + t.Errorf("SelectStatement.SQL() = %q, want %q", buf.String(), want) + } + }) + } +} + +// TestLiteralValuePoolResetsStringFields checks that a pooled literal comes +// back without the previous user's introducer or escape mode. +func TestLiteralValuePoolResetsStringFields(t *testing.T) { + lit := ast.GetLiteralValue() + lit.Value, lit.Type, lit.Prefix, lit.RawBackslash = "x", "string", "N", true + ast.PutLiteralValue(lit) + + for i := 0; i < 100; i++ { + got := ast.GetLiteralValue() + if got.Prefix != "" || got.RawBackslash || got.Value != nil || got.Type != "" { + t.Fatalf("pooled literal not reset: %+v", *got) + } + ast.PutLiteralValue(got) + } +} diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index 2e23398e..17a97159 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -154,6 +154,10 @@ func isBareNameChars(name string, leadingHash bool) bool { if c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') || (c >= '0' && c <= '9') { continue } + if c == '$' && i > 0 { + // Not a first character: Oracle v$session, Snowflake SYSTEM$TYPEOF. + continue + } return false } if i == len(name) { @@ -163,6 +167,9 @@ func isBareNameChars(name string, leadingHash bool) bool { if r == '_' || unicode.IsLetter(r) { continue } + if r == '$' && i+j > 0 { + continue + } if unicode.IsDigit(r) { if i+j == 0 { return false // a non-ASCII digit cannot start a bare identifier diff --git a/pkg/sql/ast/render_ddl_name_quoting_632_test.go b/pkg/sql/ast/render_ddl_name_quoting_632_test.go index a661a881..9f065d21 100644 --- a/pkg/sql/ast/render_ddl_name_quoting_632_test.go +++ b/pkg/sql/ast/render_ddl_name_quoting_632_test.go @@ -505,14 +505,18 @@ func TestHandBuiltHostileDDLNames_NoStatementBreak_PR632(t *testing.T) { // only as the leading SQL Server temp-table marker and '@' only as a // Snowflake stage reference made of path characters: anywhere else the // tokenizer does not accept them bare (and MySQL treats '#' as a comment), -// so the part is quoted. +// so the part is quoted. '$' is a bare identifier character, but never the +// first one (Oracle v$session, Snowflake SYSTEM$TYPEOF). func TestQualifiedName_SpecialCharsOnlyLeading_PR632(t *testing.T) { cases := []struct{ in, want string }{ {"#tmp", "#tmp"}, {"##global", "##global"}, {"db.#tmp", "db.#tmp"}, {"a#b", `"a#b"`}, - {"a$b", `"a$b"`}, + {"a$b", "a$b"}, + {"v$session", "v$session"}, + {"$a", `"$a"`}, + {"1$a", `"1$a"`}, {"a@b", `"a@b"`}, {"@stage", "@stage"}, {"@db.sch.stage/path/file.csv", "@db.sch.stage/path/file.csv"}, diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 7372fb1b..64e3e56c 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -195,6 +195,10 @@ func identifierNeedsQuoting(name string) bool { case r == '.': digitLed, partStart = false, true continue + case r == '$' && !partStart && !digitLed: + // A later character of a name (Oracle v$session, Snowflake + // SYSTEM$TYPEOF); never the first. + continue case r != '_' && r != '*' && !unicode.IsLetter(r) && !unicode.IsDigit(r): return true case partStart: @@ -287,16 +291,7 @@ func (l *LiteralValue) SQL() string { } switch { case strings.EqualFold(l.Type, "STRING"): - text := literalText(l.Value) - if !stringLiteralNeedsEscape(text) { - return "'" + text + "'" - } - buf := getBuf() - defer putBuf(buf) - buf.WriteByte('\'') - writeEscapedStringLiteral(buf, text) - buf.WriteByte('\'') - return buf.String() + return l.stringSQL() case strings.EqualFold(l.Type, "BOOLEAN"): return strings.ToUpper(literalText(l.Value)) default: @@ -314,9 +309,7 @@ func (l *LiteralValue) writeSQL(buf *bytes.Buffer) { } switch { case strings.EqualFold(l.Type, "STRING"): - buf.WriteByte('\'') - writeEscapedStringLiteral(buf, literalText(l.Value)) - buf.WriteByte('\'') + l.writeStringSQL(buf) case strings.EqualFold(l.Type, "BOOLEAN"): buf.WriteString(strings.ToUpper(literalText(l.Value))) default: diff --git a/pkg/sql/ast/sql_string_literal.go b/pkg/sql/ast/sql_string_literal.go new file mode 100644 index 00000000..a3a83477 --- /dev/null +++ b/pkg/sql/ast/sql_string_literal.go @@ -0,0 +1,159 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "bytes" + "strings" + "unicode/utf8" +) + +// stringLiteralNeedsEscapeRaw is the RawBackslash counterpart of +// stringLiteralNeedsEscape: a quote, NUL or non-ASCII byte is rewritten, but +// backslashes, line breaks and Ctrl-Z are plain characters. +func stringLiteralNeedsEscapeRaw(s string) bool { + for i := 0; i < len(s); i++ { + if c := s[i]; c >= utf8.RuneSelf || c == '\'' || c == 0 { + return true + } + } + return false +} + +// writeRawStringLiteral escapes s for a single-quoted literal in a dialect +// where a backslash is an ordinary character: only the quote is doubled and +// invalid UTF-8 is replaced with U+FFFD. NUL bytes are dropped, as in +// writeEscapedStringLiteral. +func writeRawStringLiteral(buf *bytes.Buffer, s string) { + if !stringLiteralNeedsEscapeRaw(s) { + buf.WriteString(s) + return + } + for _, r := range s { + switch r { + case '\'': + buf.WriteString("''") + case '\x00': + default: + buf.WriteRune(r) + } + } +} + +// safeStringPrefix returns the literal's introducer, or "" when it is not a +// plain word (letters, digits, underscore). A hand-built AST could otherwise +// smuggle arbitrary text in front of the opening quote. +func (l *LiteralValue) safeStringPrefix() string { + for i := 0; i < len(l.Prefix); i++ { + c := l.Prefix[i] + if c != '_' && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') { + return "" + } + } + return l.Prefix +} + +// stringSQL renders a STRING literal. +func (l *LiteralValue) stringSQL() string { + text := literalText(l.Value) + if l.Prefix == "" { + if !l.RawBackslash && !stringLiteralNeedsEscape(text) { + return "'" + text + "'" + } + if l.RawBackslash && !stringLiteralNeedsEscapeRaw(text) { + return "'" + text + "'" + } + } + buf := getBuf() + defer putBuf(buf) + l.writeStringSQL(buf) + return buf.String() +} + +// writeStringSQL writes a STRING literal: the introducer (if any), the opening +// quote, the text escaped with the literal's own rules, and the closing quote. +func (l *LiteralValue) writeStringSQL(buf *bytes.Buffer) { + text := literalText(l.Value) + switch l.Prefix { + case "E", "e": + // PostgreSQL escape string: backslash sequences are decoded, so the + // text is escaped like a MySQL string, except that Ctrl-Z has no \Z. + buf.WriteString("E'") + writeEscapedEStringLiteral(buf, text) + buf.WriteByte('\'') + return + case "q", "Q": + // Oracle alternative quoting: no escaping at all, so pick a delimiter + // that does not close early. If none works, fall back to a plain + // string, which means the same. + if open, closer, ok := altQuoteDelimiters(text); ok { + buf.WriteString("q'") + buf.WriteByte(open) + buf.WriteString(text) + buf.WriteByte(closer) + buf.WriteByte('\'') + return + } + buf.WriteByte('\'') + writeRawStringLiteral(buf, text) + buf.WriteByte('\'') + return + } + buf.WriteString(l.safeStringPrefix()) + buf.WriteByte('\'') + if l.RawBackslash { + writeRawStringLiteral(buf, text) + } else { + writeEscapedStringLiteral(buf, text) + } + buf.WriteByte('\'') +} + +// altQuoteDelimiters picks the delimiters of an Oracle q'..' literal for text: +// the first of [], {}, (), <>, !!, # # whose closing delimiter followed by a +// quote does not occur in text. ok is false when text has a NUL or invalid +// UTF-8 (which a plain literal normalises) or no delimiter fits. +func altQuoteDelimiters(text string) (open, closer byte, ok bool) { + if !utf8.ValidString(text) || strings.IndexByte(text, 0) >= 0 { + return 0, 0, false + } + for _, d := range [...][2]byte{{'[', ']'}, {'{', '}'}, {'(', ')'}, {'<', '>'}, {'!', '!'}, {'#', '#'}} { + if !strings.Contains(text, string(d[1])+"'") { + return d[0], d[1], true + } + } + return 0, 0, false +} + +// writeEscapedEStringLiteral escapes s for a PostgreSQL E'..' literal. +func writeEscapedEStringLiteral(buf *bytes.Buffer, s string) { + for _, r := range s { + switch r { + case '\'': + buf.WriteString("''") + case '\\': + buf.WriteString(`\\`) + case 0: + case '\n': + buf.WriteString(`\n`) + case '\r': + buf.WriteString(`\r`) + case '\t': + buf.WriteString(`\t`) + default: + buf.WriteRune(r) + } + } +} diff --git a/pkg/sql/dialect/string_escape.go b/pkg/sql/dialect/string_escape.go new file mode 100644 index 00000000..4168b693 --- /dev/null +++ b/pkg/sql/dialect/string_escape.go @@ -0,0 +1,50 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dialect + +// StringEscape describes how a dialect treats the backslash inside a +// single-quoted string literal. The tokenizer uses it to decode literals and +// the parser records it on each string literal so the formatter can write the +// literal back with the same rules (re-escaping with the wrong rules doubles +// backslashes on every format pass). +type StringEscape uint8 + +const ( + // StringEscapeLenient is the default for an unspecified dialect and for + // ClickHouse, Snowflake, BigQuery and Redshift: C-style + // backslash escapes are decoded and an unrecognised sequence keeps its + // backslash, so the literal never fails to tokenize. + StringEscapeLenient StringEscape = iota + // StringEscapeNone means a backslash is an ordinary character + // (PostgreSQL standard strings, Oracle, SQLite, SQL Server and the + // standard-SQL "generic" dialect). Only a doubled quote escapes the quote. + StringEscapeNone + // StringEscapeMySQL is MySQL and MariaDB: backslash escapes are decoded, + // \% and \_ keep their backslash (so LIKE patterns survive) and any other + // escaped character stands for itself. + StringEscapeMySQL +) + +// StringEscape returns how the dialect treats a backslash in a string literal. +func (d Dialect) StringEscape() StringEscape { + switch d { + case PostgreSQL, Oracle, SQLite, SQLServer, Generic: + return StringEscapeNone + case MySQL, MariaDB: + return StringEscapeMySQL + default: + return StringEscapeLenient + } +} diff --git a/pkg/sql/dialect/string_escape_test.go b/pkg/sql/dialect/string_escape_test.go new file mode 100644 index 00000000..9c00bae8 --- /dev/null +++ b/pkg/sql/dialect/string_escape_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dialect + +import "testing" + +func TestStringEscape(t *testing.T) { + tests := []struct { + d Dialect + want StringEscape + }{ + {Unknown, StringEscapeLenient}, + {Generic, StringEscapeNone}, + {PostgreSQL, StringEscapeNone}, + {Oracle, StringEscapeNone}, + {SQLite, StringEscapeNone}, + {SQLServer, StringEscapeNone}, + {MySQL, StringEscapeMySQL}, + {MariaDB, StringEscapeMySQL}, + {ClickHouse, StringEscapeLenient}, + {Snowflake, StringEscapeLenient}, + {BigQuery, StringEscapeLenient}, + {Redshift, StringEscapeLenient}, + {Parse("postgres"), StringEscapeNone}, + {Parse("mssql"), StringEscapeNone}, + } + for _, tt := range tests { + if got := tt.d.StringEscape(); got != tt.want { + t.Errorf("%q.StringEscape() = %d, want %d", tt.d, got, tt.want) + } + } +} diff --git a/pkg/sql/parser/dml_update.go b/pkg/sql/parser/dml_update.go index 2f37e544..e57f6b45 100644 --- a/pkg/sql/parser/dml_update.go +++ b/pkg/sql/parser/dml_update.go @@ -59,7 +59,7 @@ func (p *Parser) parseUpdateStatement() (ast.Statement, error) { // Parse value expression var expr ast.Expression if p.isStringLiteral() { - expr = &ast.LiteralValue{Value: p.currentToken.Token.Value, Type: "string"} + expr = p.newStringLiteral(p.currentToken.Token.Value) p.advance() } else if p.isNumericLiteral() { litType := "int" diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 63c00919..7fbde3b9 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -63,6 +63,10 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { return &ast.UnaryExpression{Operator: ast.Prior, Expr: operand, Pos: priorPos}, nil } + if lit, ok := p.parsePrefixedStringLiteral(); ok { + return lit, nil + } + if p.isType(models.TokenTypeCase) { // Handle CASE expressions (both simple and searched forms) return p.parseCaseExpression() @@ -267,7 +271,7 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { // Handle string literals value := p.currentToken.Token.Value p.advance() - return &ast.LiteralValue{Value: value, Type: "string"}, nil + return p.newStringLiteral(value), nil } if p.isNumericLiteral() { diff --git a/pkg/sql/parser/pivot.go b/pkg/sql/parser/pivot.go index 4fbfc3a2..bc9ed2fd 100644 --- a/pkg/sql/parser/pivot.go +++ b/pkg/sql/parser/pivot.go @@ -53,8 +53,7 @@ func renderQuotedIdent(tok models.Token) string { // as is. func (p *Parser) renderPivotValue() string { if p.isStringLiteral() { - lit := ast.LiteralValue{Value: p.currentToken.Token.Value, Type: "STRING"} - return lit.SQL() + return p.newStringLiteral(p.currentToken.Token.Value).SQL() } return renderQuotedIdent(p.currentToken.Token) } diff --git a/pkg/sql/parser/string_literal.go b/pkg/sql/parser/string_literal.go new file mode 100644 index 00000000..9250a865 --- /dev/null +++ b/pkg/sql/parser/string_literal.go @@ -0,0 +1,95 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/dialect" +) + +// newStringLiteral builds the AST node for a string literal whose decoded text +// is value. The node records whether the dialect treats a backslash as an +// ordinary character, so the formatter writes the text back under the same +// rules instead of doubling every backslash on each pass. +func (p *Parser) newStringLiteral(value string) *ast.LiteralValue { + return &ast.LiteralValue{ + Value: value, + Type: "string", + RawBackslash: p.dialectTyped.StringEscape() == dialect.StringEscapeNone, + } +} + +// parsePrefixedStringLiteral parses a string literal that carries an +// introducer: N'..' (national), X'..' (hex), B'..' (bit), a PostgreSQL E'..' +// escape string and a MySQL character-set introducer such as _utf8mb4'..'. +// ok is false, with nothing consumed, when the current token starts no such +// literal. +// +// The introducer is kept in ast.LiteralValue.Prefix so the formatter writes it +// back: dropping X or N would change the value's type, and E and q keep the +// quoting the author chose. The tokenizer has already decoded the text. +func (p *Parser) parsePrefixedStringLiteral() (expr ast.Expression, ok bool) { + tok := p.currentToken.Token + switch tok.Type { + case models.TokenTypeNationalStringLiteral: + p.advance() + lit := p.newStringLiteral(tok.Value) + lit.Prefix = "N" + return lit, true + case models.TokenTypeHexStringLiteral: + p.advance() + lit := p.newStringLiteral(tok.Value) + lit.Prefix = "X" + return lit, true + case models.TokenTypeByteStringLiteral: + p.advance() + lit := p.newStringLiteral(tok.Value) + lit.Prefix = "B" + return lit, true + case models.TokenTypeEscapedStringLiteral: + p.advance() + lit := p.newStringLiteral(tok.Value) + lit.Prefix = "E" + lit.RawBackslash = false // an E-string is written with escapes + return lit, true + case models.TokenTypeSingleQuotedString: + // Oracle q'[text]': the tokenizer marks it with Quote 'q'. + if tok.Quote != 'q' { + return nil, false + } + p.advance() + lit := p.newStringLiteral(tok.Value) + lit.Prefix = "q" + return lit, true + case models.TokenTypeIdentifier: + // _charset'text': an underscore word directly followed by a string. + if len(tok.Value) < 2 || tok.Value[0] != '_' || tok.Quote != 0 || + (p.dialectTyped != dialect.MySQL && p.dialectTyped != dialect.MariaDB) { + return nil, false + } + next := p.peekToken() + if next.Token.Type != models.TokenTypeSingleQuotedString || next.Start != p.currentToken.End { + return nil, false + } + p.advance() // charset name + str := p.currentToken.Token.Value + p.advance() // string + lit := p.newStringLiteral(str) + lit.Prefix = tok.Value + return lit, true + } + return nil, false +} diff --git a/pkg/sql/tokenizer/error_location_test.go b/pkg/sql/tokenizer/error_location_test.go index 64b90c57..54d1df64 100644 --- a/pkg/sql/tokenizer/error_location_test.go +++ b/pkg/sql/tokenizer/error_location_test.go @@ -39,7 +39,7 @@ func refLocation(input string, idx int) models.Location { // TestTokenizer_ErrorLocations checks that tokenizer errors report the byte // that starts the offending construct (the opening delimiter of an -// unterminated literal, the escape character of a bad escape) under the same +// unterminated literal) under the same // byte-based rule as token spans. func TestTokenizer_ErrorLocations(t *testing.T) { cases := []struct { @@ -65,9 +65,10 @@ func TestTokenizer_ErrorLocations(t *testing.T) { {"bracket after tab", keywords.DialectSQLServer, "SELECT\t[col", "[", models.Location{Line: 1, Column: 8}}, {"bracket multi-line multibyte", keywords.DialectSQLServer, "SELECT N'é€',\n\t\t[abc def", "[", models.Location{Line: 2, Column: 3}}, - // Invalid escape: the character after the backslash. - {"bad escape after tab and multibyte", keywords.DialectMySQL, "SELECT\t'é\\q'", "q'", models.Location{Line: 1, Column: 12}}, - {"bad escape multi-line", keywords.DialectMySQL, "SELECT 'a\nb€',\n\t'x\\q'", "q'", models.Location{Line: 3, Column: 5}}, + // A backslash-escaped closing quote leaves the literal unterminated: + // the opening quote is reported. + {"escaped quote after tab and multibyte", keywords.DialectMySQL, "SELECT\t'é\\'", "'é", models.Location{Line: 1, Column: 8}}, + {"escaped quote multi-line", keywords.DialectMySQL, "SELECT 'a\nb€',\n\t'x\\'", "'x", models.Location{Line: 3, Column: 2}}, // Regression guards for paths that already used the opener. {"single quote multi-line", keywords.DialectPostgreSQL, "SELECT\t'é',\n\t'abc", "'abc", models.Location{Line: 2, Column: 2}}, diff --git a/pkg/sql/tokenizer/lexical_extensions_test.go b/pkg/sql/tokenizer/lexical_extensions_test.go new file mode 100644 index 00000000..77cf37ec --- /dev/null +++ b/pkg/sql/tokenizer/lexical_extensions_test.go @@ -0,0 +1,325 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import ( + "reflect" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +type lexTok struct { + typ models.TokenType + val string +} + +func lexAll(t *testing.T, d keywords.SQLDialect, sql string) []lexTok { + t.Helper() + tkz, _ := NewWithDialect(d) + tokens, err := tkz.TokenizeString(sql) + if err != nil { + t.Fatalf("%s %q: %v", d, sql, err) + } + var out []lexTok + for _, tw := range tokens { + if tw.Token.Type == models.TokenTypeEOF { + continue + } + out = append(out, lexTok{tw.Token.Type, tw.Token.Value}) + } + return out +} + +func TestLexNumbers(t *testing.T) { + num := models.TokenTypeNumber + tests := []struct { + name string + sql string + want []lexTok + }{ + {"hex", "0x1F", []lexTok{{num, "0x1F"}}}, + {"hex upper prefix and lower digits", "0XaB09", []lexTok{{num, "0XaB09"}}}, + {"hex in expression", "a = 0xFF AND b", []lexTok{ + {models.TokenTypeIdentifier, "a"}, {models.TokenTypeEq, "="}, {num, "0xFF"}, + {models.TokenTypeAnd, "AND"}, {models.TokenTypeIdentifier, "b"}}}, + {"0x without digits is zero then a word", "0x", []lexTok{{num, "0"}, {models.TokenTypeIdentifier, "x"}}}, + {"leading dot", ".5", []lexTok{{num, ".5"}}}, + {"leading dot after operator", "a+.5", []lexTok{{models.TokenTypeIdentifier, "a"}, {models.TokenTypePlus, "+"}, {num, ".5"}}}, + {"leading dot after comma", "(1,.25)", []lexTok{ + {models.TokenTypeLeftParen, "("}, {num, "1"}, {models.TokenTypeComma, ","}, {num, ".25"}, {models.TokenTypeRightParen, ")"}}}, + {"leading dot with exponent", ".5e-3", []lexTok{{num, ".5e-3"}}}, + {"qualifier dot is not a number", "t.5", []lexTok{{models.TokenTypeIdentifier, "t"}, {models.TokenTypeDot, "."}, {num, "5"}}}, + {"trailing dot", "5.", []lexTok{{num, "5."}}}, + {"trailing dot before comma", "5., 6", []lexTok{{num, "5."}, {models.TokenTypeComma, ","}, {num, "6"}}}, + {"trailing dot before keyword", "5. FROM", []lexTok{{num, "5."}, {models.TokenTypeFrom, "FROM"}}}, + {"trailing dot with exponent", "1.e5", []lexTok{{num, "1.e5"}}}, + {"dot then word stays separate", "1.foo", []lexTok{{num, "1"}, {models.TokenTypeDot, "."}, {models.TokenTypeIdentifier, "foo"}}}, + {"double dot stays separate", "1..2", []lexTok{{num, "1"}, {models.TokenTypeDot, "."}, {num, ".2"}}}, + {"plain decimal unchanged", "1.5", []lexTok{{num, "1.5"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := lexAll(t, keywords.DialectMySQL, tt.sql); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q\n got %v\nwant %v", tt.sql, got, tt.want) + } + }) + } +} + +func TestLexOperators(t *testing.T) { + tests := []struct { + sql string + want lexTok + }{ + {"^", lexTok{models.TokenTypeCaret, "^"}}, + {"^@", lexTok{models.TokenTypeCaretAt, "^@"}}, + {"<=>", lexTok{models.TokenTypeSpaceship, "<=>"}}, + {"<->", lexTok{models.TokenTypeCustomBinaryOperator, "<->"}}, + {"<<", lexTok{models.TokenTypeShiftLeft, "<<"}}, + {">>", lexTok{models.TokenTypeShiftRight, ">>"}}, + {"==", lexTok{models.TokenTypeDoubleEq, "=="}}, + {":=", lexTok{models.TokenTypeAssignment, ":="}}, + {"{", lexTok{models.TokenTypeLBrace, "{"}}, + {"}", lexTok{models.TokenTypeRBrace, "}"}}, + } + for _, tt := range tests { + for _, d := range []keywords.SQLDialect{keywords.DialectPostgreSQL, keywords.DialectMySQL, keywords.DialectSQLite, keywords.DialectOracle} { + got := lexAll(t, d, "a "+tt.sql+" b") + want := []lexTok{{models.TokenTypeIdentifier, "a"}, tt.want, {models.TokenTypeIdentifier, "b"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("%s %q\n got %v\nwant %v", d, tt.sql, got, want) + } + } + } +} + +// TestLexOperatorsKeepNeighbours guards the tokens that share a prefix with +// the new operators. +func TestLexOperatorsKeepNeighbours(t *testing.T) { + tests := []struct { + sql string + want []models.TokenType + }{ + {"a<=b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeLtEq, models.TokenTypeIdentifier}}, + {"a<>b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeNeq, models.TokenTypeIdentifier}}, + {"a<-1", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeLt, models.TokenTypeMinus, models.TokenTypeNumber}}, + {"a->b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeArrow, models.TokenTypeIdentifier}}, + {"a>=b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeGtEq, models.TokenTypeIdentifier}}, + {"a=>b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeRArrow, models.TokenTypeIdentifier}}, + {"a::b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeDoubleColon, models.TokenTypeIdentifier}}, + {"a<@b", []models.TokenType{models.TokenTypeIdentifier, models.TokenTypeArrowAt, models.TokenTypeIdentifier}}, + } + for _, tt := range tests { + var got []models.TokenType + for _, tok := range lexAll(t, keywords.DialectPostgreSQL, tt.sql) { + got = append(got, tok.typ) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q: got %v, want %v", tt.sql, got, tt.want) + } + } +} + +func TestLexIdentifierDollarAndHash(t *testing.T) { + id := models.TokenTypeIdentifier + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + want []lexTok + }{ + {"oracle v$session", keywords.DialectOracle, "v$session", []lexTok{{id, "v$session"}}}, + {"oracle gv$ view after dot", keywords.DialectOracle, "sys.gv$sql", []lexTok{{id, "sys"}, {models.TokenTypeDot, "."}, {id, "gv$sql"}}}, + {"oracle hash", keywords.DialectOracle, "a#", []lexTok{{id, "a#"}}}, + {"oracle hash and dollar", keywords.DialectOracle, "x#y$z", []lexTok{{id, "x#y$z"}}}, + {"snowflake SYSTEM$TYPEOF", keywords.DialectSnowflake, "SYSTEM$TYPEOF(1)", []lexTok{ + {id, "SYSTEM$TYPEOF"}, {models.TokenTypeLeftParen, "("}, {models.TokenTypeNumber, "1"}, {models.TokenTypeRightParen, ")"}}}, + {"snowflake METADATA$FILENAME", keywords.DialectSnowflake, "METADATA$FILENAME", []lexTok{{id, "METADATA$FILENAME"}}}, + {"postgres dollar", keywords.DialectPostgreSQL, "a$b", []lexTok{{id, "a$b"}}}, + {"postgres hash is an operator", keywords.DialectPostgreSQL, "a#>b", []lexTok{{id, "a"}, {models.TokenTypeHashArrow, "#>"}, {id, "b"}}}, + {"dollar cannot start a name", keywords.DialectOracle, "$1", []lexTok{{models.TokenTypePlaceholder, "$1"}}}, + {"hash cannot continue a name outside oracle", keywords.DialectMySQL, "a #", []lexTok{{id, "a"}, {models.TokenTypeSharp, "#"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := lexAll(t, tt.dialect, tt.sql); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q\n got %v\nwant %v", tt.sql, got, tt.want) + } + }) + } +} + +func TestLexPlaceholdersAndBrackets(t *testing.T) { + ph := models.TokenTypePlaceholder + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + want []lexTok + }{ + {"sqlite named", keywords.DialectSQLite, ":name", []lexTok{{ph, ":name"}}}, + {"sqlite positional", keywords.DialectSQLite, "?1", []lexTok{{ph, "?1"}}}, + {"sqlite bare question mark", keywords.DialectSQLite, "? ,?", []lexTok{{ph, "?"}, {models.TokenTypeComma, ","}, {ph, "?"}}}, + {"sqlite bracket identifier", keywords.DialectSQLite, "[col]", []lexTok{{models.TokenTypeIdentifier, "col"}}}, + {"sqlite bracket identifier with space", keywords.DialectSQLite, "[Order Details]", []lexTok{{models.TokenTypeIdentifier, "Order Details"}}}, + {"mysql question mark is not numbered", keywords.DialectMySQL, "?1", []lexTok{{ph, "?"}, {models.TokenTypeNumber, "1"}}}, + {"postgres colon is not a bind", keywords.DialectPostgreSQL, "a[1:n]", []lexTok{ + {models.TokenTypeIdentifier, "a"}, {models.TokenTypeLBracket, "["}, {models.TokenTypeNumber, "1"}, + {models.TokenTypeColon, ":"}, {models.TokenTypeIdentifier, "n"}, {models.TokenTypeRBracket, "]"}}}, + {"clickhouse typed parameter", keywords.DialectClickHouse, "{p:UInt64}", []lexTok{{ph, "{p:UInt64}"}}}, + {"clickhouse parameter with parenthesised type", keywords.DialectClickHouse, "{d: DateTime64(3, 'UTC')}", []lexTok{{ph, "{d: DateTime64(3, 'UTC')}"}}}, + {"clickhouse array parameter", keywords.DialectClickHouse, "x IN {ids:Array(UInt32)}", []lexTok{ + {models.TokenTypeIdentifier, "x"}, {models.TokenTypeIn, "IN"}, {ph, "{ids:Array(UInt32)}"}}}, + {"clickhouse map literal is not a parameter", keywords.DialectClickHouse, "{'a':1}", []lexTok{ + {models.TokenTypeLBrace, "{"}, {models.TokenTypeSingleQuotedString, "a"}, {models.TokenTypeColon, ":"}, + {models.TokenTypeNumber, "1"}, {models.TokenTypeRBrace, "}"}}}, + {"clickhouse unterminated parameter", keywords.DialectClickHouse, "{p:UInt64", []lexTok{ + {models.TokenTypeLBrace, "{"}, {models.TokenTypeIdentifier, "p"}, {models.TokenTypeColon, ":"}, {models.TokenTypeIdentifier, "UInt64"}}}, + {"other dialects see braces", keywords.DialectPostgreSQL, "{p}", []lexTok{ + {models.TokenTypeLBrace, "{"}, {models.TokenTypeIdentifier, "p"}, {models.TokenTypeRBrace, "}"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := lexAll(t, tt.dialect, tt.sql); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q\n got %v\nwant %v", tt.sql, got, tt.want) + } + }) + } +} + +func TestLexPrefixedStrings(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + typ models.TokenType + value string + quote rune + }{ + {"hex mysql", keywords.DialectMySQL, `X'4D'`, models.TokenTypeHexStringLiteral, "4D", 'X'}, + {"hex lower sqlite", keywords.DialectSQLite, `x'CAFE'`, models.TokenTypeHexStringLiteral, "CAFE", 'X'}, + {"bit mysql", keywords.DialectMySQL, `b'101'`, models.TokenTypeByteStringLiteral, "101", 'B'}, + {"bit upper postgres", keywords.DialectPostgreSQL, `B'101'`, models.TokenTypeByteStringLiteral, "101", 'B'}, + {"national oracle", keywords.DialectOracle, `N'x'`, models.TokenTypeNationalStringLiteral, "x", 'N'}, + {"national lower mysql", keywords.DialectMySQL, `n'x'`, models.TokenTypeNationalStringLiteral, "x", 'N'}, + {"national sqlserver", keywords.DialectSQLServer, `N'it''s'`, models.TokenTypeNationalStringLiteral, "it's", 'N'}, + {"escape string tab", keywords.DialectPostgreSQL, `E'tab\there'`, models.TokenTypeEscapedStringLiteral, "tab\there", 'E'}, + {"escape string quotes and backslash", keywords.DialectPostgreSQL, `E'it\'s \\ ok'`, models.TokenTypeEscapedStringLiteral, `it's \ ok`, 'E'}, + {"escape string doubled quote", keywords.DialectPostgreSQL, `e'a''b'`, models.TokenTypeEscapedStringLiteral, "a'b", 'E'}, + {"escape string octal hex unicode", keywords.DialectPostgreSQL, `E'\101\x42C\U00000044'`, models.TokenTypeEscapedStringLiteral, "ABCD", 'E'}, + {"escape string unknown escape", keywords.DialectPostgreSQL, `E'\q'`, models.TokenTypeEscapedStringLiteral, "q", 'E'}, + {"escape string multibyte", keywords.DialectPostgreSQL, "E'é\\n世'", models.TokenTypeEscapedStringLiteral, "é\n世", 'E'}, + {"q-quote brackets", keywords.DialectOracle, `q'[It's quoted]'`, models.TokenTypeSingleQuotedString, "It's quoted", 'q'}, + {"q-quote braces", keywords.DialectOracle, `q'{a}'`, models.TokenTypeSingleQuotedString, "a", 'q'}, + {"q-quote parens", keywords.DialectOracle, `Q'(b)'`, models.TokenTypeSingleQuotedString, "b", 'q'}, + {"q-quote angle", keywords.DialectOracle, `q''`, models.TokenTypeSingleQuotedString, "c", 'q'}, + {"q-quote custom delimiter", keywords.DialectOracle, `q'!d]'e!'`, models.TokenTypeSingleQuotedString, "d]'e", 'q'}, + {"q-quote with closer inside", keywords.DialectOracle, `q'[a]b]'`, models.TokenTypeSingleQuotedString, "a]b", 'q'}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tkz, _ := NewWithDialect(tt.dialect) + tokens, err := tkz.TokenizeString(tt.sql) + if err != nil { + t.Fatal(err) + } + if len(tokens) != 2 { // literal + EOF + t.Fatalf("got %d tokens, want 1: %v", len(tokens)-1, tokens) + } + tok := tokens[0].Token + if tok.Type != tt.typ || tok.Value != tt.value || tok.Quote != tt.quote { + t.Errorf("got {%s %q %q}, want {%s %q %q}", tok.Type, tok.Value, tok.Quote, tt.typ, tt.value, tt.quote) + } + if tokens[0].End.Column != len(tt.sql)+1 { + t.Errorf("end column %d, want %d", tokens[0].End.Column, len(tt.sql)+1) + } + }) + } +} + +// TestLexPrefixLettersOutsideTheirDialect checks that a letter before a quote +// is an introducer only where the dialect has it. +func TestLexPrefixLettersOutsideTheirDialect(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectMySQL, `E'x'`}, + {keywords.DialectOracle, `X'4D'`}, + {keywords.DialectOracle, `E'x'`}, + {keywords.DialectPostgreSQL, `q'[x]'`}, + {keywords.DialectMySQL, `q'[x]'`}, + {keywords.DialectSQLite, `N'x'`}, + {keywords.DialectSQLite, `b'1'`}, + {keywords.DialectClickHouse, `X'4D'`}, + } + for _, tt := range tests { + got := lexAll(t, tt.dialect, tt.sql) + if len(got) != 2 || got[0].typ != models.TokenTypeIdentifier { + t.Errorf("%s %q: got %v, want identifier then string", tt.dialect, tt.sql, got) + } + } +} + +func TestLexQQuoteEdgeCases(t *testing.T) { + tkz, _ := NewWithDialect(keywords.DialectOracle) + if _, err := tkz.TokenizeString(`SELECT q'[never closed' FROM dual`); err == nil { + t.Error("unterminated q-quote: expected an error") + } + // q followed by a quote and a blank is the word q and a plain string. + got := lexAll(t, keywords.DialectOracle, `q' x'`) + if len(got) != 2 || got[0].typ != models.TokenTypeIdentifier || got[1].typ != models.TokenTypeSingleQuotedString { + t.Errorf("q' x': got %v", got) + } + pg, _ := NewWithDialect(keywords.DialectPostgreSQL) + if _, err := pg.TokenizeString(`E'abc\`); err == nil { + t.Error("unterminated E-string: expected an error") + } +} + +func TestLexLeadingBOM(t *testing.T) { + tkz, _ := NewWithDialect(keywords.DialectPostgreSQL) + for _, api := range []string{"Tokenize", "TokenizeString"} { + sql := "\xEF\xBB\xBFSELECT 1" + var tokens []models.TokenWithSpan + var err error + if api == "Tokenize" { + tokens, err = tkz.Tokenize([]byte(sql)) + } else { + tokens, err = tkz.TokenizeString(sql) + } + if err != nil { + t.Fatalf("%s: %v", api, err) + } + if len(tokens) != 3 || tokens[0].Token.Type != models.TokenTypeSelect { + t.Fatalf("%s: tokens %v", api, tokens) + } + if tokens[0].Start != (models.Location{Line: 1, Column: 1}) { + t.Errorf("%s: SELECT starts at %+v, want 1:1", api, tokens[0].Start) + } + if tokens[1].Start != (models.Location{Line: 1, Column: 8}) { + t.Errorf("%s: number starts at %+v, want 1:8", api, tokens[1].Start) + } + } + // A BOM-only input is empty; a BOM later in the text is still an error. + if tokens, err := tkz.TokenizeString("\xEF\xBB\xBF"); err != nil || len(tokens) != 1 { + t.Errorf("BOM only: tokens %v, err %v", tokens, err) + } + if _, err := tkz.TokenizeString("SELECT \xEF\xBB\xBF1"); err == nil { + t.Error("BOM in the middle of the text: expected an error") + } +} diff --git a/pkg/sql/tokenizer/oracle_bind_param_test.go b/pkg/sql/tokenizer/oracle_bind_param_test.go index 7e048d25..dc83e41b 100644 --- a/pkg/sql/tokenizer/oracle_bind_param_test.go +++ b/pkg/sql/tokenizer/oracle_bind_param_test.go @@ -201,8 +201,7 @@ func TestTokenizer_OracleColonNonBind(t *testing.T) { input: "x := 1", want: []wantTok{ {models.TokenTypeIdentifier, "x"}, - {models.TokenTypeColon, ":"}, - {models.TokenTypeEq, "="}, + {models.TokenTypeAssignment, ":="}, {models.TokenTypeNumber, "1"}, }, }, @@ -251,13 +250,14 @@ func TestTokenizer_OracleColonNonBind(t *testing.T) { }) } -func TestTokenizer_ColonBindIsOracleOnly(t *testing.T) { +// TestTokenizer_ColonBindIsOracleAndSQLiteOnly: ':name' is a bind variable in +// Oracle and SQLite; elsewhere ':' keeps its other uses (slices, VARIANT paths). +func TestTokenizer_ColonBindIsOracleAndSQLiteOnly(t *testing.T) { dialects := []keywords.SQLDialect{ keywords.DialectPostgreSQL, keywords.DialectMySQL, keywords.DialectMariaDB, keywords.DialectSQLServer, - keywords.DialectSQLite, keywords.DialectSnowflake, keywords.DialectClickHouse, keywords.DialectGeneric, diff --git a/pkg/sql/tokenizer/placeholders.go b/pkg/sql/tokenizer/placeholders.go new file mode 100644 index 00000000..cde4a9ff --- /dev/null +++ b/pkg/sql/tokenizer/placeholders.go @@ -0,0 +1,86 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import "unicode/utf8" + +// clickHouseParamEnd reports where a ClickHouse query parameter such as +// {id:UInt64} or {d:DateTime64(3, 'UTC')} that starts at t.pos ends, as an +// index just past the closing brace. It returns 0 when the text at t.pos is +// not a parameter: the brace must be followed by a name, a colon and a +// non-empty type, and the type may nest parentheses and quoted strings. A map +// or object literal such as {'a': 1} is therefore left to the caller. +func (t *Tokenizer) clickHouseParamEnd() int { + in := t.input + i := t.pos.Index + 1 // past '{' + for i < len(in) && (in[i] == ' ' || in[i] == '\t') { + i++ + } + nameStart := i + for i < len(in) { + r, size := utf8.DecodeRune(in[i:]) + if !(i == nameStart && isIdentifierStart(r)) && !(i > nameStart && isIdentifierChar(r)) { + break + } + i += size + } + if i == nameStart { + return 0 + } + for i < len(in) && (in[i] == ' ' || in[i] == '\t') { + i++ + } + if i >= len(in) || in[i] != ':' { + return 0 + } + i++ + typeStart := i + depth := 0 + for i < len(in) { + switch c := in[i]; c { + case '\'', '"', '`': + // Skip a quoted string or identifier inside the type. + q := c + i++ + for i < len(in) && in[i] != q { + if in[i] == '\\' && i+1 < len(in) { + i++ + } + i++ + } + if i >= len(in) { + return 0 + } + case '(': + depth++ + case ')': + depth-- + if depth < 0 { + return 0 + } + case '}': + if depth == 0 { + if i == typeStart { + return 0 + } + return i + 1 + } + case '\n', ';': + return 0 + } + i++ + } + return 0 +} diff --git a/pkg/sql/tokenizer/pool.go b/pkg/sql/tokenizer/pool.go index 646f2439..f57b4e01 100644 --- a/pkg/sql/tokenizer/pool.go +++ b/pkg/sql/tokenizer/pool.go @@ -20,6 +20,7 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/metrics" "github.com/ajitpratap0/GoSQLX/pkg/models" + sqldialect "github.com/ajitpratap0/GoSQLX/pkg/sql/dialect" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" ) @@ -128,6 +129,7 @@ func PutTokenizer(t *Tokenizer) { // Tokenize call where the configured dialect must persist. The dialect // is a plain field, so restoring it is a single assignment. t.dialect = keywords.DialectPostgreSQL + t.strEscape = sqldialect.StringEscapeLenient tokenizerPool.Put(t) diff --git a/pkg/sql/tokenizer/prefixed_string.go b/pkg/sql/tokenizer/prefixed_string.go new file mode 100644 index 00000000..809ef303 --- /dev/null +++ b/pkg/sql/tokenizer/prefixed_string.go @@ -0,0 +1,259 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import ( + "bytes" + "unicode/utf8" + + "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// This file lexes string literals with a one-letter introducer written +// directly before the opening quote: +// +// N'text' national string (TokenTypeNationalStringLiteral) +// X'4D' hexadecimal string (TokenTypeHexStringLiteral) +// B'101' bit string (TokenTypeByteStringLiteral) +// E'a\tb' PostgreSQL escape string (TokenTypeEscapedStringLiteral) +// q'[it's]' Oracle alternative quote (TokenTypeSingleQuotedString, Quote 'q') +// +// Each token's Value is the decoded text. The introducer decides the token +// type, so the parser can keep it for the round trip (N, X, B) or discard it +// when the decoded text is all the literal means (E, q). + +// prefixedStringAllowed reports whether the introducer letter (upper-cased) +// starts a prefixed literal in the tokenizer's dialect. An unconfigured +// tokenizer behaves as PostgreSQL. +func (t *Tokenizer) prefixedStringAllowed(letter byte) bool { + d := t.dialect + switch letter { + case 'N': + return d == keywords.DialectSQLServer || d == keywords.DialectOracle || + d == keywords.DialectMySQL || d == keywords.DialectMariaDB || d == keywords.DialectPostgreSQL + case 'X': + return d == keywords.DialectMySQL || d == keywords.DialectMariaDB || + d == keywords.DialectSQLite || d == keywords.DialectPostgreSQL || d == keywords.DialectSnowflake + case 'B': + return d == keywords.DialectMySQL || d == keywords.DialectMariaDB || d == keywords.DialectPostgreSQL + case 'E': + return d == keywords.DialectPostgreSQL + case 'Q': + return d == keywords.DialectOracle + } + return false +} + +// readPrefixedString lexes a prefixed literal whose one-letter introducer has +// just been consumed, leaving t.pos on the opening quote. ok is false, with +// nothing consumed, when the letter is not an introducer in this dialect, so +// the caller reads an ordinary identifier. +func (t *Tokenizer) readPrefixedString(letter byte) (tok models.Token, ok bool, err error) { + if letter >= 'a' && letter <= 'z' { + letter -= 'a' - 'A' + } + if !t.prefixedStringAllowed(letter) { + return models.Token{}, false, nil + } + switch letter { + case 'N', 'X', 'B': + inner, err := t.readQuotedString('\'') + if err != nil { + return models.Token{}, true, err + } + typ := models.TokenTypeNationalStringLiteral + switch letter { + case 'X': + typ = models.TokenTypeHexStringLiteral + case 'B': + typ = models.TokenTypeByteStringLiteral + } + return models.Token{Type: typ, Value: inner.Value, Quote: rune(letter)}, true, nil + case 'E': + tok, err := t.readEscapedString() + return tok, true, err + default: // 'Q' + return t.readAltQuotedString() + } +} + +// readEscapedString reads the body of a PostgreSQL E'...' string. t.pos is on +// the opening quote. Backslash sequences are decoded (\b \f \n \r \t, octal +// \ooo, \xhh, \uXXXX, \UXXXXXXXX); any other escaped character stands for +// itself, so \\ and \' work. A doubled quote is a quote. +func (t *Tokenizer) readEscapedString() (models.Token, error) { + startPos := t.pos + t.pos.Index++ // opening quote (ASCII) + t.pos.Column++ + buf := &t.litBuf + buf.Reset() + in := t.input + for t.pos.Index < len(in) { + c := in[t.pos.Index] + switch { + case c == '\'': + if t.pos.Index+1 < len(in) && in[t.pos.Index+1] == '\'' { + buf.WriteByte('\'') + t.pos.Index += 2 + t.pos.Column += 2 + continue + } + t.pos.Index++ + t.pos.Column++ + return models.Token{Type: models.TokenTypeEscapedStringLiteral, Value: buf.String(), Quote: 'E'}, nil + case c == '\\': + t.pos.Index++ + t.pos.Column++ + if t.pos.Index >= len(in) { + return models.Token{}, errors.UnterminatedStringError(t.toSQLPosition(startPos), t.src) + } + t.decodePGEscape(buf) + default: + r, size := utf8.DecodeRune(in[t.pos.Index:]) + if r == utf8.RuneError && size == 1 { + buf.WriteRune(utf8.RuneError) + } else { + buf.Write(in[t.pos.Index : t.pos.Index+size]) + } + t.pos.AdvanceRune(r, size) + } + } + return models.Token{}, errors.UnterminatedStringError(t.toSQLPosition(startPos), t.src) +} + +// decodePGEscape decodes the escape that follows a backslash in an E-string; +// t.pos is on the character after the backslash. +func (t *Tokenizer) decodePGEscape(buf *bytes.Buffer) { + in := t.input + c := in[t.pos.Index] + hexRun := func(max int) (v rune, n int) { + for n < max && t.pos.Index+1+n < len(in) { + d := in[t.pos.Index+1+n] + switch { + case d >= '0' && d <= '9': + v = v<<4 | rune(d-'0') + case d >= 'a' && d <= 'f': + v = v<<4 | rune(d-'a'+10) + case d >= 'A' && d <= 'F': + v = v<<4 | rune(d-'A'+10) + default: + return v, n + } + n++ + } + return v, n + } + switch { + case c >= '0' && c <= '7': + v, n := rune(0), 0 + for n < 3 && t.pos.Index+n < len(in) && in[t.pos.Index+n] >= '0' && in[t.pos.Index+n] <= '7' { + v = v<<3 | rune(in[t.pos.Index+n]-'0') + n++ + } + buf.WriteByte(byte(v)) + t.pos.Index += n + t.pos.Column += n + case c == 'x': + if v, n := hexRun(2); n > 0 { + buf.WriteByte(byte(v)) + t.pos.Index += 1 + n + t.pos.Column += 1 + n + return + } + buf.WriteByte('x') + t.pos.Index++ + t.pos.Column++ + case c == 'u' || c == 'U': + want := 4 + if c == 'U' { + want = 8 + } + if v, n := hexRun(want); n == want && utf8.ValidRune(v) { + buf.WriteRune(v) + t.pos.Index += 1 + n + t.pos.Column += 1 + n + return + } + buf.WriteByte(c) + t.pos.Index++ + t.pos.Column++ + default: + switch c { + case 'b': + buf.WriteByte('\b') + case 'f': + buf.WriteByte('\f') + case 'n': + buf.WriteByte('\n') + case 'r': + buf.WriteByte('\r') + case 't': + buf.WriteByte('\t') + default: + r, size := utf8.DecodeRune(in[t.pos.Index:]) + buf.WriteRune(r) + t.pos.AdvanceRune(r, size) + return + } + t.pos.Index++ + t.pos.Column++ + } +} + +// readAltQuotedString reads an Oracle alternative-quoting literal q'...' +// where is any non-blank character: [ { ( < close with the matching +// bracket, any other character closes with itself. t.pos is on the opening +// quote. ok is false, with nothing consumed, when no delimiter follows the +// quote (q is then an ordinary word). +func (t *Tokenizer) readAltQuotedString() (models.Token, bool, error) { + in := t.input + d := t.pos.Index + 1 + if d >= len(in) { + return models.Token{}, false, nil + } + open, dsize := utf8.DecodeRune(in[d:]) + if open == ' ' || open == '\t' || open == '\n' || open == '\r' || open == '\'' { + return models.Token{}, false, nil + } + closer := open + switch open { + case '[': + closer = ']' + case '{': + closer = '}' + case '(': + closer = ')' + case '<': + closer = '>' + } + startPos := t.pos + contentStart := d + dsize + for i := contentStart; i < len(in); { + r, size := utf8.DecodeRune(in[i:]) + if r == closer && i+size < len(in) && in[i+size] == '\'' { + value := t.src[contentStart:i] + end := i + size + 1 + for t.pos.Index < end { + cr, cs := utf8.DecodeRune(in[t.pos.Index:]) + t.pos.AdvanceRune(cr, cs) + } + return models.Token{Type: models.TokenTypeSingleQuotedString, Value: value, Quote: 'q'}, true, nil + } + i += size + } + return models.Token{}, true, errors.UnterminatedStringError(t.toSQLPosition(startPos), t.src) +} diff --git a/pkg/sql/tokenizer/quoted_zero_copy_test.go b/pkg/sql/tokenizer/quoted_zero_copy_test.go index 8b46e0d2..082d9a07 100644 --- a/pkg/sql/tokenizer/quoted_zero_copy_test.go +++ b/pkg/sql/tokenizer/quoted_zero_copy_test.go @@ -58,8 +58,8 @@ type quotedCase struct { } var ( - backslashDialects = []keywords.SQLDialect{"", keywords.DialectPostgreSQL, keywords.DialectMySQL, keywords.DialectMariaDB} - noBackslashDialects = []keywords.SQLDialect{keywords.DialectSQLServer, keywords.DialectOracle, keywords.DialectSQLite, keywords.DialectSnowflake, keywords.DialectClickHouse} + backslashDialects = []keywords.SQLDialect{"", keywords.DialectMySQL, keywords.DialectMariaDB, keywords.DialectSnowflake, keywords.DialectClickHouse} + noBackslashDialects = []keywords.SQLDialect{keywords.DialectPostgreSQL, keywords.DialectSQLServer, keywords.DialectOracle, keywords.DialectSQLite} ) func quotedCases() []quotedCase { @@ -77,8 +77,12 @@ func quotedCases() []quotedCase { {name: "empty", dialect: pg, sql: `''`, typ: str, value: "", quote: '\'', quoteStyle: noWord, end: loc(1, 3)}, {name: "doubled quote", dialect: pg, sql: `'it''s'`, typ: str, value: "it's", quote: '\'', quoteStyle: noWord, end: loc(1, 8), alias: aliasNo}, {name: "trailing doubled quote", dialect: pg, sql: `'a'''`, typ: str, value: "a'", quote: '\'', quoteStyle: noWord, alias: aliasNo}, - {name: "escaped backslash", dialect: pg, sql: `'a\\'`, typ: str, value: `a\`, quote: '\'', quoteStyle: noWord, alias: aliasNo}, - {name: "invalid escape", dialect: pg, sql: `'a\q'`, errCode: gerrors.ErrCodeInvalidSyntax, errLoc: loc(1, 4)}, + {name: "escaped backslash", dialect: keywords.DialectMySQL, sql: `'a\\'`, typ: str, value: `a\`, quote: '\'', quoteStyle: noWord, alias: aliasNo}, + {name: "unknown escape mysql drops the backslash", dialect: keywords.DialectMySQL, sql: `'a\q'`, typ: str, value: `aq`, quote: '\'', quoteStyle: noWord, alias: aliasNo}, + {name: "unknown escape keeps the backslash", dialect: keywords.DialectClickHouse, sql: `'a\q'`, typ: str, value: `a\q`, quote: '\'', quoteStyle: noWord, alias: aliasNo}, + {name: "like escapes keep the backslash", dialect: keywords.DialectMySQL, sql: `'50\%\_'`, typ: str, value: `50\%\_`, quote: '\'', quoteStyle: noWord, alias: aliasNo}, + {name: "backslash quote does not close", dialect: keywords.DialectClickHouse, sql: `'a\'b'`, typ: str, value: `a'b`, quote: '\'', quoteStyle: noWord, alias: aliasNo}, + {name: "standard string ends at backslash quote", dialect: pg, sql: `'a\' x`, typ: str, value: `a\`, quote: '\'', quoteStyle: noWord, end: loc(1, 5), alias: aliasYes}, {name: "multi-line", dialect: pg, sql: "'a\nb' x", typ: str, value: "a\nb", quote: '\'', quoteStyle: noWord, end: loc(2, 3), alias: aliasYes}, {name: "unterminated at EOF", dialect: pg, sql: "'abc", errCode: gerrors.ErrCodeUnterminatedString, errLoc: loc(1, 1)}, {name: "unterminated after newline", dialect: pg, sql: "x 'abc\n", errCode: gerrors.ErrCodeUnterminatedString, errLoc: loc(1, 3)}, diff --git a/pkg/sql/tokenizer/string_escape_modes_test.go b/pkg/sql/tokenizer/string_escape_modes_test.go new file mode 100644 index 00000000..e1629505 --- /dev/null +++ b/pkg/sql/tokenizer/string_escape_modes_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// firstString tokenizes sql and returns the first string-like token. +func firstString(t *testing.T, tkz *Tokenizer, sql string) (models.Token, error) { + t.Helper() + tokens, err := tkz.TokenizeString(sql) + if err != nil { + return models.Token{}, err + } + for _, tw := range tokens { + switch tw.Token.Type { + case models.TokenTypeSingleQuotedString, models.TokenTypeNationalStringLiteral, + models.TokenTypeHexStringLiteral, models.TokenTypeByteStringLiteral, + models.TokenTypeEscapedStringLiteral: + return tw.Token, nil + } + } + t.Fatalf("no string token in %q", sql) + return models.Token{}, nil +} + +// TestStringEscapeModes pins how every dialect reads a backslash inside a +// single-quoted string: PostgreSQL standard strings, Oracle, SQLite, SQL Server +// and generic keep it, MySQL/MariaDB decode escapes (\% and \_ keep their +// backslash, other escaped characters stand for themselves), the remaining +// dialects decode escapes and keep the backslash of unknown ones. +func TestStringEscapeModes(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + want string + }{ + {"postgresql keeps backslash", keywords.DialectPostgreSQL, `SELECT 'a\b'`, `a\b`}, + {"postgresql regex", keywords.DialectPostgreSQL, `SELECT 'x\.y'`, `x\.y`}, + {"postgresql backslash before quote closes", keywords.DialectPostgreSQL, `SELECT '\'`, `\`}, + {"postgresql doubled quote", keywords.DialectPostgreSQL, `SELECT 'it''s'`, `it's`}, + {"oracle keeps backslash", keywords.DialectOracle, `SELECT 'a\b' FROM dual`, `a\b`}, + {"sqlite keeps backslash", keywords.DialectSQLite, `SELECT 'C:\dir\n'`, `C:\dir\n`}, + {"sqlserver keeps backslash", keywords.DialectSQLServer, `SELECT 'a\nb'`, `a\nb`}, + {"generic keeps backslash", keywords.DialectGeneric, `SELECT 'a\nb'`, `a\nb`}, + + {"mysql newline escape", keywords.DialectMySQL, `SELECT 'a\nb'`, "a\nb"}, + {"mysql escaped backslash", keywords.DialectMySQL, `SELECT 'a\\b'`, `a\b`}, + {"mysql escaped quote", keywords.DialectMySQL, `SELECT 'it\'s'`, `it's`}, + {"mysql like percent keeps backslash", keywords.DialectMySQL, `SELECT '50\%'`, `50\%`}, + {"mysql like underscore keeps backslash", keywords.DialectMySQL, `SELECT '\_x'`, `\_x`}, + {"mysql unknown escape drops backslash", keywords.DialectMySQL, `SELECT 'a\qb'`, `aqb`}, + {"mysql NUL escape", keywords.DialectMySQL, `SELECT 'a\0b'`, "a\x00b"}, + {"mysql backspace escape", keywords.DialectMySQL, `SELECT 'a\bb'`, "a\bb"}, + {"mysql ctrl-z escape", keywords.DialectMySQL, `SELECT 'a\Zb'`, "a\x1ab"}, + {"mariadb like percent", keywords.DialectMariaDB, `SELECT '50\%'`, `50\%`}, + + {"clickhouse escaped quote", keywords.DialectClickHouse, `SELECT 'a\'b'`, `a'b`}, + {"clickhouse unknown escape keeps backslash", keywords.DialectClickHouse, `SELECT 'x\.y'`, `x\.y`}, + {"clickhouse escaped backslash", keywords.DialectClickHouse, `SELECT 'a\\b'`, `a\b`}, + {"snowflake escaped quote", keywords.DialectSnowflake, `SELECT 'a\'b'`, `a'b`}, + {"snowflake tab escape", keywords.DialectSnowflake, `SELECT 'a\tb'`, "a\tb"}, + {"snowflake unknown escape keeps backslash", keywords.DialectSnowflake, `SELECT '\d+'`, `\d+`}, + + {"unconfigured tokenizer decodes", "", `SELECT 'a\nb'`, "a\nb"}, + {"unconfigured tokenizer keeps unknown escape", "", `SELECT 'x\.y'`, `x\.y`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tkz *Tokenizer + if tt.dialect == "" { + tkz, _ = New() + } else { + tkz, _ = NewWithDialect(tt.dialect) + } + for _, api := range []string{"Tokenize", "TokenizeString"} { + var tok models.Token + var err error + if api == "Tokenize" { + tokens, e := tkz.Tokenize([]byte(tt.sql)) + err = e + for _, tw := range tokens { + if tw.Token.Type == models.TokenTypeSingleQuotedString { + tok = tw.Token + break + } + } + } else { + tok, err = firstString(t, tkz, tt.sql) + } + if err != nil { + t.Fatalf("%s(%q): %v", api, tt.sql, err) + } + if tok.Value != tt.want { + t.Errorf("%s(%q) value = %q, want %q", api, tt.sql, tok.Value, tt.want) + } + } + }) + } +} + +// TestStringEscapeMultiLineEscape checks line tracking when a backslash +// escapes a line break. +func TestStringEscapeMultiLineEscape(t *testing.T) { + tkz, _ := NewWithDialect(keywords.DialectMySQL) + tokens, err := tkz.TokenizeString("SELECT 'a\\\nb', 1") + if err != nil { + t.Fatal(err) + } + last := tokens[len(tokens)-2] // the number 1 + if last.Token.Value != "1" || last.Start.Line != 2 { + t.Errorf("token after escaped newline: value %q at line %d, want 1 at line 2", last.Token.Value, last.Start.Line) + } +} + +// TestStringEscapeUnterminated checks that a backslash before the closing +// quote leaves a string unterminated only where the backslash escapes. +func TestStringEscapeUnterminated(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + wantErr bool + }{ + {keywords.DialectMySQL, `SELECT 'a\'`, true}, + {keywords.DialectClickHouse, `SELECT 'a\'`, true}, + {keywords.DialectPostgreSQL, `SELECT 'a\'`, false}, + {keywords.DialectOracle, `SELECT 'a\'`, false}, + {keywords.DialectSQLite, `SELECT 'a\'`, false}, + {keywords.DialectMySQL, `SELECT 'a\`, true}, + } + for _, tt := range tests { + tkz, _ := NewWithDialect(tt.dialect) + _, err := tkz.TokenizeString(tt.sql) + if (err != nil) != tt.wantErr { + t.Errorf("%s %q: err = %v, wantErr %v", tt.dialect, tt.sql, err, tt.wantErr) + } + } +} + +// TestStringEscapePoolReset checks that a pooled tokenizer does not carry a +// dialect's escape rules to the next user. +func TestStringEscapePoolReset(t *testing.T) { + tkz := GetTokenizer() + tkz.SetDialect(keywords.DialectPostgreSQL) + PutTokenizer(tkz) + + tkz = GetTokenizer() + defer PutTokenizer(tkz) + tok, err := firstString(t, tkz, `SELECT 'a\nb'`) + if err != nil { + t.Fatal(err) + } + if tok.Value != "a\nb" { + t.Errorf("pooled tokenizer kept PostgreSQL escape rules: value %q", tok.Value) + } +} diff --git a/pkg/sql/tokenizer/testdata/tokenizer_literal_golden.json b/pkg/sql/tokenizer/testdata/tokenizer_literal_golden.json index f3b3ffc5..908a1ddd 100644 --- a/pkg/sql/tokenizer/testdata/tokenizer_literal_golden.json +++ b/pkg/sql/tokenizer/testdata/tokenizer_literal_golden.json @@ -3,7 +3,7 @@ {"id":"file:../../../testdata/corpus/orm/gorm_joins.sql","content_sha":"26fb2a1ec5ee93af2f4c8cf5a6e80ef5bf06744690854966ca53843a5aa484b1","digests":["35523e9e35b72355","35523e9e35b72355","35523e9e35b72355","35523e9e35b72355","35523e9e35b72355","35523e9e35b72355","35523e9e35b72355","35523e9e35b72355","35523e9e35b72355"]}, {"id":"file:../../../testdata/corpus/orm/gorm_preload.sql","content_sha":"7d7dc913900304052f48c6cc3971f76b702ac96181adb6bdc2e94faf28357694","digests":["c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b","c9d0d55fd585787b"]}, {"id":"file:../../../testdata/corpus/orm/gorm_scopes.sql","content_sha":"cf642ff1feaa14a0f2c6f7631a17da25158042ed71ab4799d991deb362fb8676","digests":["6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837","6fa2256b5e44a837"]}, -{"id":"file:../../../testdata/corpus/orm/sqlx_queries.sql","content_sha":"37997a04635db8085f600542cdc580151ea17a52086e5fd7e91e7aeef30bb872","digests":["fdf6696282be7793","fdf6696282be7793","fdf6696282be7793","fdf6696282be7793","8bd48b782468c54b","fdf6696282be7793","fdf6696282be7793","fdf6696282be7793","fdf6696282be7793"]}, +{"id":"file:../../../testdata/corpus/orm/sqlx_queries.sql","content_sha":"37997a04635db8085f600542cdc580151ea17a52086e5fd7e91e7aeef30bb872","digests":["fdf6696282be7793","fdf6696282be7793","fdf6696282be7793","fdf6696282be7793","8bd48b782468c54b","fdf6696282be7793","8bd48b782468c54b","fdf6696282be7793","fdf6696282be7793"]}, {"id":"file:../../../testdata/corpus/tpcds/q01_store_returns.sql","content_sha":"c2661cb1fdba83d296c41fede575b407e32dd359727715df725e86be200826f9","digests":["ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73","ab72acca9b482b73"]}, {"id":"file:../../../testdata/corpus/tpcds/q03_brand_sales.sql","content_sha":"b6cef604f0f788bf240959d1be5c80ab0c65c3614f488f35fb72b63f771ae25f","digests":["9db425b22d716847","9db425b22d716847","9db425b22d716847","9db425b22d716847","9db425b22d716847","9db425b22d716847","9db425b22d716847","9db425b22d716847","9db425b22d716847"]}, {"id":"file:../../../testdata/corpus/tpcds/q06_promo_revenue.sql","content_sha":"de601cb204cdf9e9c8d5fda94f11246a7c2c9cc0d6d824dbd1fdc82138a2e5cc","digests":["e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6","e0ebad465ee28da6"]}, @@ -41,7 +41,7 @@ {"id":"file:../../../testdata/corpus/tpch/q22_global_sales_opportunity.sql","content_sha":"a78c1ace0cd897875430ec4669cb66114eabe611908ce81b06ce6b474c359956","digests":["727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2","727036dcb74e45e2"]}, {"id":"file:../../../testdata/mssql/01_top_select.sql","content_sha":"f08028989f8203aac83b17fa61f7a72eb01b2b04d4754f97bffe278dea794cd1","digests":["3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92","3fda0acaa0264a92"]}, {"id":"file:../../../testdata/mssql/02_top_percent.sql","content_sha":"1f359303111a36e7793cebc278713d6e512c301c1a36c5cb8fd081755a42dfff","digests":["e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b","e5910b4e7d30682b"]}, -{"id":"file:../../../testdata/mssql/03_square_brackets.sql","content_sha":"a6a60ba1c428ab46e3eec10e066a62f42a85b2a50c5edb2d3330d79aad28a92b","digests":["51c79669ae2bff84","51c79669ae2bff84","51c79669ae2bff84","51c79669ae2bff84","e1e47999448ec760","51c79669ae2bff84","51c79669ae2bff84","51c79669ae2bff84","51c79669ae2bff84"]}, +{"id":"file:../../../testdata/mssql/03_square_brackets.sql","content_sha":"a6a60ba1c428ab46e3eec10e066a62f42a85b2a50c5edb2d3330d79aad28a92b","digests":["51c79669ae2bff84","51c79669ae2bff84","51c79669ae2bff84","51c79669ae2bff84","e1e47999448ec760","51c79669ae2bff84","e1e47999448ec760","51c79669ae2bff84","51c79669ae2bff84"]}, {"id":"file:../../../testdata/mssql/04_offset_fetch.sql","content_sha":"c37174e25a488cc3f8d843be23354724f8c7cbd7ced8f0dc2fea4ab9d3abe947","digests":["2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c","2f0e85d9527d070c"]}, {"id":"file:../../../testdata/mssql/05_merge_statement.sql","content_sha":"0fda33f87344a778f4928773b8d2151c83612d3eecd4ee950235a765597849a4","digests":["ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495","ed603e0c4a500495"]}, {"id":"file:../../../testdata/mssql/06_cte_basic.sql","content_sha":"c38d6bb3602e1ac2e5a05a52dfa3563ba00a47b76a0f2e4f5161768245d6c40d","digests":["50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1","50e6133c331d76b1"]}, @@ -49,7 +49,7 @@ {"id":"file:../../../testdata/mssql/08_window_row_number.sql","content_sha":"6554f71bd316018606e875a547cf3ebb2393157ae9cff7594340af67fe1c4912","digests":["3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb","3f41e4d08e392cfb"]}, {"id":"file:../../../testdata/mssql/09_window_rank.sql","content_sha":"d22ffe9ec0b6eed98d80ac6bf6bba1978ba03aba1e401e712729daec81e2c095","digests":["9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253","9e7e6f55c841c253"]}, {"id":"file:../../../testdata/mssql/10_window_lag_lead.sql","content_sha":"2b2fdcd1183a211cee025ecbdd030d822ad69a761ab627e6d9531ac020995c12","digests":["1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0","1c4ed46aef06b2a0"]}, -{"id":"file:../../../testdata/mssql/11_pivot.sql","content_sha":"e64b388a40681b7ca9569056667a3731f608a81b480bbd1890ca16936ae1f836","digests":["7d5d17f62b24bb86","7d5d17f62b24bb86","7d5d17f62b24bb86","7d5d17f62b24bb86","e75a249adc82f696","7d5d17f62b24bb86","7d5d17f62b24bb86","7d5d17f62b24bb86","7d5d17f62b24bb86"]}, +{"id":"file:../../../testdata/mssql/11_pivot.sql","content_sha":"e64b388a40681b7ca9569056667a3731f608a81b480bbd1890ca16936ae1f836","digests":["7d5d17f62b24bb86","7d5d17f62b24bb86","7d5d17f62b24bb86","7d5d17f62b24bb86","e75a249adc82f696","7d5d17f62b24bb86","e75a249adc82f696","7d5d17f62b24bb86","7d5d17f62b24bb86"]}, {"id":"file:../../../testdata/mssql/12_unpivot.sql","content_sha":"8479c1cb74aed107ba99160add64132d2e87f6595588a90f01db65101a62ea23","digests":["53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10","53b8404c6808fb10"]}, {"id":"file:../../../testdata/mssql/13_cross_apply.sql","content_sha":"1820b6225dc675ca1e7df250a5cc036e12580fb610adbd81b4258b694c84313a","digests":["4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434","4b04b28c6eba3434"]}, {"id":"file:../../../testdata/mssql/14_outer_apply.sql","content_sha":"79220e8a2d63c18f000854dd600ac574f658e40ca756482ec4afd81ec60ef03c","digests":["4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f","4ca7ddd03d27f38f"]}, @@ -88,7 +88,7 @@ {"id":"file:../../../testdata/mysql/27_replace_into.sql","content_sha":"f691d733ef860b25ac672169a067f9a5c6f04885b593e8ef369116507cf3d96c","digests":["8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97","8d56a33fcf61ec97"]}, {"id":"file:../../../testdata/mysql/28_insert_select.sql","content_sha":"ccf79fec3fbfd2864ed6fd5de33515056511195203576add62619954646ae9ca","digests":["fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b","fc1868da9d21d09b"]}, {"id":"file:../../../testdata/mysql/29_full_text_search.sql","content_sha":"b876f5f4641e1462edc736b140029a83556e5321b0de98f6e92780423ab17e5d","digests":["0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e","0c6d748d0c64c72e"]}, -{"id":"file:../../../testdata/mysql/30_regexp.sql","content_sha":"625e2934d9027b87ae9befcbe84d972462211455643521782edb647f585030f4","digests":["75ff08e1f80067ea","75ff08e1f80067ea","75ff08e1f80067ea","75ff08e1f80067ea","9c84e1ffb5d72fa8","9c84e1ffb5d72fa8","9c84e1ffb5d72fa8","9c84e1ffb5d72fa8","9c84e1ffb5d72fa8"]}, +{"id":"file:../../../testdata/mysql/30_regexp.sql","content_sha":"625e2934d9027b87ae9befcbe84d972462211455643521782edb647f585030f4","digests":["75ff08e1f80067ea","9c84e1ffb5d72fa8","75ff08e1f80067ea","75ff08e1f80067ea","9c84e1ffb5d72fa8","9c84e1ffb5d72fa8","9c84e1ffb5d72fa8","75ff08e1f80067ea","75ff08e1f80067ea"]}, {"id":"file:../../../testdata/oracle/01_rownum.sql","content_sha":"54b2b17b50d71b92805325d4acd2245d7119783cc6befefab24b8ffe5a54b5b8","digests":["bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a","bdaa066ceaee961a"]}, {"id":"file:../../../testdata/oracle/02_dual_table.sql","content_sha":"c6e5fa31d9661a8cb2b425447ff1b19393199f6c10f2b3e77aa8f6cc3d239912","digests":["04a4164be63810f2","04a4164be63810f2","04a4164be63810f2","04a4164be63810f2","04a4164be63810f2","04a4164be63810f2","04a4164be63810f2","04a4164be63810f2","04a4164be63810f2"]}, {"id":"file:../../../testdata/oracle/03_sequence_nextval.sql","content_sha":"9a254a2f0482b049b663470403d296b017e4d17aae54c51042372de9f119bc64","digests":["234f08026c63f431","234f08026c63f431","234f08026c63f431","234f08026c63f431","234f08026c63f431","234f08026c63f431","234f08026c63f431","234f08026c63f431","234f08026c63f431"]}, @@ -107,7 +107,7 @@ {"id":"file:../../../testdata/oracle/16_decode.sql","content_sha":"1e26890fd48c31779505b7a003e47454f951802e0b996c6aa1f98a3bf68ce845","digests":["bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250","bbe7e5ab71996250"]}, {"id":"file:../../../testdata/oracle/17_nvl_nvl2.sql","content_sha":"06dee8764ace42cb91f2991623ee295541716a216946fddcdb9b8fdb2d845ab0","digests":["9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4","9459f8736f093ff4"]}, {"id":"file:../../../testdata/oracle/18_date_functions.sql","content_sha":"77c5b59ca2d3b19b0f62aac902f42763858e6ab749cbae5ebc95686984f0b874","digests":["9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e","9955eefbdda9be9e"]}, -{"id":"file:../../../testdata/oracle/19_regexp_functions.sql","content_sha":"41ad2d9c69564422b012ebbb2370468bb1e33cabb04ff8f34a85f982961ff97a","digests":["143605b1d079850b","143605b1d079850b","143605b1d079850b","143605b1d079850b","19212e490bd5cbb8","19212e490bd5cbb8","19212e490bd5cbb8","19212e490bd5cbb8","19212e490bd5cbb8"]}, +{"id":"file:../../../testdata/oracle/19_regexp_functions.sql","content_sha":"41ad2d9c69564422b012ebbb2370468bb1e33cabb04ff8f34a85f982961ff97a","digests":["19212e490bd5cbb8","19212e490bd5cbb8","b66a6f3f1ef5d8cd","b66a6f3f1ef5d8cd","19212e490bd5cbb8","19212e490bd5cbb8","19212e490bd5cbb8","19212e490bd5cbb8","19212e490bd5cbb8"]}, {"id":"file:../../../testdata/oracle/20_with_clause.sql","content_sha":"6f744ce8c5f3720d4ec5588ff0db713ab583fbc901174f0050a03298e2337b8e","digests":["6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b","6f06ddfcdbaba29b"]}, {"id":"file:../../../testdata/postgresql/01_simple_select.sql","content_sha":"71ff01ad43e53b8d6b3b07dff0a9f87a14997c6b12b2d026efd79b40a4a29be2","digests":["766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0","766637ee7ca838d0"]}, {"id":"file:../../../testdata/postgresql/02_simple_insert.sql","content_sha":"db936f494a0579cea5a52c2ab989501efd23a930172b4181390249ec1d3ecc31","digests":["8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67","8d14aaa53afcbe67"]}, @@ -118,7 +118,7 @@ {"id":"file:../../../testdata/postgresql/07_left_join.sql","content_sha":"8203a5c90ee8b6c5853df23ed6f44caba478bd736e4641d9b9f214e26a61ba0f","digests":["3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86","3ebda366cbfc2e86"]}, {"id":"file:../../../testdata/postgresql/08_aggregation.sql","content_sha":"30451a7c6150b569336ddba5d40207663c2de41ae3fc7158a77cdb7fb8af71cd","digests":["ff103360792c322e","ff103360792c322e","ff103360792c322e","ff103360792c322e","ff103360792c322e","ff103360792c322e","ff103360792c322e","ff103360792c322e","ff103360792c322e"]}, {"id":"file:../../../testdata/postgresql/09_subquery.sql","content_sha":"daff231d7440e1bc8f5b706cea64be76fac5014911c51995426ddb4eb44372c7","digests":["d83440227721de3a","d83440227721de3a","d83440227721de3a","d83440227721de3a","d83440227721de3a","d83440227721de3a","d83440227721de3a","d83440227721de3a","d83440227721de3a"]}, -{"id":"file:../../../testdata/postgresql/10_array_operators.sql","content_sha":"c963aa98db50d3d14201d7305b9cf6bf97530ed4aefcc9744eed493be7f8fd34","digests":["ce4f1896e831aab5","ce4f1896e831aab5","ce4f1896e831aab5","ce4f1896e831aab5","bb59fb36812747c2","ce4f1896e831aab5","ce4f1896e831aab5","ce4f1896e831aab5","ce4f1896e831aab5"]}, +{"id":"file:../../../testdata/postgresql/10_array_operators.sql","content_sha":"c963aa98db50d3d14201d7305b9cf6bf97530ed4aefcc9744eed493be7f8fd34","digests":["ce4f1896e831aab5","ce4f1896e831aab5","ce4f1896e831aab5","ce4f1896e831aab5","bb59fb36812747c2","ce4f1896e831aab5","bb59fb36812747c2","ce4f1896e831aab5","ce4f1896e831aab5"]}, {"id":"file:../../../testdata/postgresql/11_jsonb_operators.sql","content_sha":"983ccc8a9b82f2e4dbbbe476b408e6b7862e99aad1ed42fb20f5d2af20663197","digests":["0fde22b07b4e8e2e","0fde22b07b4e8e2e","0ada83df780d8ee0","0ada83df780d8ee0","0fde22b07b4e8e2e","0fde22b07b4e8e2e","0ada83df780d8ee0","0fde22b07b4e8e2e","0fde22b07b4e8e2e"]}, {"id":"file:../../../testdata/postgresql/12_case_expression.sql","content_sha":"16620a2ec34663392bc5d527d0bacc148ae7ead5c606bc83d12b2c03dfb0759e","digests":["618f66c330679364","618f66c330679364","618f66c330679364","618f66c330679364","618f66c330679364","618f66c330679364","618f66c330679364","618f66c330679364","618f66c330679364"]}, {"id":"file:../../../testdata/postgresql/13_multiple_joins.sql","content_sha":"144f7d728550d93663014d34dca1399b0e86fed0cf80370aaf8895292ef7e8c2","digests":["f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a","f002475e38d1d24a"]}, @@ -156,7 +156,7 @@ {"id":"file:../../../testdata/real_world/15_customer_segmentation_rfm.sql","content_sha":"ae2c8398e74dae9f39b12d3e1320fe9357b989179ee2ec33228ab7d273795319","digests":["e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e","e37bd17668ae0e9e"]}, {"id":"file:../../../testdata/real_world/16_log_analysis_error_tracking.sql","content_sha":"4983ad45e0036bd8fa9b99816a8ea1a25837892c6adecf2a6e14b4754e7da457","digests":["15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d","15abd73a6ea9ed3d"]}, {"id":"file:../../../testdata/real_world/17_seat_availability_booking.sql","content_sha":"2710f594b78d6a49cf4528650bae3aa9f3a4282820fe0ec1a92f625c44016327","digests":["6319971c058d3464","6319971c058d3464","6319971c058d3464","6319971c058d3464","6319971c058d3464","6319971c058d3464","6319971c058d3464","6319971c058d3464","6319971c058d3464"]}, -{"id":"file:../../../testdata/real_world/18_network_graph_analysis.sql","content_sha":"0c04b47a077693e7eb8a3816eaac40f01f1b374f7df37d959bd8e04f6fb620b3","digests":["0fd53ca523a9956a","0fd53ca523a9956a","0fd53ca523a9956a","0fd53ca523a9956a","e9435ff7b56c2311","0fd53ca523a9956a","0fd53ca523a9956a","0fd53ca523a9956a","0fd53ca523a9956a"]}, +{"id":"file:../../../testdata/real_world/18_network_graph_analysis.sql","content_sha":"0c04b47a077693e7eb8a3816eaac40f01f1b374f7df37d959bd8e04f6fb620b3","digests":["0fd53ca523a9956a","0fd53ca523a9956a","0fd53ca523a9956a","0fd53ca523a9956a","e9435ff7b56c2311","0fd53ca523a9956a","e9435ff7b56c2311","0fd53ca523a9956a","0fd53ca523a9956a"]}, {"id":"file:../../../testdata/real_world/19_geo_location_radius_search.sql","content_sha":"2f240dd4a535ab29d48fcb7d801d5bb244168050d423d8c3634eccef25b2fc16","digests":["1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc","1ed9b6a06a1ea0bc"]}, {"id":"file:../../../testdata/real_world/20_data_quality_audit.sql","content_sha":"771158f5660d671bebe07e8fea9482f4eac8054f939bd46b81256245e4e728a9","digests":["097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee","097d5a218485b5ee"]}, {"id":"file:../../../testdata/real_world/21_session_analytics_user_journey.sql","content_sha":"2c684f0a1bd2ca236bf6589d3e2c2b97461f637b673402221a84a18d476e622f","digests":["ba19080e7a368465","ba19080e7a368465","ba19080e7a368465","ba19080e7a368465","ba19080e7a368465","ba19080e7a368465","ba19080e7a368465","ba19080e7a368465","ba19080e7a368465"]}, @@ -164,6 +164,85 @@ {"id":"file:../../../testdata/real_world/23_recommendation_collaborative_filtering.sql","content_sha":"1d6c1ffa15bfad62fd4decf68400faa6c68d0fb0d9b65dfe5bd5c2aa0a36db3e","digests":["c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988","c562b01c7e95d988"]}, {"id":"file:../../../testdata/real_world/24_payment_reconciliation_stripe.sql","content_sha":"556ba6c21aa2cf55133fc455b8c24b48a45c774003bd831d0cc846785cf09238","digests":["2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe","2fe1110546209fbe"]}, {"id":"file:../../../testdata/real_world/25_multi_currency_revenue.sql","content_sha":"c19b81b7f6bbf85743448a91d36a5c54c468ff0146ddd4df3ef3a18deae8fe65","digests":["df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd","df2e30851c92e1fd"]}, +{"id":"file:../../../testdata/realworld/clickhouse/01_app_crud.sql","content_sha":"2a1fe0c2e4407382db2fb3e47de3b9605f09a7eb4d7cb8e70144ad7e6f9689fd","digests":["330988421deab147","330988421deab147","330988421deab147","330988421deab147","330988421deab147","30ca3ce3eb1c0509","30ca3ce3eb1c0509","330988421deab147","838255cf0646060d"]}, +{"id":"file:../../../testdata/realworld/clickhouse/02_reporting.sql","content_sha":"8cad1d31662226c6fdf53f77ebdb341058b958bcb01571b6b15ff9ef1809e837","digests":["5425f087ba8cb1ea","5425f087ba8cb1ea","5425f087ba8cb1ea","5425f087ba8cb1ea","fe7871c7af1751bd","5425f087ba8cb1ea","fe7871c7af1751bd","5425f087ba8cb1ea","5425f087ba8cb1ea"]}, +{"id":"file:../../../testdata/realworld/clickhouse/03_ddl_migrations.sql","content_sha":"836624785c208ef5aa5f875d37713b0deebe2911a7efc4f8345203fe9d91f41a","digests":["13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c","13027949ea8ebf9c"]}, +{"id":"file:../../../testdata/realworld/clickhouse/04_window_analytics.sql","content_sha":"d474f60cca02da449497a3da6df28d0ea086606995f9143b3d07ef4d994c4c4e","digests":["3e2602038d881249","3e2602038d881249","3e2602038d881249","3e2602038d881249","3e2602038d881249","3e2602038d881249","3e2602038d881249","3e2602038d881249","3e2602038d881249"]}, +{"id":"file:../../../testdata/realworld/clickhouse/05_dialect_specific.sql","content_sha":"d29108cd2ff830a404ef9169503eeef0845dc28e575299792bd18e9f2acc0877","digests":["e934ced782eef1cd","61b1c4a4f6a823a3","e934ced782eef1cd","e934ced782eef1cd","e2c4a4318c979d09","61b1c4a4f6a823a3","e2c4a4318c979d09","e934ced782eef1cd","e934ced782eef1cd"]}, +{"id":"file:../../../testdata/realworld/clickhouse/06_materialized_views.sql","content_sha":"4576af949fb646c1ad0ee370dfab609a53c2e52d7a3de03ad59bc2bcb49e9e76","digests":["157ad2b943d23967","157ad2b943d23967","157ad2b943d23967","157ad2b943d23967","157ad2b943d23967","59da5c8fa97e1bee","59da5c8fa97e1bee","157ad2b943d23967","cc7c9e448244e74c"]}, +{"id":"file:../../../testdata/realworld/clickhouse/07_observability.sql","content_sha":"759823144b54f82a179580e98853336db9b96308b5c2196dea8b65e646d95dde","digests":["40adc73ebb2ee512","40adc73ebb2ee512","40adc73ebb2ee512","40adc73ebb2ee512","7b2f5079065fc5f2","40adc73ebb2ee512","7b2f5079065fc5f2","40adc73ebb2ee512","40adc73ebb2ee512"]}, +{"id":"file:../../../testdata/realworld/clickhouse/08_etl_pipelines.sql","content_sha":"845894c007110e2b3845af6107ca23a2b620b85797736a3c1770071c9cf89fbb","digests":["f382619f77e7aa2a","f382619f77e7aa2a","7510dc745768e6ba","7510dc745768e6ba","f382619f77e7aa2a","f382619f77e7aa2a","7510dc745768e6ba","f382619f77e7aa2a","f382619f77e7aa2a"]}, +{"id":"file:../../../testdata/realworld/clickhouse/09_deep_nesting.sql","content_sha":"668a1fdc5fc5dd50191737b136fb74a534950393eb149ebd9a1c4ed4bd2713c2","digests":["2d5752f8a0edf505","2d5752f8a0edf505","2d5752f8a0edf505","2d5752f8a0edf505","330de1add29f35c0","2d5752f8a0edf505","330de1add29f35c0","2d5752f8a0edf505","2d5752f8a0edf505"]}, +{"id":"file:../../../testdata/realworld/clickhouse/10_misc_real.sql","content_sha":"8477ac2264c720848ba3bf1e658b08d87e8367ce7dd0ff1ceb84b533a488621a","digests":["30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff","30fa9639f48306ff"]}, +{"id":"file:../../../testdata/realworld/mariadb/01_app_crud.sql","content_sha":"867da0a777bda5c00ac2f60976cf3c2c4acc5d0cf2f77d362655ba97ed06cfea","digests":["70c9e7630df085e8","2de91cbdaaf301a1","8fab7d26328a81ba","8fab7d26328a81ba","2de91cbdaaf301a1","2de91cbdaaf301a1","18af2d216722e480","70c9e7630df085e8","70c9e7630df085e8"]}, +{"id":"file:../../../testdata/realworld/mariadb/02_reporting.sql","content_sha":"335368bb3defc980ff8ec62fa93e520496d6be924b0afbbd8aeeccc4dd357ffd","digests":["d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5","d78baa25ec4c48a5"]}, +{"id":"file:../../../testdata/realworld/mariadb/03_ddl_migrations.sql","content_sha":"f43c04aa4cc557421e94ceefd58007aac7119317380547ba8f4274f8de245df4","digests":["38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65","38f86bf709da5a65"]}, +{"id":"file:../../../testdata/realworld/mariadb/04_window_analytics.sql","content_sha":"649c9c0c815b769b13a92bb9c1f54b76c4acec13263b709d031866d3eaad49d4","digests":["83eee75976a883b1","83eee75976a883b1","83eee75976a883b1","83eee75976a883b1","83eee75976a883b1","83eee75976a883b1","83eee75976a883b1","83eee75976a883b1","83eee75976a883b1"]}, +{"id":"file:../../../testdata/realworld/mariadb/05_mariadb_specific.sql","content_sha":"9d339f6e34905e73b8c04529ca0557902805f03aa0e197a67f24904c64be5984","digests":["bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e","bfae6bb1ceeb520e"]}, +{"id":"file:../../../testdata/realworld/mariadb/06_etl_admin.sql","content_sha":"45bb18b29b162914fb92fb16ae8591fe3c7b3e66110ee01b06edf48a5fbcb8a7","digests":["09bcaaea47d584b0","8de4c2030ae204f2","51f6c7eb79b64c6f","51f6c7eb79b64c6f","4fd4406f09e8d212","d604c0da860b473a","7b19329ea06f4cc7","3997ce1fb7b4228b","09bcaaea47d584b0"]}, +{"id":"file:../../../testdata/realworld/mariadb/07_stored_programs.sql","content_sha":"4bd55ee96a9f638bd5b865574edd469e1009558a88e99756ac27f7d0544f0b72","digests":["560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16","560f03a0c42fec16"]}, +{"id":"file:../../../testdata/realworld/mariadb/08_complex_queries.sql","content_sha":"b4c02a64e5aa840fcf9f3fa023063dc5ea2af863f1fd82d6b361de1818b82aa4","digests":["d3221712f38e17df","d3221712f38e17df","d3221712f38e17df","d3221712f38e17df","d3221712f38e17df","d3221712f38e17df","d3221712f38e17df","d3221712f38e17df","d3221712f38e17df"]}, +{"id":"file:../../../testdata/realworld/mariadb/09_mysql_compat_misc.sql","content_sha":"bd4d1d3243eaf73f4de4f666b837c108a3cad41a6cb1f9288bf4b4531258af92","digests":["f8ad60194acffa5e","f8ad60194acffa5e","f8ad60194acffa5e","f8ad60194acffa5e","6c3f669ebee2b5da","f8ad60194acffa5e","f8ad60194acffa5e","f8ad60194acffa5e","f8ad60194acffa5e"]}, +{"id":"file:../../../testdata/realworld/mysql/01_app_crud.sql","content_sha":"ed1c0076ab31d3421438a82de24cc1298629d3cb7c12f2f19d068120efaf0b53","digests":["f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e","f17431deb5f46c4e"]}, +{"id":"file:../../../testdata/realworld/mysql/02_reporting.sql","content_sha":"ac9e76e526bc6a35eecedb654b86247e28b984e73a0a45a7c03b98bde1df5c10","digests":["95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8","95c69bc427de2ce8"]}, +{"id":"file:../../../testdata/realworld/mysql/03_ddl_migrations.sql","content_sha":"101c051e96809f4c1b9db79eee0f87d08e29cee1f6a62716710ab5c1a452ceda","digests":["bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f","bc8c732faac3033f"]}, +{"id":"file:../../../testdata/realworld/mysql/04_window_analytics.sql","content_sha":"001685676e0e5537efd84780a3ec91b4107076d70eaba0daface83ffa217caf3","digests":["4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0","4a97bff28ef4d3d0"]}, +{"id":"file:../../../testdata/realworld/mysql/05_dialect_specific.sql","content_sha":"8afabe92c27f8fe20c06ab0c690e6c9f409e9c8b613b1dfac36b5f4fcac461a5","digests":["393d6eb108409353","89bea5a41eb9627e","9f0fa076cc0bd14a","9f0fa076cc0bd14a","ba84d18421fa60e6","0caacdd7690bb185","fe978d0723092216","ebbaa268a78bf505","393d6eb108409353"]}, +{"id":"file:../../../testdata/realworld/mysql/06_wordpress.sql","content_sha":"32bf52ba0cc65fb34792b92175847e2bbe2d2197b94663c88d20351717812dc4","digests":["fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c","fcb03c1a3bf4352c"]}, +{"id":"file:../../../testdata/realworld/mysql/07_laravel_magento.sql","content_sha":"5bd510898078e0aa892612415fb43b05c117c266f654cd0a7e4971c7f498825d","digests":["4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01","4ba9fff3ae28ab01"]}, +{"id":"file:../../../testdata/realworld/mysql/08_etl_admin.sql","content_sha":"3441831d9d827d17f7e15e7b96797b289ea7d81c44edff7af4423ba99256bf6d","digests":["f74700c1aa51c486","f74700c1aa51c486","c96207824ec77cf7","c96207824ec77cf7","f74700c1aa51c486","f74700c1aa51c486","c96207824ec77cf7","f74700c1aa51c486","f74700c1aa51c486"]}, +{"id":"file:../../../testdata/realworld/mysql/09_deep_nesting.sql","content_sha":"0a62a81f755d6bd39af8f06721a178745dd0df3cf026327c10f01dcc78246bc2","digests":["944f2c5b45432302","3e34c660cecb7fb4","944f2c5b45432302","944f2c5b45432302","3e34c660cecb7fb4","3e34c660cecb7fb4","3e34c660cecb7fb4","944f2c5b45432302","944f2c5b45432302"]}, +{"id":"file:../../../testdata/realworld/mysql/10_misc_functions.sql","content_sha":"27394b214e3a74eefc8774e3c735da3cd6a16904137e8fe7bab01dc38a5c1834","digests":["c9d842d74b342648","c9d842d74b342648","c9d842d74b342648","c9d842d74b342648","c9d842d74b342648","c9d842d74b342648","c9d842d74b342648","c9d842d74b342648","c9d842d74b342648"]}, +{"id":"file:../../../testdata/realworld/mysql/11_stored_programs.sql","content_sha":"77357658b80f61ca349180bd3c1dc84086e39fd8bb470d721035d33566dc744e","digests":["1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704","1592ce3d21c7e704"]}, +{"id":"file:../../../testdata/realworld/mysql/12_orm_misc.sql","content_sha":"7e8c1480f99be98b981a566378644e94cd4f8af038cd1fae583bc69a662ea285","digests":["6781defdfb1f766a","6781defdfb1f766a","2e994349dee8d460","2e994349dee8d460","85462df4a3cd3182","6781defdfb1f766a","2e994349dee8d460","6781defdfb1f766a","6781defdfb1f766a"]}, +{"id":"file:../../../testdata/realworld/oracle/01_app_crud.sql","content_sha":"2e0a7a1f65a3a9ad6467def83e48fd0b01d696351a3cd0b17f50ba0807616620","digests":["1bea3edddf148d85","ff6a3f6ab5ca8c1d","1bea3edddf148d85","1bea3edddf148d85","ff6a3f6ab5ca8c1d","c3377849abeca66f","c3377849abeca66f","1bea3edddf148d85","1bea3edddf148d85"]}, +{"id":"file:../../../testdata/realworld/oracle/02_reporting.sql","content_sha":"1825c28d7800d7563d1921a8da8d412b84a45f4fde53802dc7ba97b50c4cc9b2","digests":["4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3","4349decf086fd1f3"]}, +{"id":"file:../../../testdata/realworld/oracle/03_ddl_migrations.sql","content_sha":"bd403405db851e52d53294a32ffc00dd436acd8320b4a69db3f1b312d42b43d2","digests":["9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13","9521d30613bb9d13"]}, +{"id":"file:../../../testdata/realworld/oracle/04_window_analytics.sql","content_sha":"0c60d4b6770c6c01b1178ba725b981f7e838866f7c2778b15f76c2a7478b4905","digests":["c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c","c692496ddd6a0b9c"]}, +{"id":"file:../../../testdata/realworld/oracle/05_dialect_specific.sql","content_sha":"4a13ef1fec1bb3a19f18cdde597718f8b313d1f9f52533b3216b6af71209715f","digests":["b91b4872757909ea","b91b4872757909ea","b91b4872757909ea","b91b4872757909ea","b91b4872757909ea","2551e98fbb4e510f","b91b4872757909ea","b91b4872757909ea","b91b4872757909ea"]}, +{"id":"file:../../../testdata/realworld/oracle/06_etl_merge.sql","content_sha":"9f02789dba1a1616892f8a6662ac65d4cccad24f172bb20ae077124134efe625","digests":["76881d2082230a10","76881d2082230a10","76881d2082230a10","76881d2082230a10","76881d2082230a10","864258bc0495dd91","864258bc0495dd91","76881d2082230a10","76881d2082230a10"]}, +{"id":"file:../../../testdata/realworld/oracle/07_admin_ops.sql","content_sha":"cfa6552d00c76197967627e49d757a5c9f0c1e0fd72303511d39b699068e4b4e","digests":["89a79b3957d295bd","89a79b3957d295bd","89a79b3957d295bd","89a79b3957d295bd","89a79b3957d295bd","305f1bd459ac97e5","e49f2bd1ff79408d","89a79b3957d295bd","89a79b3957d295bd"]}, +{"id":"file:../../../testdata/realworld/oracle/08_complex_nested.sql","content_sha":"54d48dc0a548a4d7b8ba25b9db956b0555249206e73ede4dffa715c11438d2a9","digests":["620dad4044aa231d","620dad4044aa231d","620dad4044aa231d","620dad4044aa231d","620dad4044aa231d","0d625cc3f03a2c21","0d625cc3f03a2c21","620dad4044aa231d","620dad4044aa231d"]}, +{"id":"file:../../../testdata/realworld/postgresql/01_app_crud.sql","content_sha":"64aaf258c172e518df11f6a4812d61c891582f31874c6f251665f7e1cce549ab","digests":["739093d0608d9581","739093d0608d9581","739093d0608d9581","739093d0608d9581","61199b91a8cede2e","739093d0608d9581","61199b91a8cede2e","739093d0608d9581","739093d0608d9581"]}, +{"id":"file:../../../testdata/realworld/postgresql/02_reporting.sql","content_sha":"b63e1bd7d7dd2b24371336fcb9b06567aeb7e16a8dc0465b563309367ef1b3dd","digests":["7e33fffab141ac65","7e33fffab141ac65","7e33fffab141ac65","7e33fffab141ac65","67a0d77e5cbdb976","7e33fffab141ac65","67a0d77e5cbdb976","7e33fffab141ac65","7e33fffab141ac65"]}, +{"id":"file:../../../testdata/realworld/postgresql/03_ddl_migrations.sql","content_sha":"68df9cf369beaeac989e1487f0d96ea29901025f8ad7db14b4552b7b0884e6df","digests":["d798269ed652a1b2","d798269ed652a1b2","d798269ed652a1b2","d798269ed652a1b2","8a2326c1bcb23056","d798269ed652a1b2","8a2326c1bcb23056","d798269ed652a1b2","d798269ed652a1b2"]}, +{"id":"file:../../../testdata/realworld/postgresql/04_window_analytics.sql","content_sha":"ea935dce71a754bb4f8568da4975531d0d236368b6ff7533093704036e023d16","digests":["2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20","2f709e8f7fd5aa20"]}, +{"id":"file:../../../testdata/realworld/postgresql/05_dialect_specific.sql","content_sha":"dc4bcad1939455df60b8771058abcd80577f87c8b3eef4f2b50fd6bf5ed16bd9","digests":["a229b464ed52a341","05278c33ffe4d99e","5ff3561c8db313df","5ff3561c8db313df","8ec2aaa35ee4d7ad","694cf200f3eec85c","914143ed5bb3098b","a229b464ed52a341","a229b464ed52a341"]}, +{"id":"file:../../../testdata/realworld/postgresql/06_cte_dml_etl.sql","content_sha":"7b610fd3a9d525c09358979951af2e73fe761e48c611dade75d618a92fd33298","digests":["3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7","3c7723ffb97698a7"]}, +{"id":"file:../../../testdata/realworld/postgresql/07_admin_ops.sql","content_sha":"5e16fc22e7c6ea0bf40dee351c3b69321df03746823090af66ba648c6685a8d9","digests":["2e62146a0f448332","2e62146a0f448332","2e62146a0f448332","2e62146a0f448332","2e62146a0f448332","2e62146a0f448332","2e62146a0f448332","2e62146a0f448332","2e62146a0f448332"]}, +{"id":"file:../../../testdata/realworld/postgresql/08_supabase_rls_misc.sql","content_sha":"0a1f2a6d944716c56f67278e4f7095c28dff81dfc28f11053709e643ad489545","digests":["e1595f417105ae28","8e280873bfafcc89","e1595f417105ae28","e1595f417105ae28","dffc3f29306bbd8e","8e280873bfafcc89","dffc3f29306bbd8e","e1595f417105ae28","e1595f417105ae28"]}, +{"id":"file:../../../testdata/realworld/snowflake/01_app_crud.sql","content_sha":"f83439700b07a91c80935b76df127e7b06b3565874b17b5a3d1d9d6a917cf06e","digests":["23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1","23423e5ef426aff1"]}, +{"id":"file:../../../testdata/realworld/snowflake/02_reporting.sql","content_sha":"02a05250ee03c397155b1e216806482cdb8dbc5748e0fee7a327a2fd68e2a4e2","digests":["951ea8aab495f757","951ea8aab495f757","951ea8aab495f757","951ea8aab495f757","951ea8aab495f757","951ea8aab495f757","951ea8aab495f757","951ea8aab495f757","951ea8aab495f757"]}, +{"id":"file:../../../testdata/realworld/snowflake/03_ddl_migrations.sql","content_sha":"305b9d9a4e604f32ffda42d2096cb2d449db0cee505dbac0245244fa09a146ef","digests":["bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced","bcc83313c6184ced"]}, +{"id":"file:../../../testdata/realworld/snowflake/04_window_analytics.sql","content_sha":"104886690baa6674d6da99d93bb18b0b89cd2d547cb52ede9924e7613265dd96","digests":["424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2","424dc5edd3bafad2"]}, +{"id":"file:../../../testdata/realworld/snowflake/05_dialect_specific.sql","content_sha":"8598123bea80cbf7c3a3087af08e99a10f2efdfbe265364d9b92c4d3cd63989d","digests":["3ca570d037618b82","f97a5eb562fd48e7","3ca570d037618b82","3ca570d037618b82","cedc556c4e4a1d03","ac0689ec5680a2bc","12ebba16c3bdcf04","3ca570d037618b82","3ca570d037618b82"]}, +{"id":"file:../../../testdata/realworld/snowflake/06_etl_merge.sql","content_sha":"e3b8e8120cd4ba1df44bd156e31bf01b3e52d221e1f0193747ca4907646a8b77","digests":["9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b","9b5151605ffaf53b"]}, +{"id":"file:../../../testdata/realworld/snowflake/07_admin.sql","content_sha":"a3abce75f3ce967c75097747eceb23d7c51119a1a6090db42fb6ef66b5fc8f3a","digests":["040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925","040b5ca62afc6925"]}, +{"id":"file:../../../testdata/realworld/snowflake/08_pivot_match.sql","content_sha":"2f0f8eb81e4fb0fd9533707a807c3ed95df055e5ff76207ccd6fb3194466581e","digests":["91e1afef18f7082d","91e1afef18f7082d","91e1afef18f7082d","91e1afef18f7082d","c27d149ec24f7bac","55692f0a024786fd","d26c4cf9b47a6bb4","91e1afef18f7082d","91e1afef18f7082d"]}, +{"id":"file:../../../testdata/realworld/snowflake/09_dbt_models.sql","content_sha":"53b34f403895aad08cb17e0ed9d1286d89571ac9375dc2fd20858268375b2240","digests":["942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23","942950bc6bea0c23"]}, +{"id":"file:../../../testdata/realworld/snowflake/10_complex_nested.sql","content_sha":"39720eab45e06008346d7f7e0d2303717d251a3d982b5bd2b23e878816ef4def","digests":["d1a793bcf010a9f0","080c17ead95391f1","d1a793bcf010a9f0","d1a793bcf010a9f0","080c17ead95391f1","5b18f2812398c965","5b18f2812398c965","d1a793bcf010a9f0","d1a793bcf010a9f0"]}, +{"id":"file:../../../testdata/realworld/sqlite/01_app_crud.sql","content_sha":"4538a207e9b6aa3a22d12a30a47632b9f7ff792d99e0e326b41af51f6cfb813b","digests":["06f414735857fbc1","810681693e246042","06f414735857fbc1","06f414735857fbc1","810681693e246042","03389af7f2650280","1f56d96f627377e1","06f414735857fbc1","06f414735857fbc1"]}, +{"id":"file:../../../testdata/realworld/sqlite/02_upsert_replace.sql","content_sha":"9343ed4445a98f975c6bc2243b0776cb8639d191250ba1884b9d36b22b1294f4","digests":["3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda","3d8c2fb9b0031bda"]}, +{"id":"file:../../../testdata/realworld/sqlite/03_ddl_migrations.sql","content_sha":"3daa7a56d4540decc2a4a03690f7c1571e0df28ca8d759f33540eda657b59e3a","digests":["2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92","2adfadff5b0f7e92"]}, +{"id":"file:../../../testdata/realworld/sqlite/04_window_analytics.sql","content_sha":"40e2eb8a8fc5fc572710e45635b3474cfb44dca7048fdadfdaf8e2bcc0cea566","digests":["cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94","cc535356f3c16a94"]}, +{"id":"file:../../../testdata/realworld/sqlite/05_dialect_specific.sql","content_sha":"a3fe34c7eca4f45a756983635337849afd7df4645e4ef8ea3537064a020662da","digests":["d5bc0089e3d053f1","1c7699a33304fab3","1c7699a33304fab3","1c7699a33304fab3","fa29243dde93d2ef","d5bc0089e3d053f1","1bd221ed900969f4","1c7699a33304fab3","d5bc0089e3d053f1"]}, +{"id":"file:../../../testdata/realworld/sqlite/06_json.sql","content_sha":"6397be4ccd07641aa3e2b6d766c2755d43fa739a7d4fb9f20a93ccf92db80022","digests":["a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad","a8349aa00742a3ad"]}, +{"id":"file:../../../testdata/realworld/sqlite/07_ctes_recursive.sql","content_sha":"940ba0f6e562ea55dae6ea075b960f25d3140c8ca4e1d0ae56525566f09b97e3","digests":["a055416c40a67d84","a055416c40a67d84","a055416c40a67d84","a055416c40a67d84","a055416c40a67d84","a055416c40a67d84","a055416c40a67d84","a055416c40a67d84","a055416c40a67d84"]}, +{"id":"file:../../../testdata/realworld/sqlite/08_reporting.sql","content_sha":"0aa5b86d937281c20e218d49a07d4bf187cbdb62a2138a9f996bb1b89f609099","digests":["57a22c2e27fd96cd","57a22c2e27fd96cd","18c9f5b1c10bc57d","18c9f5b1c10bc57d","57a22c2e27fd96cd","5a8800d91c10bf71","1869874f29658b3d","57a22c2e27fd96cd","57a22c2e27fd96cd"]}, +{"id":"file:../../../testdata/realworld/sqlite/09_etl_admin.sql","content_sha":"e829dc76f518dd14fceb866d5fb6760f79c18ee4d29bbf70c31cad9d4b4fd9cb","digests":["5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f","5f54ccc0bcb8413f"]}, +{"id":"file:../../../testdata/realworld/sqlite/10_mobile_offline_sync.sql","content_sha":"f8036dde9bddb923aae95293a27727c0f86377b91986d07c72b0a31edb6ad1bb","digests":["9c8de3a693c96d92","9c8de3a693c96d92","dfdaf44d34d8d53d","dfdaf44d34d8d53d","9c8de3a693c96d92","9c8de3a693c96d92","dfdaf44d34d8d53d","9c8de3a693c96d92","9c8de3a693c96d92"]}, +{"id":"file:../../../testdata/realworld/sqlite/11_deep_nesting.sql","content_sha":"3b0a31938eec652e477e26c2f52bf698cb79b767b34e3620e166347100eace40","digests":["1e84935fc0109521","f80b32ad3ce71f66","1e84935fc0109521","1e84935fc0109521","cbb5bd057cb304d2","cbb5bd057cb304d2","f80b32ad3ce71f66","1e84935fc0109521","1e84935fc0109521"]}, +{"id":"file:../../../testdata/realworld/sqlite/12_orm_generated.sql","content_sha":"23d6f0c652fbe17d6a32946507fa719f280ebb1d9b37a46be5ccf521b4ef926c","digests":["467ce704b17c93de","467ce704b17c93de","eb4af7c07f441baf","eb4af7c07f441baf","467ce704b17c93de","467ce704b17c93de","cf7c8a8799ff48a8","467ce704b17c93de","467ce704b17c93de"]}, +{"id":"file:../../../testdata/realworld/sqlserver/01_app_crud.sql","content_sha":"6bf8b80cc27c4c056899fc4f8eb114e4ddeda1cc215caaba8841329d8f0287c2","digests":["1b0830a2c377ba23","dbefd4792e364c72","dbefd4792e364c72","dbefd4792e364c72","c6b6129607bd27ed","dbefd4792e364c72","0ab0e2ddcfe1802c","1b0830a2c377ba23","1b0830a2c377ba23"]}, +{"id":"file:../../../testdata/realworld/sqlserver/02_reporting.sql","content_sha":"3fc6cf905f60c794c88d8c87018573b0153ec1b90d0d29f31c8d06a843e4e184","digests":["f914a6746e6d0c1c","491731561837d050","491731561837d050","491731561837d050","98de5b4eb253bde9","491731561837d050","2c0e712746a90277","f914a6746e6d0c1c","f914a6746e6d0c1c"]}, +{"id":"file:../../../testdata/realworld/sqlserver/03_ddl_migrations.sql","content_sha":"d737ba726e55b8d1a33c8febcdb34e4a8b36cfa35c2fe88546e7425f9e2ad3af","digests":["095a8df8bdcb4faf","85c89319b72f330d","85c89319b72f330d","85c89319b72f330d","71c6692389c6a08b","85c89319b72f330d","eca6c96d20c82414","095a8df8bdcb4faf","095a8df8bdcb4faf"]}, +{"id":"file:../../../testdata/realworld/sqlserver/04_window_analytics.sql","content_sha":"832d69f6b309d89a53a3c428a06538c0b3c028ac70327feb602dc7523f0cd5b1","digests":["aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a","aba30f97d745ef4a"]}, +{"id":"file:../../../testdata/realworld/sqlserver/05_dialect_specific.sql","content_sha":"70c6b55f9e32d9da32845269df14e400999fdbe9f538c7f150c17ffa3ac9ddbe","digests":["449e9cc841831880","4f92d86f99a60153","4f92d86f99a60153","4f92d86f99a60153","1515338adffe012e","4f92d86f99a60153","c47f99200b2cb673","449e9cc841831880","449e9cc841831880"]}, +{"id":"file:../../../testdata/realworld/sqlserver/06_merge_etl.sql","content_sha":"c2e16ab2cba3246f5155a81c60b10153265c513c61106de8e5b470ab77d5f7f0","digests":["93689a7a2c325499","a093868d5a40e68e","a093868d5a40e68e","a093868d5a40e68e","a093868d5a40e68e","a093868d5a40e68e","93689a7a2c325499","93689a7a2c325499","93689a7a2c325499"]}, +{"id":"file:../../../testdata/realworld/sqlserver/07_ctes_deep.sql","content_sha":"80dca90bed4842a8ee068830a609a3f848327d5984c60148a8935df19119317a","digests":["f16bb0ab5813d3d5","534fc1f821be22ba","f16bb0ab5813d3d5","f16bb0ab5813d3d5","534fc1f821be22ba","534fc1f821be22ba","0480c0a4e937adc6","f16bb0ab5813d3d5","f16bb0ab5813d3d5"]}, +{"id":"file:../../../testdata/realworld/sqlserver/08_procs_batches.sql","content_sha":"00e302af6209c4aefb752356ae04e76ebd727fc6c793dfbd16d00f6085bbe0dc","digests":["a80c628391e2dcc6","4096f2e47797609d","4096f2e47797609d","4096f2e47797609d","32a7da90f64d1742","4096f2e47797609d","a77fbeb4d5b7273f","a80c628391e2dcc6","a80c628391e2dcc6"]}, +{"id":"file:../../../testdata/realworld/sqlserver/09_admin_dmv.sql","content_sha":"a0991caf114d8b06fc9a21577719b6ec48130451a870c6c2c75a1b20917eb395","digests":["37e19fbebd79586e","9190123aa223cf49","9190123aa223cf49","9190123aa223cf49","9190123aa223cf49","9190123aa223cf49","37e19fbebd79586e","37e19fbebd79586e","37e19fbebd79586e"]}, +{"id":"file:../../../testdata/realworld/sqlserver/10_etl_mixed.sql","content_sha":"0fc6f5060b667e1eac30a6166056a2d5a1fb808f6e29458b159e7901cc5d06c2","digests":["b638d43d43871ab1","b8d3f22dd51752a1","47042ac8dcf02739","47042ac8dcf02739","e575339fcb4e5753","b8d3f22dd51752a1","889061627d8c67e3","b638d43d43871ab1","b638d43d43871ab1"]}, {"id":"file:../../../testdata/sql/basic_queries.sql","content_sha":"0948ca69ae63c18e038def59ed32f1bff75c19ecee6b865d9e71dedac7beb553","digests":["a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f","a8a6ac68d76fc41f"]}, {"id":"file:../../../testdata/sql/demo_queries.sql","content_sha":"e1c041c651316dfea12fcca0bd9bf75c72ba421324cd143e812790c11d87f906","digests":["5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62","5b497a821c8caa62"]}, {"id":"file:../../../testdata/sql/performance_tests.sql","content_sha":"47e67f7db63538e5eb0e0d6b313dd50260bef6be4d9a3aefec532a3981dcae1a","digests":["51d3764940af2dda","51d3764940af2dda","51d3764940af2dda","51d3764940af2dda","51d3764940af2dda","51d3764940af2dda","51d3764940af2dda","51d3764940af2dda","51d3764940af2dda"]}, @@ -192,28 +271,28 @@ {"id":"edge-ctx:6","input":"SELECT ''''', z\nFROM t","digests":["2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b","2c50ccae24e34f4b"]}, {"id":"edge:7","input":"''''''","digests":["100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a","100702d3d1197c6a"]}, {"id":"edge-ctx:7","input":"SELECT '''''', z\nFROM t","digests":["052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71","052fbbc178e00e71"]}, -{"id":"edge:8","input":"'a\\nb'","digests":["ae50db685f7e119c","ae50db685f7e119c","ae50db685f7e119c","ae50db685f7e119c","e1f4a2a703342e55","e1f4a2a703342e55","e1f4a2a703342e55","e1f4a2a703342e55","e1f4a2a703342e55"]}, -{"id":"edge-ctx:8","input":"SELECT 'a\\nb', z\nFROM t","digests":["2a4c7a62d81139c2","2a4c7a62d81139c2","2a4c7a62d81139c2","2a4c7a62d81139c2","e32cb9dcfa6153e2","e32cb9dcfa6153e2","e32cb9dcfa6153e2","e32cb9dcfa6153e2","e32cb9dcfa6153e2"]}, -{"id":"edge:9","input":"'a\\\\'","digests":["b9de1d2bc1bd6964","b9de1d2bc1bd6964","b9de1d2bc1bd6964","b9de1d2bc1bd6964","b778e1deae10b188","b778e1deae10b188","b778e1deae10b188","b778e1deae10b188","b778e1deae10b188"]}, -{"id":"edge-ctx:9","input":"SELECT 'a\\\\', z\nFROM t","digests":["660ac81b1ae7a036","660ac81b1ae7a036","660ac81b1ae7a036","660ac81b1ae7a036","4b342ade005758ed","4b342ade005758ed","4b342ade005758ed","4b342ade005758ed","4b342ade005758ed"]}, -{"id":"edge:10","input":"'a\\\\' 'b'","digests":["b2a577fc9b0cbb68","b2a577fc9b0cbb68","b2a577fc9b0cbb68","b2a577fc9b0cbb68","0bd18078b90ebb12","0bd18078b90ebb12","0bd18078b90ebb12","0bd18078b90ebb12","0bd18078b90ebb12"]}, -{"id":"edge-ctx:10","input":"SELECT 'a\\\\' 'b', z\nFROM t","digests":["624e1adbafc9aa1e","624e1adbafc9aa1e","624e1adbafc9aa1e","624e1adbafc9aa1e","78845813ebb6fecd","78845813ebb6fecd","78845813ebb6fecd","78845813ebb6fecd","78845813ebb6fecd"]}, -{"id":"edge:11","input":"'\\\\'","digests":["8d07813b87bc81c8","8d07813b87bc81c8","8d07813b87bc81c8","8d07813b87bc81c8","43c8d0f4c539bb89","43c8d0f4c539bb89","43c8d0f4c539bb89","43c8d0f4c539bb89","43c8d0f4c539bb89"]}, -{"id":"edge-ctx:11","input":"SELECT '\\\\', z\nFROM t","digests":["841e517b63521d3c","841e517b63521d3c","841e517b63521d3c","841e517b63521d3c","a377428bc4dbf5f1","a377428bc4dbf5f1","a377428bc4dbf5f1","a377428bc4dbf5f1","a377428bc4dbf5f1"]}, -{"id":"edge:12","input":"'a\\q'","digests":["c945c0b9c5080d53","c945c0b9c5080d53","c945c0b9c5080d53","c945c0b9c5080d53","203e5c6c9ad0dd20","203e5c6c9ad0dd20","203e5c6c9ad0dd20","203e5c6c9ad0dd20","203e5c6c9ad0dd20"]}, -{"id":"edge-ctx:12","input":"SELECT 'a\\q', z\nFROM t","digests":["59e026e6a3020148","59e026e6a3020148","59e026e6a3020148","59e026e6a3020148","52df02d6bb8c6abe","52df02d6bb8c6abe","52df02d6bb8c6abe","52df02d6bb8c6abe","52df02d6bb8c6abe"]}, -{"id":"edge:13","input":"'a\\'b'","digests":["1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","a7b9dccff45ade91","a7b9dccff45ade91","a7b9dccff45ade91","a7b9dccff45ade91","a7b9dccff45ade91"]}, -{"id":"edge-ctx:13","input":"SELECT 'a\\'b', z\nFROM t","digests":["2b811e8c2624f45e","2b811e8c2624f45e","2b811e8c2624f45e","2b811e8c2624f45e","86ac23f841263192","86ac23f841263192","86ac23f841263192","86ac23f841263192","86ac23f841263192"]}, -{"id":"edge:14","input":"'a\\\"b'","digests":["ef01453ee204bb63","ef01453ee204bb63","ef01453ee204bb63","ef01453ee204bb63","6372fe60f6a6d30a","6372fe60f6a6d30a","6372fe60f6a6d30a","6372fe60f6a6d30a","6372fe60f6a6d30a"]}, -{"id":"edge-ctx:14","input":"SELECT 'a\\\"b', z\nFROM t","digests":["890a2e763703057f","890a2e763703057f","890a2e763703057f","890a2e763703057f","1b45a03ccbcb2bfd","1b45a03ccbcb2bfd","1b45a03ccbcb2bfd","1b45a03ccbcb2bfd","1b45a03ccbcb2bfd"]}, -{"id":"edge:15","input":"'a\\`b'","digests":["6afde6f58ea147fa","6afde6f58ea147fa","6afde6f58ea147fa","6afde6f58ea147fa","784f5759fa117773","784f5759fa117773","784f5759fa117773","784f5759fa117773","784f5759fa117773"]}, -{"id":"edge-ctx:15","input":"SELECT 'a\\`b', z\nFROM t","digests":["54801a441fe58f0c","54801a441fe58f0c","54801a441fe58f0c","54801a441fe58f0c","f9b7bc9897c274c5","f9b7bc9897c274c5","f9b7bc9897c274c5","f9b7bc9897c274c5","f9b7bc9897c274c5"]}, -{"id":"edge:16","input":"'a\\tb\\rc'","digests":["301c869d44e51bca","301c869d44e51bca","301c869d44e51bca","301c869d44e51bca","3ce52d5293ec430c","3ce52d5293ec430c","3ce52d5293ec430c","3ce52d5293ec430c","3ce52d5293ec430c"]}, -{"id":"edge-ctx:16","input":"SELECT 'a\\tb\\rc', z\nFROM t","digests":["fa81d96e840e0254","fa81d96e840e0254","fa81d96e840e0254","fa81d96e840e0254","b73b6134a815fb30","b73b6134a815fb30","b73b6134a815fb30","b73b6134a815fb30","b73b6134a815fb30"]}, -{"id":"edge:17","input":"'abc\\","digests":["0643a0bc9ba6c849","0643a0bc9ba6c849","0643a0bc9ba6c849","0643a0bc9ba6c849","d2dc2b9e6f815dd9","d2dc2b9e6f815dd9","d2dc2b9e6f815dd9","d2dc2b9e6f815dd9","d2dc2b9e6f815dd9"]}, -{"id":"edge-ctx:17","input":"SELECT 'abc\\, z\nFROM t","digests":["d7792f23ea802ff5","d7792f23ea802ff5","d7792f23ea802ff5","d7792f23ea802ff5","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95"]}, -{"id":"edge:18","input":"'\\","digests":["0444a61dc23e2b19","0444a61dc23e2b19","0444a61dc23e2b19","0444a61dc23e2b19","983b205858b7200a","983b205858b7200a","983b205858b7200a","983b205858b7200a","983b205858b7200a"]}, -{"id":"edge-ctx:18","input":"SELECT '\\, z\nFROM t","digests":["0d9488fe07dfc685","0d9488fe07dfc685","0d9488fe07dfc685","0d9488fe07dfc685","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a"]}, +{"id":"edge:8","input":"'a\\nb'","digests":["ae50db685f7e119c","e1f4a2a703342e55","ae50db685f7e119c","ae50db685f7e119c","e1f4a2a703342e55","e1f4a2a703342e55","e1f4a2a703342e55","ae50db685f7e119c","ae50db685f7e119c"]}, +{"id":"edge-ctx:8","input":"SELECT 'a\\nb', z\nFROM t","digests":["2a4c7a62d81139c2","e32cb9dcfa6153e2","2a4c7a62d81139c2","2a4c7a62d81139c2","e32cb9dcfa6153e2","e32cb9dcfa6153e2","e32cb9dcfa6153e2","2a4c7a62d81139c2","2a4c7a62d81139c2"]}, +{"id":"edge:9","input":"'a\\\\'","digests":["b9de1d2bc1bd6964","b778e1deae10b188","b9de1d2bc1bd6964","b9de1d2bc1bd6964","b778e1deae10b188","b778e1deae10b188","b778e1deae10b188","b9de1d2bc1bd6964","b9de1d2bc1bd6964"]}, +{"id":"edge-ctx:9","input":"SELECT 'a\\\\', z\nFROM t","digests":["660ac81b1ae7a036","4b342ade005758ed","660ac81b1ae7a036","660ac81b1ae7a036","4b342ade005758ed","4b342ade005758ed","4b342ade005758ed","660ac81b1ae7a036","660ac81b1ae7a036"]}, +{"id":"edge:10","input":"'a\\\\' 'b'","digests":["b2a577fc9b0cbb68","0bd18078b90ebb12","b2a577fc9b0cbb68","b2a577fc9b0cbb68","0bd18078b90ebb12","0bd18078b90ebb12","0bd18078b90ebb12","b2a577fc9b0cbb68","b2a577fc9b0cbb68"]}, +{"id":"edge-ctx:10","input":"SELECT 'a\\\\' 'b', z\nFROM t","digests":["624e1adbafc9aa1e","78845813ebb6fecd","624e1adbafc9aa1e","624e1adbafc9aa1e","78845813ebb6fecd","78845813ebb6fecd","78845813ebb6fecd","624e1adbafc9aa1e","624e1adbafc9aa1e"]}, +{"id":"edge:11","input":"'\\\\'","digests":["8d07813b87bc81c8","43c8d0f4c539bb89","8d07813b87bc81c8","8d07813b87bc81c8","43c8d0f4c539bb89","43c8d0f4c539bb89","43c8d0f4c539bb89","8d07813b87bc81c8","8d07813b87bc81c8"]}, +{"id":"edge-ctx:11","input":"SELECT '\\\\', z\nFROM t","digests":["841e517b63521d3c","a377428bc4dbf5f1","841e517b63521d3c","841e517b63521d3c","a377428bc4dbf5f1","a377428bc4dbf5f1","a377428bc4dbf5f1","841e517b63521d3c","841e517b63521d3c"]}, +{"id":"edge:12","input":"'a\\q'","digests":["203e5c6c9ad0dd20","203e5c6c9ad0dd20","d017c5c0490cd11c","d017c5c0490cd11c","203e5c6c9ad0dd20","203e5c6c9ad0dd20","203e5c6c9ad0dd20","203e5c6c9ad0dd20","203e5c6c9ad0dd20"]}, +{"id":"edge-ctx:12","input":"SELECT 'a\\q', z\nFROM t","digests":["52df02d6bb8c6abe","52df02d6bb8c6abe","e1698ee1eed2d5de","e1698ee1eed2d5de","52df02d6bb8c6abe","52df02d6bb8c6abe","52df02d6bb8c6abe","52df02d6bb8c6abe","52df02d6bb8c6abe"]}, +{"id":"edge:13","input":"'a\\'b'","digests":["1f150d15a0391773","a7b9dccff45ade91","1f150d15a0391773","1f150d15a0391773","a7b9dccff45ade91","a7b9dccff45ade91","a7b9dccff45ade91","1f150d15a0391773","1f150d15a0391773"]}, +{"id":"edge-ctx:13","input":"SELECT 'a\\'b', z\nFROM t","digests":["2b811e8c2624f45e","86ac23f841263192","2b811e8c2624f45e","2b811e8c2624f45e","86ac23f841263192","86ac23f841263192","86ac23f841263192","2b811e8c2624f45e","2b811e8c2624f45e"]}, +{"id":"edge:14","input":"'a\\\"b'","digests":["ef01453ee204bb63","6372fe60f6a6d30a","ef01453ee204bb63","ef01453ee204bb63","6372fe60f6a6d30a","6372fe60f6a6d30a","6372fe60f6a6d30a","ef01453ee204bb63","ef01453ee204bb63"]}, +{"id":"edge-ctx:14","input":"SELECT 'a\\\"b', z\nFROM t","digests":["890a2e763703057f","1b45a03ccbcb2bfd","890a2e763703057f","890a2e763703057f","1b45a03ccbcb2bfd","1b45a03ccbcb2bfd","1b45a03ccbcb2bfd","890a2e763703057f","890a2e763703057f"]}, +{"id":"edge:15","input":"'a\\`b'","digests":["6afde6f58ea147fa","784f5759fa117773","6afde6f58ea147fa","6afde6f58ea147fa","784f5759fa117773","784f5759fa117773","784f5759fa117773","6afde6f58ea147fa","6afde6f58ea147fa"]}, +{"id":"edge-ctx:15","input":"SELECT 'a\\`b', z\nFROM t","digests":["54801a441fe58f0c","f9b7bc9897c274c5","54801a441fe58f0c","54801a441fe58f0c","f9b7bc9897c274c5","f9b7bc9897c274c5","f9b7bc9897c274c5","54801a441fe58f0c","54801a441fe58f0c"]}, +{"id":"edge:16","input":"'a\\tb\\rc'","digests":["301c869d44e51bca","3ce52d5293ec430c","301c869d44e51bca","301c869d44e51bca","3ce52d5293ec430c","3ce52d5293ec430c","3ce52d5293ec430c","301c869d44e51bca","301c869d44e51bca"]}, +{"id":"edge-ctx:16","input":"SELECT 'a\\tb\\rc', z\nFROM t","digests":["fa81d96e840e0254","b73b6134a815fb30","fa81d96e840e0254","fa81d96e840e0254","b73b6134a815fb30","b73b6134a815fb30","b73b6134a815fb30","fa81d96e840e0254","fa81d96e840e0254"]}, +{"id":"edge:17","input":"'abc\\","digests":["0643a0bc9ba6c849","d2dc2b9e6f815dd9","0643a0bc9ba6c849","0643a0bc9ba6c849","d2dc2b9e6f815dd9","d2dc2b9e6f815dd9","d2dc2b9e6f815dd9","0643a0bc9ba6c849","0643a0bc9ba6c849"]}, +{"id":"edge-ctx:17","input":"SELECT 'abc\\, z\nFROM t","digests":["4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95","4e1b97efbffc5c95"]}, +{"id":"edge:18","input":"'\\","digests":["0444a61dc23e2b19","983b205858b7200a","0444a61dc23e2b19","0444a61dc23e2b19","983b205858b7200a","983b205858b7200a","983b205858b7200a","0444a61dc23e2b19","0444a61dc23e2b19"]}, +{"id":"edge-ctx:18","input":"SELECT '\\, z\nFROM t","digests":["eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a","eba4ef5e6a590f9a"]}, {"id":"edge:19","input":"'a\nb'","digests":["48987c4bc5544827","48987c4bc5544827","48987c4bc5544827","48987c4bc5544827","48987c4bc5544827","48987c4bc5544827","48987c4bc5544827","48987c4bc5544827","48987c4bc5544827"]}, {"id":"edge-ctx:19","input":"SELECT 'a\nb', z\nFROM t","digests":["f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab","f8bbbd5204aee5ab"]}, {"id":"edge:20","input":"'a\r\nb'","digests":["84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1","84454aeccbba95c1"]}, @@ -256,16 +335,16 @@ {"id":"edge-ctx:38","input":"SELECT 'é''é', z\nFROM t","digests":["25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d","25c12b790e7a556d"]}, {"id":"edge:39","input_b64":"J/8=","digests":["3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45","3b31eb586a701e45"]}, {"id":"edge-ctx:39","input_b64":"U0VMRUNUICf/LCB6CkZST00gdA==","digests":["1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5","1f4296edf6eaa8b5"]}, -{"id":"edge:40","input":"N'abc'","digests":["5fd7391de28833d2","5fd7391de28833d2","5fd7391de28833d2","5fd7391de28833d2","a50ea792f1d217ef","5fd7391de28833d2","5fd7391de28833d2","5fd7391de28833d2","5fd7391de28833d2"]}, -{"id":"edge-ctx:40","input":"SELECT N'abc', z\nFROM t","digests":["25c60c614ec2ed97","25c60c614ec2ed97","25c60c614ec2ed97","25c60c614ec2ed97","2560e72722e92c2c","25c60c614ec2ed97","25c60c614ec2ed97","25c60c614ec2ed97","25c60c614ec2ed97"]}, -{"id":"edge:41","input":"N'it''s'","digests":["043cd2a4adffdd5b","043cd2a4adffdd5b","043cd2a4adffdd5b","043cd2a4adffdd5b","172298ed3ea2c9b8","043cd2a4adffdd5b","043cd2a4adffdd5b","043cd2a4adffdd5b","043cd2a4adffdd5b"]}, -{"id":"edge-ctx:41","input":"SELECT N'it''s', z\nFROM t","digests":["7e60d73a3249e619","7e60d73a3249e619","7e60d73a3249e619","7e60d73a3249e619","1912a76859af4cda","7e60d73a3249e619","7e60d73a3249e619","7e60d73a3249e619","7e60d73a3249e619"]}, -{"id":"edge:42","input":"N''","digests":["25a1a8642c31416f","25a1a8642c31416f","25a1a8642c31416f","25a1a8642c31416f","2cfcf212502a728d","25a1a8642c31416f","25a1a8642c31416f","25a1a8642c31416f","25a1a8642c31416f"]}, -{"id":"edge-ctx:42","input":"SELECT N'', z\nFROM t","digests":["7cec3ef1c4575383","7cec3ef1c4575383","7cec3ef1c4575383","7cec3ef1c4575383","27ab4c06d631584a","7cec3ef1c4575383","7cec3ef1c4575383","7cec3ef1c4575383","7cec3ef1c4575383"]}, -{"id":"edge:43","input":"n'x'","digests":["1ffbd1684ca12e52","1ffbd1684ca12e52","1ffbd1684ca12e52","1ffbd1684ca12e52","92cd5a30ac549142","1ffbd1684ca12e52","1ffbd1684ca12e52","1ffbd1684ca12e52","1ffbd1684ca12e52"]}, -{"id":"edge-ctx:43","input":"SELECT n'x', z\nFROM t","digests":["b74a9844d68263ef","b74a9844d68263ef","b74a9844d68263ef","b74a9844d68263ef","3dda3d19c9eaf4c9","b74a9844d68263ef","b74a9844d68263ef","b74a9844d68263ef","b74a9844d68263ef"]}, -{"id":"edge:44","input":"N'a\\b'","digests":["3bd2075f5cbd00bd","3bd2075f5cbd00bd","3bd2075f5cbd00bd","3bd2075f5cbd00bd","86808038a1737211","05da977678c9ab05","05da977678c9ab05","05da977678c9ab05","05da977678c9ab05"]}, -{"id":"edge-ctx:44","input":"SELECT N'a\\b', z\nFROM t","digests":["a79f6a7b7b879f19","a79f6a7b7b879f19","a79f6a7b7b879f19","a79f6a7b7b879f19","fa3dcde661d4c21a","59bbb735bae4f009","59bbb735bae4f009","59bbb735bae4f009","59bbb735bae4f009"]}, +{"id":"edge:40","input":"N'abc'","digests":["5fd7391de28833d2","a50ea792f1d217ef","a50ea792f1d217ef","a50ea792f1d217ef","a50ea792f1d217ef","a50ea792f1d217ef","5fd7391de28833d2","5fd7391de28833d2","5fd7391de28833d2"]}, +{"id":"edge-ctx:40","input":"SELECT N'abc', z\nFROM t","digests":["25c60c614ec2ed97","2560e72722e92c2c","2560e72722e92c2c","2560e72722e92c2c","2560e72722e92c2c","2560e72722e92c2c","25c60c614ec2ed97","25c60c614ec2ed97","25c60c614ec2ed97"]}, +{"id":"edge:41","input":"N'it''s'","digests":["043cd2a4adffdd5b","172298ed3ea2c9b8","172298ed3ea2c9b8","172298ed3ea2c9b8","172298ed3ea2c9b8","172298ed3ea2c9b8","043cd2a4adffdd5b","043cd2a4adffdd5b","043cd2a4adffdd5b"]}, +{"id":"edge-ctx:41","input":"SELECT N'it''s', z\nFROM t","digests":["7e60d73a3249e619","1912a76859af4cda","1912a76859af4cda","1912a76859af4cda","1912a76859af4cda","1912a76859af4cda","7e60d73a3249e619","7e60d73a3249e619","7e60d73a3249e619"]}, +{"id":"edge:42","input":"N''","digests":["25a1a8642c31416f","2cfcf212502a728d","2cfcf212502a728d","2cfcf212502a728d","2cfcf212502a728d","2cfcf212502a728d","25a1a8642c31416f","25a1a8642c31416f","25a1a8642c31416f"]}, +{"id":"edge-ctx:42","input":"SELECT N'', z\nFROM t","digests":["7cec3ef1c4575383","27ab4c06d631584a","27ab4c06d631584a","27ab4c06d631584a","27ab4c06d631584a","27ab4c06d631584a","7cec3ef1c4575383","7cec3ef1c4575383","7cec3ef1c4575383"]}, +{"id":"edge:43","input":"n'x'","digests":["1ffbd1684ca12e52","92cd5a30ac549142","92cd5a30ac549142","92cd5a30ac549142","92cd5a30ac549142","92cd5a30ac549142","1ffbd1684ca12e52","1ffbd1684ca12e52","1ffbd1684ca12e52"]}, +{"id":"edge-ctx:43","input":"SELECT n'x', z\nFROM t","digests":["b74a9844d68263ef","3dda3d19c9eaf4c9","3dda3d19c9eaf4c9","3dda3d19c9eaf4c9","3dda3d19c9eaf4c9","3dda3d19c9eaf4c9","b74a9844d68263ef","b74a9844d68263ef","b74a9844d68263ef"]}, +{"id":"edge:44","input":"N'a\\b'","digests":["52deaee6ac025400","86808038a1737211","805e718202c7d533","805e718202c7d533","86808038a1737211","86808038a1737211","05da977678c9ab05","52deaee6ac025400","52deaee6ac025400"]}, +{"id":"edge-ctx:44","input":"SELECT N'a\\b', z\nFROM t","digests":["503cd936895fe6d9","fa3dcde661d4c21a","2faedf05f816ea38","2faedf05f816ea38","fa3dcde661d4c21a","fa3dcde661d4c21a","59bbb735bae4f009","503cd936895fe6d9","503cd936895fe6d9"]}, {"id":"edge:45","input":"N'abc","digests":["9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735","9b7aed9652b59735"]}, {"id":"edge-ctx:45","input":"SELECT N'abc, z\nFROM t","digests":["2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6","2e0d0ee977be6fe6"]}, {"id":"edge:46","input":"\"Col\"","digests":["521a48e42f179722","521a48e42f179722","521a48e42f179722","521a48e42f179722","521a48e42f179722","521a48e42f179722","521a48e42f179722","521a48e42f179722","521a48e42f179722"]}, @@ -342,62 +421,62 @@ {"id":"edge-ctx:81","input":"SELECT '''a\\nb''', z\nFROM t","digests":["12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7","12490560bb3f8fc7"]}, {"id":"edge:82","input":"\"\"\"a'b\"\"\"","digests":["e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3","e53178a8b5fd06b3"]}, {"id":"edge-ctx:82","input":"SELECT \"\"\"a'b\"\"\", z\nFROM t","digests":["53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b","53a384a31ee6cb1b"]}, -{"id":"edge:83","input":"SELECT 'a', \"b\", `c` FROM t WHERE x = 'it''s' AND y = 'p\\nq'","digests":["f9727120bbdefc93","f9727120bbdefc93","f9727120bbdefc93","f9727120bbdefc93","e580a8e7faa7b878","e580a8e7faa7b878","e580a8e7faa7b878","e580a8e7faa7b878","e580a8e7faa7b878"]}, -{"id":"edge-ctx:83","input":"SELECT SELECT 'a', \"b\", `c` FROM t WHERE x = 'it''s' AND y = 'p\\nq', z\nFROM t","digests":["7f1f03354eac9d0b","7f1f03354eac9d0b","7f1f03354eac9d0b","7f1f03354eac9d0b","d27d065df1958cb4","d27d065df1958cb4","d27d065df1958cb4","d27d065df1958cb4","d27d065df1958cb4"]}, +{"id":"edge:83","input":"SELECT 'a', \"b\", `c` FROM t WHERE x = 'it''s' AND y = 'p\\nq'","digests":["f9727120bbdefc93","e580a8e7faa7b878","f9727120bbdefc93","f9727120bbdefc93","e580a8e7faa7b878","e580a8e7faa7b878","e580a8e7faa7b878","f9727120bbdefc93","f9727120bbdefc93"]}, +{"id":"edge-ctx:83","input":"SELECT SELECT 'a', \"b\", `c` FROM t WHERE x = 'it''s' AND y = 'p\\nq', z\nFROM t","digests":["7f1f03354eac9d0b","d27d065df1958cb4","7f1f03354eac9d0b","7f1f03354eac9d0b","d27d065df1958cb4","d27d065df1958cb4","d27d065df1958cb4","7f1f03354eac9d0b","7f1f03354eac9d0b"]}, {"id":"edge:84","input":"SELECT 'a'\n, 'b\nc', \"d\" FROM t -- tail","digests":["8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e","8cdc19bf9c74033e"]}, {"id":"edge-ctx:84","input":"SELECT SELECT 'a'\n, 'b\nc', \"d\" FROM t -- tail, z\nFROM t","digests":["9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f","9a786d8b840ae12f"]}, {"id":"edge:85","input":"SELECT “Name”, ‘v’ FROM “T” WHERE «k» = 'x'","digests":["f134e50821f967e4","f134e50821f967e4","f134e50821f967e4","f134e50821f967e4","f134e50821f967e4","f134e50821f967e4","f134e50821f967e4","f134e50821f967e4","f134e50821f967e4"]}, {"id":"edge-ctx:85","input":"SELECT SELECT “Name”, ‘v’ FROM “T” WHERE «k» = 'x', z\nFROM t","digests":["4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7","4e518f666710d5b7"]}, -{"id":"edge:86","input":"INSERT INTO t (a, b) VALUES ('alpha', 'beta'), ('it''s', 'a\\tb')","digests":["a1e073c91f88e1a7","a1e073c91f88e1a7","a1e073c91f88e1a7","a1e073c91f88e1a7","ce96831e62dc88ed","ce96831e62dc88ed","ce96831e62dc88ed","ce96831e62dc88ed","ce96831e62dc88ed"]}, -{"id":"edge-ctx:86","input":"SELECT INSERT INTO t (a, b) VALUES ('alpha', 'beta'), ('it''s', 'a\\tb'), z\nFROM t","digests":["e57d9caf566ed7ec","e57d9caf566ed7ec","e57d9caf566ed7ec","e57d9caf566ed7ec","831705631cd97847","831705631cd97847","831705631cd97847","831705631cd97847","831705631cd97847"]}, -{"id":"edge:87","input":"SELECT N'abc', N'x''y' FROM [dbo].[T] WHERE \"c\" = 'v'","digests":["419c1615fccf227f","419c1615fccf227f","419c1615fccf227f","419c1615fccf227f","41c66726bbd22c0d","419c1615fccf227f","419c1615fccf227f","419c1615fccf227f","419c1615fccf227f"]}, -{"id":"edge-ctx:87","input":"SELECT SELECT N'abc', N'x''y' FROM [dbo].[T] WHERE \"c\" = 'v', z\nFROM t","digests":["87058e4fbb8847df","87058e4fbb8847df","87058e4fbb8847df","87058e4fbb8847df","73f4e05f79319c0b","87058e4fbb8847df","87058e4fbb8847df","87058e4fbb8847df","87058e4fbb8847df"]}, +{"id":"edge:86","input":"INSERT INTO t (a, b) VALUES ('alpha', 'beta'), ('it''s', 'a\\tb')","digests":["a1e073c91f88e1a7","ce96831e62dc88ed","a1e073c91f88e1a7","a1e073c91f88e1a7","ce96831e62dc88ed","ce96831e62dc88ed","ce96831e62dc88ed","a1e073c91f88e1a7","a1e073c91f88e1a7"]}, +{"id":"edge-ctx:86","input":"SELECT INSERT INTO t (a, b) VALUES ('alpha', 'beta'), ('it''s', 'a\\tb'), z\nFROM t","digests":["e57d9caf566ed7ec","831705631cd97847","e57d9caf566ed7ec","e57d9caf566ed7ec","831705631cd97847","831705631cd97847","831705631cd97847","e57d9caf566ed7ec","e57d9caf566ed7ec"]}, +{"id":"edge:87","input":"SELECT N'abc', N'x''y' FROM [dbo].[T] WHERE \"c\" = 'v'","digests":["419c1615fccf227f","ed8e82d9395b9c6c","ed8e82d9395b9c6c","ed8e82d9395b9c6c","41c66726bbd22c0d","ed8e82d9395b9c6c","3ea264bef550b239","419c1615fccf227f","419c1615fccf227f"]}, +{"id":"edge-ctx:87","input":"SELECT SELECT N'abc', N'x''y' FROM [dbo].[T] WHERE \"c\" = 'v', z\nFROM t","digests":["87058e4fbb8847df","bb434fd0e41e6a16","bb434fd0e41e6a16","bb434fd0e41e6a16","73f4e05f79319c0b","bb434fd0e41e6a16","4bdc0eb2575557c0","87058e4fbb8847df","87058e4fbb8847df"]}, {"id":"edge:88","input":"SELECT '''triple''' AS t, `bt``x` FROM t","digests":["64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae","64f5b178f3392cae"]}, {"id":"edge-ctx:88","input":"SELECT SELECT '''triple''' AS t, `bt``x` FROM t, z\nFROM t","digests":["4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa","4069af0435c44eaa"]}, {"id":"edge:89","input":"SELECT 'é', \"é\", `é` FROM t\nWHERE a = 'z'","digests":["b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35","b2fdfde102302f35"]}, {"id":"edge-ctx:89","input":"SELECT SELECT 'é', \"é\", `é` FROM t\nWHERE a = 'z', z\nFROM t","digests":["c37117f7c4215966","c37117f7c4215966","c37117f7c4215966","c37117f7c4215966","c37117f7c4215966","c37117f7c4215966","c37117f7c4215966","c37117f7c4215966","c37117f7c4215966"]}, -{"id":"edge:90","input":"'a\nb' 'c\\q'","digests":["80e3bbf349831ac3","80e3bbf349831ac3","80e3bbf349831ac3","80e3bbf349831ac3","7a273513c1fef3b9","7a273513c1fef3b9","7a273513c1fef3b9","7a273513c1fef3b9","7a273513c1fef3b9"]}, -{"id":"edge-ctx:90","input":"SELECT 'a\nb' 'c\\q', z\nFROM t","digests":["c955f884e5659f72","c955f884e5659f72","c955f884e5659f72","c955f884e5659f72","b6daae8599813aea","b6daae8599813aea","b6daae8599813aea","b6daae8599813aea","b6daae8599813aea"]}, +{"id":"edge:90","input":"'a\nb' 'c\\q'","digests":["7a273513c1fef3b9","7a273513c1fef3b9","fea521a8de6c013f","fea521a8de6c013f","7a273513c1fef3b9","7a273513c1fef3b9","7a273513c1fef3b9","7a273513c1fef3b9","7a273513c1fef3b9"]}, +{"id":"edge-ctx:90","input":"SELECT 'a\nb' 'c\\q', z\nFROM t","digests":["b6daae8599813aea","b6daae8599813aea","60b706f98299d1ae","60b706f98299d1ae","b6daae8599813aea","b6daae8599813aea","b6daae8599813aea","b6daae8599813aea","b6daae8599813aea"]}, {"id":"edge:91","input":"'a\nb' \"c\nd\"","digests":["d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a","d9eab7bb1274b41a"]}, {"id":"edge-ctx:91","input":"SELECT 'a\nb' \"c\nd\", z\nFROM t","digests":["67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf","67ab489127cc7cdf"]}, -{"id":"edge:92","input":"`é\nx` 'y\\q'","digests":["ea6ff8ff99904935","ea6ff8ff99904935","ea6ff8ff99904935","ea6ff8ff99904935","11a1d6ab65beea65","11a1d6ab65beea65","11a1d6ab65beea65","11a1d6ab65beea65","11a1d6ab65beea65"]}, -{"id":"edge-ctx:92","input":"SELECT `é\nx` 'y\\q', z\nFROM t","digests":["8c6196a16c9eb467","8c6196a16c9eb467","8c6196a16c9eb467","8c6196a16c9eb467","ad3da9d7a65a118c","ad3da9d7a65a118c","ad3da9d7a65a118c","ad3da9d7a65a118c","ad3da9d7a65a118c"]}, +{"id":"edge:92","input":"`é\nx` 'y\\q'","digests":["11a1d6ab65beea65","11a1d6ab65beea65","46bd0867a299b4d3","46bd0867a299b4d3","11a1d6ab65beea65","11a1d6ab65beea65","11a1d6ab65beea65","11a1d6ab65beea65","11a1d6ab65beea65"]}, +{"id":"edge-ctx:92","input":"SELECT `é\nx` 'y\\q', z\nFROM t","digests":["ad3da9d7a65a118c","ad3da9d7a65a118c","9bead0dc54263615","9bead0dc54263615","ad3da9d7a65a118c","ad3da9d7a65a118c","ad3da9d7a65a118c","ad3da9d7a65a118c","ad3da9d7a65a118c"]}, {"id":"edge:93","input":"'é€𝄞' \"c\nd\"","digests":["3506b903294d11ab","3506b903294d11ab","3506b903294d11ab","3506b903294d11ab","3506b903294d11ab","3506b903294d11ab","3506b903294d11ab","3506b903294d11ab","3506b903294d11ab"]}, {"id":"edge-ctx:93","input":"SELECT 'é€𝄞' \"c\nd\", z\nFROM t","digests":["9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb","9e60b4d5ec484bcb"]}, -{"id":"edge:94","input":"\"é\" 'a\\q'","digests":["284635c184bac677","284635c184bac677","284635c184bac677","284635c184bac677","cc44a44f19d91d8f","cc44a44f19d91d8f","cc44a44f19d91d8f","cc44a44f19d91d8f","cc44a44f19d91d8f"]}, -{"id":"edge-ctx:94","input":"SELECT \"é\" 'a\\q', z\nFROM t","digests":["64c9d44c0aabcd4e","64c9d44c0aabcd4e","64c9d44c0aabcd4e","64c9d44c0aabcd4e","46f7449a8294353c","46f7449a8294353c","46f7449a8294353c","46f7449a8294353c","46f7449a8294353c"]}, +{"id":"edge:94","input":"\"é\" 'a\\q'","digests":["cc44a44f19d91d8f","cc44a44f19d91d8f","f12cbbbd1d29f47c","f12cbbbd1d29f47c","cc44a44f19d91d8f","cc44a44f19d91d8f","cc44a44f19d91d8f","cc44a44f19d91d8f","cc44a44f19d91d8f"]}, +{"id":"edge-ctx:94","input":"SELECT \"é\" 'a\\q', z\nFROM t","digests":["46f7449a8294353c","46f7449a8294353c","5b7f715c67ec1f0d","5b7f715c67ec1f0d","46f7449a8294353c","46f7449a8294353c","46f7449a8294353c","46f7449a8294353c","46f7449a8294353c"]}, {"id":"edge:95","input":"'''a\nb''' \"c\nd\"","digests":["797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b","797b8c780ecbdd5b"]}, {"id":"edge-ctx:95","input":"SELECT '''a\nb''' \"c\nd\", z\nFROM t","digests":["dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1","dd7a2c13021131b1"]}, -{"id":"edge:96","input":"'''a\nb''' 'c\\q'","digests":["037fb68d528a080a","037fb68d528a080a","037fb68d528a080a","037fb68d528a080a","051db694cf3b02a0","051db694cf3b02a0","051db694cf3b02a0","051db694cf3b02a0","051db694cf3b02a0"]}, -{"id":"edge-ctx:96","input":"SELECT '''a\nb''' 'c\\q', z\nFROM t","digests":["c1410465031775a1","c1410465031775a1","c1410465031775a1","c1410465031775a1","6d0ccaafe07321f3","6d0ccaafe07321f3","6d0ccaafe07321f3","6d0ccaafe07321f3","6d0ccaafe07321f3"]}, -{"id":"edge:97","input":"'''é€𝄞''' 'c\\q'","digests":["66124adc9bb22902","66124adc9bb22902","66124adc9bb22902","66124adc9bb22902","431e56e83a63448b","431e56e83a63448b","431e56e83a63448b","431e56e83a63448b","431e56e83a63448b"]}, -{"id":"edge-ctx:97","input":"SELECT '''é€𝄞''' 'c\\q', z\nFROM t","digests":["00dc21bb449e671c","00dc21bb449e671c","00dc21bb449e671c","00dc21bb449e671c","b96ade0392530325","b96ade0392530325","b96ade0392530325","b96ade0392530325","b96ade0392530325"]}, -{"id":"edge:98","input":"N'é\nx' 'c\\q'","digests":["66f5782ae2938984","66f5782ae2938984","66f5782ae2938984","66f5782ae2938984","252f9b34da482a34","c6f95736203ffd5d","c6f95736203ffd5d","c6f95736203ffd5d","c6f95736203ffd5d"]}, -{"id":"edge-ctx:98","input":"SELECT N'é\nx' 'c\\q', z\nFROM t","digests":["0da153e74efe66e1","0da153e74efe66e1","0da153e74efe66e1","0da153e74efe66e1","1823c537ffb8b58a","b0fc4d8ad066dfa6","b0fc4d8ad066dfa6","b0fc4d8ad066dfa6","b0fc4d8ad066dfa6"]}, +{"id":"edge:96","input":"'''a\nb''' 'c\\q'","digests":["051db694cf3b02a0","051db694cf3b02a0","6f5ac2455904097a","6f5ac2455904097a","051db694cf3b02a0","051db694cf3b02a0","051db694cf3b02a0","051db694cf3b02a0","051db694cf3b02a0"]}, +{"id":"edge-ctx:96","input":"SELECT '''a\nb''' 'c\\q', z\nFROM t","digests":["6d0ccaafe07321f3","6d0ccaafe07321f3","600554a74f934ec9","600554a74f934ec9","6d0ccaafe07321f3","6d0ccaafe07321f3","6d0ccaafe07321f3","6d0ccaafe07321f3","6d0ccaafe07321f3"]}, +{"id":"edge:97","input":"'''é€𝄞''' 'c\\q'","digests":["431e56e83a63448b","431e56e83a63448b","95ce7ab24a338d11","95ce7ab24a338d11","431e56e83a63448b","431e56e83a63448b","431e56e83a63448b","431e56e83a63448b","431e56e83a63448b"]}, +{"id":"edge-ctx:97","input":"SELECT '''é€𝄞''' 'c\\q', z\nFROM t","digests":["b96ade0392530325","b96ade0392530325","eeb7029a04437ced","eeb7029a04437ced","b96ade0392530325","b96ade0392530325","b96ade0392530325","b96ade0392530325","b96ade0392530325"]}, +{"id":"edge:98","input":"N'é\nx' 'c\\q'","digests":["c6f95736203ffd5d","252f9b34da482a34","99be450da7de69dc","99be450da7de69dc","252f9b34da482a34","252f9b34da482a34","c6f95736203ffd5d","c6f95736203ffd5d","c6f95736203ffd5d"]}, +{"id":"edge-ctx:98","input":"SELECT N'é\nx' 'c\\q', z\nFROM t","digests":["b0fc4d8ad066dfa6","1823c537ffb8b58a","1533157d2fbe35af","1533157d2fbe35af","1823c537ffb8b58a","1823c537ffb8b58a","b0fc4d8ad066dfa6","b0fc4d8ad066dfa6","b0fc4d8ad066dfa6"]}, {"id":"edge:99","input":"N'é€𝄞' \"c\nd\"","digests":["3195a29420d98e96","3195a29420d98e96","3195a29420d98e96","3195a29420d98e96","3195a29420d98e96","3195a29420d98e96","3195a29420d98e96","3195a29420d98e96","3195a29420d98e96"]}, {"id":"edge-ctx:99","input":"SELECT N'é€𝄞' \"c\nd\", z\nFROM t","digests":["0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a","0add70dade15bb4a"]}, -{"id":"edge:100","input":"‘a\nb’ 'c\\q'","digests":["f6a45e53f848f78c","f6a45e53f848f78c","f6a45e53f848f78c","f6a45e53f848f78c","951a44eec552533e","951a44eec552533e","951a44eec552533e","951a44eec552533e","951a44eec552533e"]}, -{"id":"edge-ctx:100","input":"SELECT ‘a\nb’ 'c\\q', z\nFROM t","digests":["7172f47e527ff685","7172f47e527ff685","7172f47e527ff685","7172f47e527ff685","59381a8641425502","59381a8641425502","59381a8641425502","59381a8641425502","59381a8641425502"]}, +{"id":"edge:100","input":"‘a\nb’ 'c\\q'","digests":["951a44eec552533e","951a44eec552533e","9598afe373877a11","9598afe373877a11","951a44eec552533e","951a44eec552533e","951a44eec552533e","951a44eec552533e","951a44eec552533e"]}, +{"id":"edge-ctx:100","input":"SELECT ‘a\nb’ 'c\\q', z\nFROM t","digests":["59381a8641425502","59381a8641425502","dcf270668790c1b6","dcf270668790c1b6","59381a8641425502","59381a8641425502","59381a8641425502","59381a8641425502","59381a8641425502"]}, {"id":"edge:101","input":"‘é€𝄞’ \"c\nd\"","digests":["92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e","92ce5b3d5d4e289e"]}, {"id":"edge-ctx:101","input":"SELECT ‘é€𝄞’ \"c\nd\", z\nFROM t","digests":["d22586331e8334c0","d22586331e8334c0","d22586331e8334c0","d22586331e8334c0","d22586331e8334c0","d22586331e8334c0","d22586331e8334c0","d22586331e8334c0","d22586331e8334c0"]}, {"id":"edge:102","input":"“é\nx” 'c\\q'","digests":["ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6","ec7f63079a1816b6"]}, {"id":"edge-ctx:102","input":"SELECT “é\nx” 'c\\q', z\nFROM t","digests":["21802c0c437590ac","21802c0c437590ac","21802c0c437590ac","21802c0c437590ac","21802c0c437590ac","21802c0c437590ac","21802c0c437590ac","21802c0c437590ac","21802c0c437590ac"]}, {"id":"edge:103","input":"“途 \"c\nd\"","digests":["2892372859beab23","2892372859beab23","2892372859beab23","2892372859beab23","2892372859beab23","2892372859beab23","2892372859beab23","2892372859beab23","2892372859beab23"]}, {"id":"edge-ctx:103","input":"SELECT “途 \"c\nd\", z\nFROM t","digests":["fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6","fba361a74401f9c6"]}, -{"id":"edge:104","input":"'a''\nb''c' 'c\\q'","digests":["5a76ba7765ac96ea","5a76ba7765ac96ea","5a76ba7765ac96ea","5a76ba7765ac96ea","5d352ca3505f280e","5d352ca3505f280e","5d352ca3505f280e","5d352ca3505f280e","5d352ca3505f280e"]}, -{"id":"edge-ctx:104","input":"SELECT 'a''\nb''c' 'c\\q', z\nFROM t","digests":["b6301c147d791d85","b6301c147d791d85","b6301c147d791d85","b6301c147d791d85","ce8906be8c1ba43e","ce8906be8c1ba43e","ce8906be8c1ba43e","ce8906be8c1ba43e","ce8906be8c1ba43e"]}, -{"id":"edge:105","input":"'a\\\nb' 'c\\q'","digests":["dc4bf912b0dc1f78","dc4bf912b0dc1f78","dc4bf912b0dc1f78","dc4bf912b0dc1f78","9443964d461251fa","9443964d461251fa","9443964d461251fa","9443964d461251fa","9443964d461251fa"]}, -{"id":"edge-ctx:105","input":"SELECT 'a\\\nb' 'c\\q', z\nFROM t","digests":["7c1b8d228cc55283","7c1b8d228cc55283","7c1b8d228cc55283","7c1b8d228cc55283","5e6b33dc0257a152","5e6b33dc0257a152","5e6b33dc0257a152","5e6b33dc0257a152","5e6b33dc0257a152"]}, +{"id":"edge:104","input":"'a''\nb''c' 'c\\q'","digests":["5d352ca3505f280e","5d352ca3505f280e","dbd2da34bd3c8243","dbd2da34bd3c8243","5d352ca3505f280e","5d352ca3505f280e","5d352ca3505f280e","5d352ca3505f280e","5d352ca3505f280e"]}, +{"id":"edge-ctx:104","input":"SELECT 'a''\nb''c' 'c\\q', z\nFROM t","digests":["ce8906be8c1ba43e","ce8906be8c1ba43e","3d8f284c64c1c476","3d8f284c64c1c476","ce8906be8c1ba43e","ce8906be8c1ba43e","ce8906be8c1ba43e","ce8906be8c1ba43e","ce8906be8c1ba43e"]}, +{"id":"edge:105","input":"'a\\\nb' 'c\\q'","digests":["9443964d461251fa","9443964d461251fa","fea521a8de6c013f","fea521a8de6c013f","9443964d461251fa","9443964d461251fa","9443964d461251fa","9443964d461251fa","9443964d461251fa"]}, +{"id":"edge-ctx:105","input":"SELECT 'a\\\nb' 'c\\q', z\nFROM t","digests":["5e6b33dc0257a152","5e6b33dc0257a152","60b706f98299d1ae","60b706f98299d1ae","5e6b33dc0257a152","5e6b33dc0257a152","5e6b33dc0257a152","5e6b33dc0257a152","5e6b33dc0257a152"]}, {"id":"edge:106","input":"`a``\nb` \"c\nd\"","digests":["51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424","51635d8c7e2b1424"]}, {"id":"edge-ctx:106","input":"SELECT `a``\nb` \"c\nd\", z\nFROM t","digests":["9db126c88689e383","9db126c88689e383","9db126c88689e383","9db126c88689e383","9db126c88689e383","9db126c88689e383","9db126c88689e383","9db126c88689e383","9db126c88689e383"]}, -{"id":"edge:107","input":"\"a\"\"é\" 'c\\q'","digests":["379a3de5de0783de","379a3de5de0783de","379a3de5de0783de","379a3de5de0783de","a637636da6f7f6bc","a637636da6f7f6bc","a637636da6f7f6bc","a637636da6f7f6bc","a637636da6f7f6bc"]}, -{"id":"edge-ctx:107","input":"SELECT \"a\"\"é\" 'c\\q', z\nFROM t","digests":["130ebd5c16fe0078","130ebd5c16fe0078","130ebd5c16fe0078","130ebd5c16fe0078","e35d9e820303c3ca","e35d9e820303c3ca","e35d9e820303c3ca","e35d9e820303c3ca","e35d9e820303c3ca"]}, -{"id":"edge:108","input":"'a\r\nb' 'c\\q'","digests":["73db9fbfdaf37c05","73db9fbfdaf37c05","73db9fbfdaf37c05","73db9fbfdaf37c05","abadf0637d47955a","abadf0637d47955a","abadf0637d47955a","abadf0637d47955a","abadf0637d47955a"]}, -{"id":"edge-ctx:108","input":"SELECT 'a\r\nb' 'c\\q', z\nFROM t","digests":["99b05fe11096e26b","99b05fe11096e26b","99b05fe11096e26b","99b05fe11096e26b","60890844420c3649","60890844420c3649","60890844420c3649","60890844420c3649","60890844420c3649"]}, +{"id":"edge:107","input":"\"a\"\"é\" 'c\\q'","digests":["a637636da6f7f6bc","a637636da6f7f6bc","c71339015a72396c","c71339015a72396c","a637636da6f7f6bc","a637636da6f7f6bc","a637636da6f7f6bc","a637636da6f7f6bc","a637636da6f7f6bc"]}, +{"id":"edge-ctx:107","input":"SELECT \"a\"\"é\" 'c\\q', z\nFROM t","digests":["e35d9e820303c3ca","e35d9e820303c3ca","be5b7d47dfcdf4f4","be5b7d47dfcdf4f4","e35d9e820303c3ca","e35d9e820303c3ca","e35d9e820303c3ca","e35d9e820303c3ca","e35d9e820303c3ca"]}, +{"id":"edge:108","input":"'a\r\nb' 'c\\q'","digests":["abadf0637d47955a","abadf0637d47955a","59a73a39255e234b","59a73a39255e234b","abadf0637d47955a","abadf0637d47955a","abadf0637d47955a","abadf0637d47955a","abadf0637d47955a"]}, +{"id":"edge-ctx:108","input":"SELECT 'a\r\nb' 'c\\q', z\nFROM t","digests":["60890844420c3649","60890844420c3649","8cd51ffe2f6bd18c","8cd51ffe2f6bd18c","60890844420c3649","60890844420c3649","60890844420c3649","60890844420c3649","60890844420c3649"]}, {"id":"edge:109","input":"'é'\n'é' \"c\nd\"","digests":["16b4987863387940","16b4987863387940","16b4987863387940","16b4987863387940","16b4987863387940","16b4987863387940","16b4987863387940","16b4987863387940","16b4987863387940"]}, {"id":"edge-ctx:109","input":"SELECT 'é'\n'é' \"c\nd\", z\nFROM t","digests":["ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9","ca869cc5b528f5d9"]}, -{"id":"edge:110","input":"SELECT 'a\nb', 'é€' FROM t WHERE x = 'c\\q'","digests":["1d403ae80e980d1d","1d403ae80e980d1d","1d403ae80e980d1d","1d403ae80e980d1d","a99c1a7c7ddd937c","a99c1a7c7ddd937c","a99c1a7c7ddd937c","a99c1a7c7ddd937c","a99c1a7c7ddd937c"]}, -{"id":"edge-ctx:110","input":"SELECT SELECT 'a\nb', 'é€' FROM t WHERE x = 'c\\q', z\nFROM t","digests":["7fd58d889b467ed5","7fd58d889b467ed5","7fd58d889b467ed5","7fd58d889b467ed5","63d26bf67bde0e1f","63d26bf67bde0e1f","63d26bf67bde0e1f","63d26bf67bde0e1f","63d26bf67bde0e1f"]}, +{"id":"edge:110","input":"SELECT 'a\nb', 'é€' FROM t WHERE x = 'c\\q'","digests":["a99c1a7c7ddd937c","a99c1a7c7ddd937c","817d380fc4de2989","817d380fc4de2989","a99c1a7c7ddd937c","a99c1a7c7ddd937c","a99c1a7c7ddd937c","a99c1a7c7ddd937c","a99c1a7c7ddd937c"]}, +{"id":"edge-ctx:110","input":"SELECT SELECT 'a\nb', 'é€' FROM t WHERE x = 'c\\q', z\nFROM t","digests":["63d26bf67bde0e1f","63d26bf67bde0e1f","4d235b432e31f785","4d235b432e31f785","63d26bf67bde0e1f","63d26bf67bde0e1f","63d26bf67bde0e1f","63d26bf67bde0e1f","63d26bf67bde0e1f"]}, {"id":"edge:111","input":"SELECT 'a\nb',\n 'é€𝄞', \"c\nd\" FROM t","digests":["b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9","b18dc1a9a296b7e9"]}, {"id":"edge-ctx:111","input":"SELECT SELECT 'a\nb',\n 'é€𝄞', \"c\nd\" FROM t, z\nFROM t","digests":["bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230","bd77f85ad43f2230"]}, {"id":"lit:../parser/aggregate_orderby_test.go:36","input":"SELECT STRING_AGG(name, ', ' ORDER BY name) FROM users","digests":["6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c","6bd396db3883df7c"]}, @@ -418,15 +497,15 @@ {"id":"lit:../parser/aggregate_orderby_test.go:477","input":"SELECT STRING_AGG(name, ', ' ORDER BY) FROM users","digests":["d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099","d45272d2b3c99099"]}, {"id":"lit:../parser/aggregate_orderby_test.go:482","input":"SELECT STRING_AGG(name, ', ' ORDER BY name DESC ASC) FROM users","digests":["e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75","e8322ae51a3c8d75"]}, {"id":"lit:../parser/aggregate_orderby_test.go:508","input":"\n\t\tSELECT dept,\n\t\t\t (SELECT STRING_AGG(name, ', ' ORDER BY name)\n\t\t\t FROM employees e\n\t\t\t WHERE e.dept_id = d.id) as employees\n\t\tFROM departments d\n\t","digests":["29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4","29cefe09f3723ea4"]}, -{"id":"lit:../parser/array_constructor_test.go:57","input":"SELECT ARRAY['a', 'b', 'c']","digests":["72cfa4cd25e500ca","72cfa4cd25e500ca","72cfa4cd25e500ca","72cfa4cd25e500ca","cccdde94327b512e","72cfa4cd25e500ca","72cfa4cd25e500ca","72cfa4cd25e500ca","72cfa4cd25e500ca"]}, -{"id":"lit:../parser/array_constructor_test.go:93","input":"SELECT * FROM users WHERE tags @\u003e ARRAY['admin', 'moderator']","digests":["d6c63d61b3867a82","d6c63d61b3867a82","d6c63d61b3867a82","d6c63d61b3867a82","f3276e70915d8c61","d6c63d61b3867a82","d6c63d61b3867a82","d6c63d61b3867a82","d6c63d61b3867a82"]}, -{"id":"lit:../parser/array_constructor_test.go:98","input":"SELECT * FROM users WHERE ARRAY['user'] \u003c@ roles","digests":["ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","48db6809be1a79c1","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c"]}, -{"id":"lit:../parser/array_constructor_test.go:125","input":"SELECT ARRAY[1, 2], ARRAY['a', 'b']","digests":["53aa3bcf551c34b7","53aa3bcf551c34b7","53aa3bcf551c34b7","53aa3bcf551c34b7","116eadb33055a63d","53aa3bcf551c34b7","53aa3bcf551c34b7","53aa3bcf551c34b7","53aa3bcf551c34b7"]}, -{"id":"lit:../parser/array_constructor_test.go:141","input":"SELECT * FROM users WHERE tags = ARRAY['admin']","digests":["6636632913493da9","6636632913493da9","6636632913493da9","6636632913493da9","8317ab6f93ff92f9","6636632913493da9","6636632913493da9","6636632913493da9","6636632913493da9"]}, -{"id":"lit:../parser/array_constructor_test.go:146","input":"SELECT * FROM users WHERE roles \u003c\u003e ARRAY['guest']","digests":["fd9fbc4e510be02f","fd9fbc4e510be02f","fd9fbc4e510be02f","fd9fbc4e510be02f","82f0048af7ab3911","fd9fbc4e510be02f","fd9fbc4e510be02f","fd9fbc4e510be02f","fd9fbc4e510be02f"]}, -{"id":"lit:../parser/array_constructor_test.go:156","input":"SELECT ARRAY[UPPER('a'), LOWER('B')]","digests":["b04c670b711e81d4","b04c670b711e81d4","b04c670b711e81d4","b04c670b711e81d4","ca38f104bf496c66","b04c670b711e81d4","b04c670b711e81d4","b04c670b711e81d4","b04c670b711e81d4"]}, -{"id":"lit:../parser/array_constructor_test.go:161","input":"INSERT INTO users (tags) VALUES (ARRAY['user', 'active'])","digests":["7b737851d5e234c4","7b737851d5e234c4","7b737851d5e234c4","7b737851d5e234c4","8ab98b6a46509fc8","7b737851d5e234c4","7b737851d5e234c4","7b737851d5e234c4","7b737851d5e234c4"]}, -{"id":"lit:../parser/array_constructor_test.go:166","input":"UPDATE users SET tags = ARRAY['updated']","digests":["b45fbd2addb0d884","b45fbd2addb0d884","b45fbd2addb0d884","b45fbd2addb0d884","29c67f49eb29d303","b45fbd2addb0d884","b45fbd2addb0d884","b45fbd2addb0d884","b45fbd2addb0d884"]}, +{"id":"lit:../parser/array_constructor_test.go:57","input":"SELECT ARRAY['a', 'b', 'c']","digests":["72cfa4cd25e500ca","72cfa4cd25e500ca","72cfa4cd25e500ca","72cfa4cd25e500ca","cccdde94327b512e","72cfa4cd25e500ca","cccdde94327b512e","72cfa4cd25e500ca","72cfa4cd25e500ca"]}, +{"id":"lit:../parser/array_constructor_test.go:93","input":"SELECT * FROM users WHERE tags @\u003e ARRAY['admin', 'moderator']","digests":["d6c63d61b3867a82","d6c63d61b3867a82","d6c63d61b3867a82","d6c63d61b3867a82","f3276e70915d8c61","d6c63d61b3867a82","f3276e70915d8c61","d6c63d61b3867a82","d6c63d61b3867a82"]}, +{"id":"lit:../parser/array_constructor_test.go:98","input":"SELECT * FROM users WHERE ARRAY['user'] \u003c@ roles","digests":["ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c","48db6809be1a79c1","ecfd9e7ed8a7a37c","48db6809be1a79c1","ecfd9e7ed8a7a37c","ecfd9e7ed8a7a37c"]}, +{"id":"lit:../parser/array_constructor_test.go:125","input":"SELECT ARRAY[1, 2], ARRAY['a', 'b']","digests":["53aa3bcf551c34b7","53aa3bcf551c34b7","53aa3bcf551c34b7","53aa3bcf551c34b7","116eadb33055a63d","53aa3bcf551c34b7","116eadb33055a63d","53aa3bcf551c34b7","53aa3bcf551c34b7"]}, +{"id":"lit:../parser/array_constructor_test.go:141","input":"SELECT * FROM users WHERE tags = ARRAY['admin']","digests":["6636632913493da9","6636632913493da9","6636632913493da9","6636632913493da9","8317ab6f93ff92f9","6636632913493da9","8317ab6f93ff92f9","6636632913493da9","6636632913493da9"]}, +{"id":"lit:../parser/array_constructor_test.go:146","input":"SELECT * FROM users WHERE roles \u003c\u003e ARRAY['guest']","digests":["fd9fbc4e510be02f","fd9fbc4e510be02f","fd9fbc4e510be02f","fd9fbc4e510be02f","82f0048af7ab3911","fd9fbc4e510be02f","82f0048af7ab3911","fd9fbc4e510be02f","fd9fbc4e510be02f"]}, +{"id":"lit:../parser/array_constructor_test.go:156","input":"SELECT ARRAY[UPPER('a'), LOWER('B')]","digests":["b04c670b711e81d4","b04c670b711e81d4","b04c670b711e81d4","b04c670b711e81d4","ca38f104bf496c66","b04c670b711e81d4","ca38f104bf496c66","b04c670b711e81d4","b04c670b711e81d4"]}, +{"id":"lit:../parser/array_constructor_test.go:161","input":"INSERT INTO users (tags) VALUES (ARRAY['user', 'active'])","digests":["7b737851d5e234c4","7b737851d5e234c4","7b737851d5e234c4","7b737851d5e234c4","8ab98b6a46509fc8","7b737851d5e234c4","8ab98b6a46509fc8","7b737851d5e234c4","7b737851d5e234c4"]}, +{"id":"lit:../parser/array_constructor_test.go:166","input":"UPDATE users SET tags = ARRAY['updated']","digests":["b45fbd2addb0d884","b45fbd2addb0d884","b45fbd2addb0d884","b45fbd2addb0d884","29c67f49eb29d303","b45fbd2addb0d884","29c67f49eb29d303","b45fbd2addb0d884","b45fbd2addb0d884"]}, {"id":"lit:../parser/array_subscript_test.go:76","input":"expected array name 'tags', got '%s'","digests":["238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206","238a4c5fa2f52206"]}, {"id":"lit:../parser/array_subscript_test.go:90","input":"expected index value '1', got '%v'","digests":["e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b","e5328fff17699e8b"]}, {"id":"lit:../parser/array_subscript_test.go:149","input":"expected array name 'matrix', got '%s'","digests":["161d0244c01ded02","161d0244c01ded02","161d0244c01ded02","161d0244c01ded02","161d0244c01ded02","161d0244c01ded02","161d0244c01ded02","161d0244c01ded02","161d0244c01ded02"]}, @@ -434,8 +513,8 @@ {"id":"lit:../parser/array_subscript_test.go:220","input":"expected end value '3', got '%v'","digests":["bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05","bc86b3d8a2c67c05"]}, {"id":"lit:../parser/array_subscript_test.go:273","input":"expected start value '2', got '%v'","digests":["e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9","e3ec45aca51e80a9"]}, {"id":"lit:../parser/array_subscript_test.go:336","input":"expected end value '5', got '%v'","digests":["a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca","a90fff7ebeb22eca"]}, -{"id":"lit:../parser/array_subscript_test.go:342","input":"SELECT * FROM posts WHERE tags[1] = 'tech'","digests":["b415a46d009dc3f0","b415a46d009dc3f0","b415a46d009dc3f0","b415a46d009dc3f0","b5fd83ea2d6b74ad","b415a46d009dc3f0","b415a46d009dc3f0","b415a46d009dc3f0","b415a46d009dc3f0"]}, -{"id":"lit:../parser/array_subscript_test.go:448","input":"expected array name 'arr', got '%s'","digests":["987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9"]}, +{"id":"lit:../parser/array_subscript_test.go:342","input":"SELECT * FROM posts WHERE tags[1] = 'tech'","digests":["b415a46d009dc3f0","b415a46d009dc3f0","b415a46d009dc3f0","b415a46d009dc3f0","b5fd83ea2d6b74ad","b415a46d009dc3f0","b5fd83ea2d6b74ad","b415a46d009dc3f0","b415a46d009dc3f0"]}, +{"id":"lit:../parser/array_subscript_test.go:452","input":"expected array name 'arr', got '%s'","digests":["987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9","987b4fc8a2651da9"]}, {"id":"lit:../parser/between_complex_test.go:72","input":"expected Expr name 'created_at', got '%s'","digests":["6bd872223d527462","6bd872223d527462","6bd872223d527462","6bd872223d527462","6bd872223d527462","6bd872223d527462","6bd872223d527462","6bd872223d527462","6bd872223d527462"]}, {"id":"lit:../parser/between_complex_test.go:81","input":"expected lower bound operator '-', got '%s'","digests":["1187f38c554fad33","1187f38c554fad33","1187f38c554fad33","1187f38c554fad33","1187f38c554fad33","1187f38c554fad33","1187f38c554fad33","1187f38c554fad33","1187f38c554fad33"]}, {"id":"lit:../parser/between_complex_test.go:90","input":"expected function name 'NOW', got '%s'","digests":["7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d","7d4228c18b12ad2d"]}, @@ -461,7 +540,7 @@ {"id":"lit:../parser/clause_alloc_free_test.go:178","input":"SELECT a FROM t WHERE NOT active AND name LIKE '%'","digests":["bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1","bdbdcc4ea58051a1"]}, {"id":"lit:../parser/clause_alloc_free_test.go:181","input":"SELECT * FROM t AT (TIMESTAMP =\u003e '2024-01-01')","digests":["2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d","2853a4ee618c2b0d"]}, {"id":"lit:../parser/clause_alloc_free_test.go:182","input":"SELECT * FROM t BEFORE (STATEMENT =\u003e 'abc')","digests":["efb0e8846606614f","efb0e8846606614f","efb0e8846606614f","efb0e8846606614f","efb0e8846606614f","efb0e8846606614f","efb0e8846606614f","efb0e8846606614f","efb0e8846606614f"]}, -{"id":"lit:../parser/clickhouse_array_literal_test.go:20","input":"SELECT ['a', 'b', 'c'] AS words","digests":["604e9dd5655d0548","604e9dd5655d0548","604e9dd5655d0548","604e9dd5655d0548","618d69fdcbd35dff","604e9dd5655d0548","604e9dd5655d0548","604e9dd5655d0548","604e9dd5655d0548"]}, +{"id":"lit:../parser/clickhouse_array_literal_test.go:20","input":"SELECT ['a', 'b', 'c'] AS words","digests":["604e9dd5655d0548","604e9dd5655d0548","604e9dd5655d0548","604e9dd5655d0548","618d69fdcbd35dff","604e9dd5655d0548","618d69fdcbd35dff","604e9dd5655d0548","604e9dd5655d0548"]}, {"id":"lit:../parser/clickhouse_nested_types_test.go:43","input":"CREATE TABLE t (\n\t\t\tts DateTime64(3, 'UTC')\n\t\t) ENGINE = MergeTree() ORDER BY ts","digests":["8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b","8d33c63f30a5fa9b"]}, {"id":"lit:../parser/clickhouse_nested_types_test.go:51","input":"CREATE TABLE t (\n\t\t\tid UInt64\n\t\t) ENGINE = ReplicatedMergeTree('/clickhouse/tables/t', '{replica}') ORDER BY id","digests":["abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87","abaeaf53ca589d87"]}, {"id":"lit:../parser/clickhouse_nested_types_test.go:55","input":"CREATE TABLE t (\n\t\t\tid UInt64\n\t\t) ENGINE = Distributed('cluster', 'db', 'local_t', id)","digests":["bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb","bf6db9d22ea3c2bb"]}, @@ -505,6 +584,20 @@ {"id":"lit:../parser/comparison_rhs_arithmetic_test.go:195","input":"a ILIKE b || '%'","digests":["2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563","2f65e8e3717d6563"]}, {"id":"lit:../parser/comparison_rhs_arithmetic_test.go:196","input":"(ILIKE a (|| b '%'))","digests":["d47c178b09814564","d47c178b09814564","d47c178b09814564","d47c178b09814564","d47c178b09814564","d47c178b09814564","d47c178b09814564","d47c178b09814564","d47c178b09814564"]}, {"id":"lit:../parser/comparison_rhs_arithmetic_test.go:234","input":"SELECT id, name FROM users WHERE a = 1 AND b \u003e c AND d \u003c\u003e 'x'","digests":["c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae","c05db5479e3559ae"]}, +{"id":"lit:../parser/composite_field_access_test.go:33","input":"SELECT (f(x)).\"*\" FROM t","digests":["1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a","1a273a46ebd4dc2a"]}, +{"id":"lit:../parser/composite_field_access_test.go:34","input":"SELECT (f(x)).\"Odd\"\"Field\" FROM t","digests":["a529992b70671d8d","a529992b70671d8d","a529992b70671d8d","a529992b70671d8d","a529992b70671d8d","a529992b70671d8d","a529992b70671d8d","a529992b70671d8d","a529992b70671d8d"]}, +{"id":"lit:../parser/composite_field_access_test.go:35","input":"SELECT (f(x)).\"a.b\" FROM t","digests":["d437f376066c22b2","d437f376066c22b2","d437f376066c22b2","d437f376066c22b2","d437f376066c22b2","d437f376066c22b2","d437f376066c22b2","d437f376066c22b2","d437f376066c22b2"]}, +{"id":"lit:../parser/composite_field_access_test.go:36","input":"SELECT (f(x)).\"field); DROP TABLE t; --\" FROM t","digests":["39984f286deab1d3","39984f286deab1d3","39984f286deab1d3","39984f286deab1d3","39984f286deab1d3","39984f286deab1d3","39984f286deab1d3","39984f286deab1d3","39984f286deab1d3"]}, +{"id":"lit:../parser/composite_field_access_test.go:43","input":"SELECT (a).\"order\" FROM t","digests":["d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd","d30e2f8fee39fadd"]}, +{"id":"lit:../parser/composite_field_access_test.go:44","input":"SELECT (a).\"ORDER\" FROM t","digests":["c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e","c332025b3fb2eb6e"]}, +{"id":"lit:../parser/composite_field_access_test.go:96","input":"SELECT (f(x)).'name' FROM t","digests":["6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a","6d5722ed9db73f2a"]}, +{"id":"lit:../parser/composite_field_followups_test.go:88","input":"SELECT (a).\"order\", (a).order FROM t","digests":["d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628","d6a8e761fce6c628"]}, +{"id":"lit:../parser/composite_field_followups_test.go:131","input":"SELECT (a).'order' FROM t","digests":["b56c0261ce941029","b56c0261ce941029","b56c0261ce941029","b56c0261ce941029","b56c0261ce941029","b56c0261ce941029","b56c0261ce941029","b56c0261ce941029","b56c0261ce941029"]}, +{"id":"lit:../parser/composite_field_followups_test.go:294","input":"SELECT * FROM unnest(arr) AS g(\"order\", \"Select\")","digests":["b4531697e475e726","b4531697e475e726","b4531697e475e726","b4531697e475e726","b4531697e475e726","b4531697e475e726","b4531697e475e726","b4531697e475e726","b4531697e475e726"]}, +{"id":"lit:../parser/composite_field_followups_test.go:295","input":"SELECT * FROM unnest(arr) g(\"order\", \"Select\")","digests":["0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e","0772fd83bbed9d4e"]}, +{"id":"lit:../parser/compound_keyword_case_test.go:92","input":"SELECT 'cross join' FROM t","digests":["dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004","dbddcd2ad2b1c004"]}, +{"id":"lit:../parser/compound_keyword_case_test.go:93","input":"SELECT 'CROSS JOIN' FROM t","digests":["c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a","c5041f8cd60dfe1a"]}, +{"id":"lit:../parser/compound_keyword_case_test.go:94","input":"SELECT * FROM t WHERE a = 'grouping sets'","digests":["609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff","609a26df6f59f7ff"]}, {"id":"lit:../parser/connect_by_expressions_test.go:42","input":"CONNECT BY PRIOR path || '/' = parent_path","digests":["efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281","efab1e70f9e2d281"]}, {"id":"lit:../parser/connect_by_expressions_test.go:42","input":"(= (|| (PRIOR path) '/') parent_path)","digests":["b317b481a6851616","b317b481a6851616","b317b481a6851616","b317b481a6851616","b317b481a6851616","b317b481a6851616","b317b481a6851616","b317b481a6851616","b317b481a6851616"]}, {"id":"lit:../parser/connect_by_expressions_test.go:48","input":"CONNECT BY PRIOR id = parent_id AND kind = 'prior' AND active = 1","digests":["4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185","4f6403d0a82c0185"]}, @@ -632,7 +725,7 @@ {"id":"lit:../parser/insert_multirow_test.go:388","input":"INSERT INTO users (name, email) VALUES\n ('John', 'john@test.com'),\n ('Jane', 'jane@test.com'),\n ('Bob', 'bob@test.com')","digests":["031eea505fabbeab","031eea505fabbeab","031eea505fabbeab","031eea505fabbeab","031eea505fabbeab","031eea505fabbeab","031eea505fabbeab","031eea505fabbeab","031eea505fabbeab"]}, {"id":"lit:../parser/insert_multirow_test.go:420","input":"INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@test.com'), (2, 'Bob', 'bob@test.com'), (3, 'Charlie', 'charlie@test.com')","digests":["c84a99c19d819708","c84a99c19d819708","c84a99c19d819708","c84a99c19d819708","c84a99c19d819708","c84a99c19d819708","c84a99c19d819708","c84a99c19d819708","c84a99c19d819708"]}, {"id":"lit:../parser/insert_multirow_test.go:454","input":"INSERT INTO data (id, value) VALUES (1::integer, 'text'::text), (2::integer, 'more'::text)","digests":["29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec","29f32eec1780cdec"]}, -{"id":"lit:../parser/insert_multirow_test.go:458","input":"INSERT INTO arrays (id, tags) VALUES (1, ARRAY['tag1', 'tag2']), (2, ARRAY['tag3', 'tag4'])","digests":["f7afe98c6d6bdc25","f7afe98c6d6bdc25","f7afe98c6d6bdc25","f7afe98c6d6bdc25","76095dc85867c067","f7afe98c6d6bdc25","f7afe98c6d6bdc25","f7afe98c6d6bdc25","f7afe98c6d6bdc25"]}, +{"id":"lit:../parser/insert_multirow_test.go:458","input":"INSERT INTO arrays (id, tags) VALUES (1, ARRAY['tag1', 'tag2']), (2, ARRAY['tag3', 'tag4'])","digests":["f7afe98c6d6bdc25","f7afe98c6d6bdc25","f7afe98c6d6bdc25","f7afe98c6d6bdc25","76095dc85867c067","f7afe98c6d6bdc25","76095dc85867c067","f7afe98c6d6bdc25","f7afe98c6d6bdc25"]}, {"id":"lit:../parser/insert_multirow_test.go:462","input":"INSERT INTO json_data (id, data) VALUES (1, '{\"key\": \"value1\"}'::jsonb), (2, '{\"key\": \"value2\"}'::jsonb)","digests":["9003861899e98fec","9003861899e98fec","9003861899e98fec","9003861899e98fec","9003861899e98fec","9003861899e98fec","9003861899e98fec","9003861899e98fec","9003861899e98fec"]}, {"id":"lit:../parser/integration_test.go:30","input":"INTEGRATION TEST: Documents current parser limitations with real-world SQL (24.44% pass rate). Run with 'go test -v' to see detailed results. Test will auto-pass when parser reaches 90%+ success rate.","digests":["ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496","ebcde502b55f9496"]}, {"id":"lit:../parser/interval_test.go:29","input":"SELECT INTERVAL '1 day'","digests":["0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4","0d389f63d2a399c4"]}, @@ -667,7 +760,7 @@ {"id":"lit:../parser/json_operators_test.go:122","input":"SELECT * FROM users WHERE data @\u003e '{\"key\": \"value\"}'","digests":["4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e","4ac6f2d39c79a40e"]}, {"id":"lit:../parser/json_operators_test.go:127","input":"SELECT * FROM users WHERE data \u003c@ '{\"key\": \"value\"}'","digests":["cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9","cde5d8b8c4a52fa9"]}, {"id":"lit:../parser/json_operators_test.go:132","input":"SELECT data #\u003e '{a,b}' FROM users","digests":["14993b70dce5cc47","14993b70dce5cc47","14993b70dce5cc47","14993b70dce5cc47","fd4fb13577296f00","14993b70dce5cc47","14993b70dce5cc47","14993b70dce5cc47","14993b70dce5cc47"]}, -{"id":"lit:../parser/json_operators_test.go:137","input":"SELECT data #\u003e\u003e '{a,b}' FROM users","digests":["0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff","a7b25db73bee0f70","0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff"]}, +{"id":"lit:../parser/json_operators_test.go:137","input":"SELECT data #\u003e\u003e '{a,b}' FROM users","digests":["0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff","39a851bd3862c744","0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff","0128f307fd5b59ff"]}, {"id":"lit:../parser/json_operators_test.go:142","input":"SELECT data #- '{a,b}' FROM users","digests":["cee24abb45813009","cee24abb45813009","cee24abb45813009","cee24abb45813009","d0852d6f46166130","cee24abb45813009","cee24abb45813009","cee24abb45813009","cee24abb45813009"]}, {"id":"lit:../parser/json_operators_test.go:147","input":"SELECT data -\u003e\u003e 'name' FROM users","digests":["408dba563314a725","408dba563314a725","408dba563314a725","408dba563314a725","408dba563314a725","408dba563314a725","408dba563314a725","408dba563314a725","408dba563314a725"]}, {"id":"lit:../parser/json_operators_test.go:152","input":"SELECT data -\u003e 'a' -\u003e 'b' -\u003e\u003e 'c' FROM users","digests":["d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5","d536ac559a4665f5"]}, @@ -796,7 +889,7 @@ {"id":"lit:../parser/oracle_test.go:96","input":"SELECT SYS_CONNECT_BY_PATH(name, '/') AS path FROM employees START WITH manager_id IS NULL CONNECT BY PRIOR id = manager_id","digests":["5e83a20262941901","5e83a20262941901","5e83a20262941901","5e83a20262941901","5e83a20262941901","5e83a20262941901","5e83a20262941901","5e83a20262941901","5e83a20262941901"]}, {"id":"lit:../parser/oracle_test.go:102","input":"SELECT * FROM (\n\t\tSELECT product, region, sales FROM sales_data\n\t) PIVOT (\n\t\tSUM(sales) FOR region IN ('North' AS north, 'South' AS south, 'East' AS east, 'West' AS west)\n\t)","digests":["2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561","2ffaef1c20e51561"]}, {"id":"lit:../parser/oracle_test.go:118","input":"SELECT product, region, sales FROM regional_sales\n\tUNPIVOT (\n\t\tsales FOR region IN (north_sales AS 'North', south_sales AS 'South', east_sales AS 'East', west_sales AS 'West')\n\t)","digests":["20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543","20c0df485b8f0543"]}, -{"id":"lit:../parser/oracle_test.go:135","input":"SELECT name, email FROM users WHERE REGEXP_LIKE(email, '^\\w+@[\\w.]+\\.\\w+$')","digests":["1365f96b7407e952","1365f96b7407e952","1365f96b7407e952","1365f96b7407e952","e164b91b2035d218","e164b91b2035d218","e164b91b2035d218","e164b91b2035d218","e164b91b2035d218"]}, +{"id":"lit:../parser/oracle_test.go:135","input":"SELECT name, email FROM users WHERE REGEXP_LIKE(email, '^\\w+@[\\w.]+\\.\\w+$')","digests":["e164b91b2035d218","e164b91b2035d218","7831fe729ddf3a52","7831fe729ddf3a52","e164b91b2035d218","e164b91b2035d218","e164b91b2035d218","e164b91b2035d218","e164b91b2035d218"]}, {"id":"lit:../parser/oracle_test.go:152","input":"SELECT NVL(name, 'Unknown') FROM users","digests":["b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba","b6addf01bda62bba"]}, {"id":"lit:../parser/oracle_test.go:156","input":"SELECT DECODE(status, 'A', 'Active', 'I', 'Inactive', 'Unknown') FROM users","digests":["85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6","85ba5b33bc23f3c6"]}, {"id":"lit:../parser/parenthesized_expression_test.go:35","input":"SELECT * FROM t WHERE (category = 'si' OR category = 'eev') AND snapshot_date = today()","digests":["c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e","c4f8a0b0ffb84b5e"]}, @@ -895,7 +988,7 @@ {"id":"lit:../parser/postgresql_integration_test.go:144","input":"SELECT data -\u003e\u003e 'email' FROM users","digests":["22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae","22189c7cb75bdbae"]}, {"id":"lit:../parser/postgresql_integration_test.go:148","input":"SELECT data -\u003e 'user' -\u003e 'profile' -\u003e\u003e 'email' FROM users","digests":["8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3","8c2548d7d9e4c6e3"]}, {"id":"lit:../parser/postgresql_integration_test.go:152","input":"SELECT * FROM users WHERE data @\u003e '{\"active\": true}'","digests":["6d4574ecee493455","6d4574ecee493455","6d4574ecee493455","6d4574ecee493455","6d4574ecee493455","6d4574ecee493455","6d4574ecee493455","6d4574ecee493455","6d4574ecee493455"]}, -{"id":"lit:../parser/postgresql_integration_test.go:156","input":"SELECT data #\u003e '{a,b,c}', data #\u003e\u003e '{x,y}' FROM items","digests":["1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b","af99f26ea31ba9a8","1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b"]}, +{"id":"lit:../parser/postgresql_integration_test.go:156","input":"SELECT data #\u003e '{a,b,c}', data #\u003e\u003e '{x,y}' FROM items","digests":["1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b","21c07d80cab40941","1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b","1a7f33f49882ad1b"]}, {"id":"lit:../parser/postgresql_integration_test.go:160","input":"SELECT * FROM users WHERE data @\u003e '{\"email\": true}' AND data \u003c@ '{\"admin\": false}'","digests":["c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7","c0c2a40f237818c7"]}, {"id":"lit:../parser/postgresql_integration_test.go:164","input":"SELECT\n\t\t\t\tu.data -\u003e 'profile' -\u003e\u003e 'name',\n\t\t\t\tu.data #\u003e '{settings,notifications}',\n\t\t\t\tCOUNT(*) FILTER (WHERE u.data @\u003e '{\"premium\": true}')\n\t\t\tFROM users u\n\t\t\tWHERE u.data -\u003e\u003e 'email' IS NOT NULL\n\t\t\tAND u.data -\u003e 'metadata' @\u003e '{\"verified\": true}'","digests":["ae919ce5aea14343","ae919ce5aea14343","ae919ce5aea14343","ae919ce5aea14343","65fb5afa9ed177d0","ae919ce5aea14343","ae919ce5aea14343","ae919ce5aea14343","ae919ce5aea14343"]}, {"id":"lit:../parser/postgresql_integration_test.go:236","input":"SELECT\n\t\tSTRING_AGG(name, ', ' ORDER BY name DESC NULLS LAST) AS names,\n\t\tARRAY_AGG(value ORDER BY created_at, priority DESC) AS value_list,\n\t\tJSON_AGG(data ORDER BY timestamp) AS json_data\n\tFROM items\n\tGROUP BY category","digests":["8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c","8e83c5ef65eae59c"]}, @@ -937,7 +1030,7 @@ {"id":"lit:../parser/snowflake_qa_test.go:63","input":"SELECT value FROM TABLE(FLATTEN(input =\u003e parse_json('[1,2,3]')))","digests":["e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2","e4522a69d75b23c2"]}, {"id":"lit:../parser/snowflake_qa_test.go:72","input":"SELECT OBJECT_CONSTRUCT('a', 1, 'b', 2) FROM t","digests":["f6cc84893f481132","f6cc84893f481132","f6cc84893f481132","f6cc84893f481132","f6cc84893f481132","f6cc84893f481132","f6cc84893f481132","f6cc84893f481132","f6cc84893f481132"]}, {"id":"lit:../parser/snowflake_qa_test.go:73","input":"SELECT OBJECT_CONSTRUCT_KEEP_NULL('a', NULL) FROM t","digests":["2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0","2f16cc8ff01a9ca0"]}, -{"id":"lit:../parser/snowflake_qa_test.go:74","input":"SELECT PARSE_JSON('{\"a\":1}'):a::int FROM t","digests":["df4d119a452fe596","df4d119a452fe596","df4d119a452fe596","df4d119a452fe596","df4d119a452fe596","7be0bf46fd82463e","df4d119a452fe596","df4d119a452fe596","df4d119a452fe596"]}, +{"id":"lit:../parser/snowflake_qa_test.go:74","input":"SELECT PARSE_JSON('{\"a\":1}'):a::int FROM t","digests":["df4d119a452fe596","df4d119a452fe596","df4d119a452fe596","df4d119a452fe596","df4d119a452fe596","7be0bf46fd82463e","7be0bf46fd82463e","df4d119a452fe596","df4d119a452fe596"]}, {"id":"lit:../parser/snowflake_qa_test.go:76","input":"SELECT GET_PATH(col, 'a.b.c') FROM t","digests":["faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa","faa895ddc24441aa"]}, {"id":"lit:../parser/snowflake_qa_test.go:84","input":"SELECT * FROM t WHERE name ILIKE '%foo%'","digests":["1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f","1512f2c60dedd94f"]}, {"id":"lit:../parser/snowflake_qa_test.go:85","input":"SELECT * FROM t WHERE name ILIKE ANY ('%foo%', '%bar%')","digests":["74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837","74976bdde2ce9837"]}, @@ -963,7 +1056,7 @@ {"id":"lit:../parser/snowflake_time_travel_test.go:24","input":"SELECT * FROM users CHANGES (INFORMATION =\u003e DEFAULT) AT (TIMESTAMP =\u003e '2024-01-01'::TIMESTAMP)","digests":["74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75","74138ff437b6bf75"]}, {"id":"lit:../parser/snowflake_time_travel_test.go:25","input":"SELECT t.id FROM users AT (TIMESTAMP =\u003e '2024-01-01'::TIMESTAMP) t WHERE t.id = 1","digests":["70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f","70476418f7b3e66f"]}, {"id":"lit:../parser/snowflake_variant_path_test.go:27","input":"SELECT col:\"weird key\" FROM t","digests":["1198402422f1742b","1198402422f1742b","1198402422f1742b","1198402422f1742b","1198402422f1742b","1198402422f1742b","1198402422f1742b","1198402422f1742b","1198402422f1742b"]}, -{"id":"lit:../parser/snowflake_variant_path_test.go:28","input":"SELECT id FROM t WHERE payload:status::string = 'active'","digests":["e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a","71055b35e8784196","e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a"]}, +{"id":"lit:../parser/snowflake_variant_path_test.go:28","input":"SELECT id FROM t WHERE payload:status::string = 'active'","digests":["e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a","e904fcd5aa05483a","71055b35e8784196","71055b35e8784196","e904fcd5aa05483a","e904fcd5aa05483a"]}, {"id":"lit:../parser/sustained_load_test.go:248","input":"INSERT INTO users (name, email) VALUES ('John Doe', 'john@example.com')","digests":["995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e","995bc33f3f25437e"]}, {"id":"lit:../parser/sustained_load_test.go:249","input":"UPDATE users SET email = 'newemail@example.com' WHERE id = 1","digests":["146cd07b3e583489","146cd07b3e583489","146cd07b3e583489","146cd07b3e583489","146cd07b3e583489","146cd07b3e583489","146cd07b3e583489","146cd07b3e583489","146cd07b3e583489"]}, {"id":"lit:../parser/sustained_load_test.go:581","input":"SELECT\n\t\t\tdept,\n\t\t\tname,\n\t\t\tsalary,\n\t\t\tROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) as dept_rank,\n\t\t\tRANK() OVER (ORDER BY salary DESC) as global_rank,\n\t\t\tLAG(salary, 1) OVER (PARTITION BY dept ORDER BY salary DESC) as prev_salary,\n\t\t\tAVG(salary) OVER (PARTITION BY dept) as dept_avg_salary\n\t\tFROM employees\n\t\tWHERE hire_date \u003e '2020-01-01'","digests":["3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12","3785e1c09c48fb12"]}, @@ -994,7 +1087,7 @@ {"id":"lit:../parser/tuple_in_test.go:95","input":"SELECT * FROM orders WHERE (user_id, status) NOT IN ((1, 'active'), (2, 'pending'))","digests":["81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd","81f9e7967794f5bd"]}, {"id":"lit:../parser/tuple_in_test.go:248","input":"SELECT * FROM users WHERE (LOWER(name), UPPER(status)) IN (('john', 'ACTIVE'), ('jane', 'PENDING'))","digests":["53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f","53b1f96cd287763f"]}, {"id":"lit:../parser/tuple_in_test.go:387","input":"SELECT * FROM orders o WHERE (o.user_id, o.status) IN ((1, 'active'), (2, 'pending'))","digests":["5b039928c63f536f","5b039928c63f536f","5b039928c63f536f","5b039928c63f536f","5b039928c63f536f","5b039928c63f536f","5b039928c63f536f","5b039928c63f536f","5b039928c63f536f"]}, -{"id":"lit:../parser/tuple_in_test.go:434","input":"expected element[%d] to have table qualifier 'o', got '%s'","digests":["fc131af3dcf0d007","fc131af3dcf0d007","fc131af3dcf0d007","fc131af3dcf0d007","657ca9ed6ebf2f0f","fc131af3dcf0d007","fc131af3dcf0d007","fc131af3dcf0d007","fc131af3dcf0d007"]}, +{"id":"lit:../parser/tuple_in_test.go:434","input":"expected element[%d] to have table qualifier 'o', got '%s'","digests":["fc131af3dcf0d007","fc131af3dcf0d007","fc131af3dcf0d007","fc131af3dcf0d007","657ca9ed6ebf2f0f","fc131af3dcf0d007","657ca9ed6ebf2f0f","fc131af3dcf0d007","fc131af3dcf0d007"]}, {"id":"lit:../parser/tuple_in_test.go:441","input":"SELECT * FROM orders WHERE (user_id, status) IN ((1, 'active'), (2, 'pending')) AND created_at \u003e '2024-01-01'","digests":["7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027","7ddffbf094d5a027"]}, {"id":"lit:../parser/tuple_in_test.go:491","input":"SELECT * FROM t WHERE (id, name, active, score) IN ((1, 'john', TRUE, 95.5), (2, 'jane', FALSE, 87.3))","digests":["aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d","aa325fe21e07af8d"]}, {"id":"lit:../parser/type_casting_test.go:35","input":"SELECT '123'::INTEGER","digests":["9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015","9ed513a629cf7015"]}, @@ -1033,7 +1126,7 @@ {"id":"lit:../parser/window_functions_test.go:294","input":"Expected function name '%s', got '%s'","digests":["c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97","c1333fa74129ec97"]}, {"id":"lit:../parser/window_functions_test.go:479","input":"Expected start bound 'UNBOUNDED PRECEDING', got '%s'","digests":["cc7154414054099c","cc7154414054099c","cc7154414054099c","cc7154414054099c","cc7154414054099c","cc7154414054099c","cc7154414054099c","cc7154414054099c","cc7154414054099c"]}, {"id":"lit:../parser/window_named_test.go:94","input":"SELECT sum(x) OVER (\"w\") FROM t WINDOW w AS (PARTITION BY y)","digests":["9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01","9e82675d4edd1e01"]}, -{"id":"lit:../parser/window_named_test.go:118","input":"Windows[0] Name=%q BaseWindow=%q, want w1/\"\"","digests":["e40921b0bce7f7e5","e40921b0bce7f7e5","e40921b0bce7f7e5","e40921b0bce7f7e5","6dd74e7ac4b452e0","e40921b0bce7f7e5","e40921b0bce7f7e5","e40921b0bce7f7e5","e40921b0bce7f7e5"]}, +{"id":"lit:../parser/window_named_test.go:118","input":"Windows[0] Name=%q BaseWindow=%q, want w1/\"\"","digests":["e40921b0bce7f7e5","e40921b0bce7f7e5","e40921b0bce7f7e5","e40921b0bce7f7e5","6dd74e7ac4b452e0","e40921b0bce7f7e5","6dd74e7ac4b452e0","e40921b0bce7f7e5","e40921b0bce7f7e5"]}, {"id":"lit:../parser/within_group_test.go:51","input":"Expected function name 'PERCENTILE_CONT', got '%s'","digests":["4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34","4e859af82ba7fe34"]}, {"id":"lit:../parser/within_group_test.go:66","input":"Expected function name 'PERCENTILE_DISC', got '%s'","digests":["f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14","f1050833e2ebeb14"]}, {"id":"lit:../parser/within_group_test.go:84","input":"Expected function name 'MODE', got '%s'","digests":["8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c","8446a91dce4eaf1c"]}, @@ -1101,35 +1194,66 @@ {"id":"lit:error_location_test.go:57","input":"SELECT 1\nFROM t\nWHERE\t\"a\nb\"","digests":["057578b0e39ca247","057578b0e39ca247","057578b0e39ca247","057578b0e39ca247","057578b0e39ca247","057578b0e39ca247","057578b0e39ca247","057578b0e39ca247","057578b0e39ca247"]}, {"id":"lit:error_location_test.go:61","input":"SELECT 'é€',\n\t$tag$body\nmore","digests":["da4b270b68582658","da4b270b68582658","da4b270b68582658","da4b270b68582658","da4b270b68582658","da4b270b68582658","da4b270b68582658","da4b270b68582658","da4b270b68582658"]}, {"id":"lit:error_location_test.go:62","input":"SELECT 'é€𝄞' || $$abc","digests":["50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7","50ec2c14476feda7"]}, -{"id":"lit:error_location_test.go:66","input":"SELECT N'é€',\n\t\t[abc def","digests":["902a75fa6321220d","902a75fa6321220d","902a75fa6321220d","902a75fa6321220d","ec6d7a4d8c5493f7","902a75fa6321220d","902a75fa6321220d","902a75fa6321220d","902a75fa6321220d"]}, -{"id":"lit:error_location_test.go:69","input":"SELECT\t'é\\q'","digests":["a764b5de5e251c76","a764b5de5e251c76","a764b5de5e251c76","a764b5de5e251c76","eab443a4d9c7932a","eab443a4d9c7932a","eab443a4d9c7932a","eab443a4d9c7932a","eab443a4d9c7932a"]}, -{"id":"lit:error_location_test.go:69","input":"q'","digests":["f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e","f3aa96ae5440e50e"]}, -{"id":"lit:error_location_test.go:70","input":"SELECT 'a\nb€',\n\t'x\\q'","digests":["b9a16ff6aaf6e48c","b9a16ff6aaf6e48c","b9a16ff6aaf6e48c","b9a16ff6aaf6e48c","f03acf1c138b7de5","f03acf1c138b7de5","f03acf1c138b7de5","f03acf1c138b7de5","f03acf1c138b7de5"]}, -{"id":"lit:error_location_test.go:73","input":"SELECT\t'é',\n\t'abc","digests":["2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e"]}, -{"id":"lit:error_location_test.go:74","input":"SELECT 'é€'\t/* never closed","digests":["0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a"]}, -{"id":"lit:error_location_test.go:75","input":"SELECT\t`a","digests":["97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc"]}, -{"id":"lit:error_location_test.go:76","input":"SELECT\t'''abc","digests":["695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0"]}, -{"id":"lit:error_location_test.go:123","input":"é\t'abc","digests":["ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd"]}, -{"id":"lit:error_location_test.go:124","input":"x\n\t'ab\ncd'","digests":["6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca"]}, -{"id":"lit:error_location_test.go:125","input":"\t'é\\q'","digests":["3952d0f0c7c51ec1","3952d0f0c7c51ec1","3952d0f0c7c51ec1","3952d0f0c7c51ec1","21532fa95a2b2677","21532fa95a2b2677","21532fa95a2b2677","21532fa95a2b2677","21532fa95a2b2677"]}, +{"id":"lit:error_location_test.go:66","input":"SELECT N'é€',\n\t\t[abc def","digests":["902a75fa6321220d","82b552ebf5ce57c3","82b552ebf5ce57c3","82b552ebf5ce57c3","ec6d7a4d8c5493f7","82b552ebf5ce57c3","ec6d7a4d8c5493f7","902a75fa6321220d","902a75fa6321220d"]}, +{"id":"lit:error_location_test.go:70","input":"SELECT\t'é\\'","digests":["3d2678ef017023a1","dcdfdd3bd5bc3020","3d2678ef017023a1","3d2678ef017023a1","dcdfdd3bd5bc3020","dcdfdd3bd5bc3020","dcdfdd3bd5bc3020","3d2678ef017023a1","3d2678ef017023a1"]}, +{"id":"lit:error_location_test.go:70","input":"'é","digests":["25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13","25dac1f7fd462d13"]}, +{"id":"lit:error_location_test.go:71","input":"SELECT 'a\nb€',\n\t'x\\'","digests":["3972f55d1602c617","f0f632d65e933f1e","3972f55d1602c617","3972f55d1602c617","f0f632d65e933f1e","f0f632d65e933f1e","f0f632d65e933f1e","3972f55d1602c617","3972f55d1602c617"]}, +{"id":"lit:error_location_test.go:71","input":"'x","digests":["53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073","53cfc19c1dffb073"]}, +{"id":"lit:error_location_test.go:74","input":"SELECT\t'é',\n\t'abc","digests":["2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e","2a4ab8f3bd8eda6e"]}, +{"id":"lit:error_location_test.go:75","input":"SELECT 'é€'\t/* never closed","digests":["0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a","0029d62cda63316a"]}, +{"id":"lit:error_location_test.go:76","input":"SELECT\t`a","digests":["97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc","97bc9bacbaebdefc"]}, +{"id":"lit:error_location_test.go:77","input":"SELECT\t'''abc","digests":["695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0","695cfa0ce77b94a0"]}, +{"id":"lit:error_location_test.go:124","input":"é\t'abc","digests":["ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd","ea77e24b908a07cd"]}, +{"id":"lit:error_location_test.go:125","input":"x\n\t'ab\ncd'","digests":["6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca","6d0c22165da41bca"]}, +{"id":"lit:error_location_test.go:126","input":"\t'é\\q'","digests":["21532fa95a2b2677","21532fa95a2b2677","a06c15c2be83ed43","a06c15c2be83ed43","21532fa95a2b2677","21532fa95a2b2677","21532fa95a2b2677","21532fa95a2b2677","21532fa95a2b2677"]}, {"id":"lit:json_operators_test.go:34","input":"data -\u003e 'key'","digests":["6852d2308725aa53","6852d2308725aa53","6852d2308725aa53","6852d2308725aa53","6852d2308725aa53","6852d2308725aa53","6852d2308725aa53","6852d2308725aa53","6852d2308725aa53"]}, {"id":"lit:json_operators_test.go:43","input":"data -\u003e\u003e 'key'","digests":["96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22","96f6be85c8493e22"]}, {"id":"lit:json_operators_test.go:52","input":"data -\u003e 'a' -\u003e\u003e 'b'","digests":["2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b","2b66f28a031efb5b"]}, {"id":"lit:json_operators_test.go:107","input":"data #\u003e '{a,b}'","digests":["fd85763161106732","fd85763161106732","fd85763161106732","fd85763161106732","33293a17420a6803","fd85763161106732","fd85763161106732","fd85763161106732","fd85763161106732"]}, -{"id":"lit:json_operators_test.go:116","input":"data #\u003e\u003e '{a,b}'","digests":["9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee","5511c3acecba7bf2","9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee"]}, +{"id":"lit:json_operators_test.go:116","input":"data #\u003e\u003e '{a,b}'","digests":["9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee","bf2f84ded1228966","9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee","9aed771ec20ff2ee"]}, {"id":"lit:json_operators_test.go:125","input":"data #- '{a,b}'","digests":["9466d9c80eafc998","9466d9c80eafc998","9466d9c80eafc998","9466d9c80eafc998","a27cc54fe9c230a7","9466d9c80eafc998","9466d9c80eafc998","9466d9c80eafc998","9466d9c80eafc998"]}, {"id":"lit:json_operators_test.go:169","input":"data @\u003e '{\"key\": \"value\"}'","digests":["43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1","43c92ed6e69151e1"]}, {"id":"lit:json_operators_test.go:178","input":"data \u003c@ '{\"key\": \"value\"}'","digests":["6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9","6ed8a88b96ba93f9"]}, {"id":"lit:json_operators_test.go:222","input":"data ? 'key'","digests":["b32aa3984926352c","b32aa3984926352c","cc5d5c32080fbec3","cc5d5c32080fbec3","b32aa3984926352c","b32aa3984926352c","cc5d5c32080fbec3","b32aa3984926352c","b32aa3984926352c"]}, -{"id":"lit:json_operators_test.go:231","input":"data ?| ARRAY['a','b']","digests":["66a3d52784fb4b65","66a3d52784fb4b65","84db0235d62576f7","84db0235d62576f7","cd8db73845182076","66a3d52784fb4b65","84db0235d62576f7","66a3d52784fb4b65","66a3d52784fb4b65"]}, -{"id":"lit:json_operators_test.go:245","input":"data ?\u0026 ARRAY['a','b']","digests":["42e3a2aa88c4cef1","42e3a2aa88c4cef1","d0371a3986066e4f","d0371a3986066e4f","98cace8fb08c1492","42e3a2aa88c4cef1","d0371a3986066e4f","42e3a2aa88c4cef1","42e3a2aa88c4cef1"]}, +{"id":"lit:json_operators_test.go:231","input":"data ?| ARRAY['a','b']","digests":["66a3d52784fb4b65","66a3d52784fb4b65","84db0235d62576f7","84db0235d62576f7","cd8db73845182076","66a3d52784fb4b65","3795ca656ef6fab8","66a3d52784fb4b65","66a3d52784fb4b65"]}, +{"id":"lit:json_operators_test.go:245","input":"data ?\u0026 ARRAY['a','b']","digests":["42e3a2aa88c4cef1","42e3a2aa88c4cef1","d0371a3986066e4f","d0371a3986066e4f","98cace8fb08c1492","42e3a2aa88c4cef1","5f755c16b45fbe2e","42e3a2aa88c4cef1","42e3a2aa88c4cef1"]}, {"id":"lit:json_operators_test.go:294","input":"data-\u003e'key'","digests":["69e9611146bba20b","69e9611146bba20b","69e9611146bba20b","69e9611146bba20b","69e9611146bba20b","69e9611146bba20b","69e9611146bba20b","69e9611146bba20b","69e9611146bba20b"]}, {"id":"lit:json_operators_test.go:303","input":"data-\u003e\u003e'key'","digests":["ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba","ca334b7550a966ba"]}, -{"id":"lit:json_operators_test.go:312","input":"data#\u003e'{a}'","digests":["a2b069362161c14d","a2b069362161c14d","a2b069362161c14d","a2b069362161c14d","dc2b282eb6ebb6cf","a2b069362161c14d","a2b069362161c14d","a2b069362161c14d","a2b069362161c14d"]}, +{"id":"lit:json_operators_test.go:312","input":"data#\u003e'{a}'","digests":["a2b069362161c14d","a2b069362161c14d","a2b069362161c14d","a2b069362161c14d","dc2b282eb6ebb6cf","348369f3352540e3","a2b069362161c14d","a2b069362161c14d","a2b069362161c14d"]}, {"id":"lit:json_operators_test.go:424","input":"SELECT data -\u003e 'name', data -\u003e\u003e 'age' FROM users","digests":["5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38","5bd2d6f2d34e9d38"]}, -{"id":"lit:json_operators_test.go:439","input":"SELECT data #\u003e '{address,city}', data #\u003e\u003e '{address,zipcode}' FROM users","digests":["4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884","a09caf9ed66b73e3","4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884"]}, -{"id":"lit:json_operators_test.go:444","input":"SELECT * FROM users WHERE data ? 'email' AND data ?| array['phone', 'mobile']","digests":["89a5ffa7d987d1a2","89a5ffa7d987d1a2","609dfbf8c3b3324e","609dfbf8c3b3324e","95e619bf3290b6af","89a5ffa7d987d1a2","609dfbf8c3b3324e","89a5ffa7d987d1a2","89a5ffa7d987d1a2"]}, +{"id":"lit:json_operators_test.go:439","input":"SELECT data #\u003e '{address,city}', data #\u003e\u003e '{address,zipcode}' FROM users","digests":["4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884","8776daaac0dcbbd0","4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884","4e8e17bff9f98884"]}, +{"id":"lit:json_operators_test.go:444","input":"SELECT * FROM users WHERE data ? 'email' AND data ?| array['phone', 'mobile']","digests":["89a5ffa7d987d1a2","89a5ffa7d987d1a2","609dfbf8c3b3324e","609dfbf8c3b3324e","95e619bf3290b6af","89a5ffa7d987d1a2","a5c7293fe08dc4f8","89a5ffa7d987d1a2","89a5ffa7d987d1a2"]}, {"id":"lit:json_operators_test.go:449","input":"SELECT data -\u003e 'user' -\u003e 'profile' -\u003e\u003e 'name' FROM users","digests":["f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6","f62275c1edf674e6"]}, +{"id":"lit:lexical_extensions_test.go:184","input":"{d: DateTime64(3, 'UTC')}","digests":["0c3606041189cc1b","0c3606041189cc1b","0c3606041189cc1b","0c3606041189cc1b","0c3606041189cc1b","0c3606041189cc1b","0c3606041189cc1b","0c3606041189cc1b","b82c82d7fe1bac6e"]}, +{"id":"lit:lexical_extensions_test.go:187","input":"{'a':1}","digests":["ae865e2aabbf3db7","ae865e2aabbf3db7","ae865e2aabbf3db7","ae865e2aabbf3db7","ae865e2aabbf3db7","3c4976cb472dc1dc","3c4976cb472dc1dc","ae865e2aabbf3db7","ae865e2aabbf3db7"]}, +{"id":"lit:lexical_extensions_test.go:213","input":"X'4D'","digests":["7c5cac810a27e934","677f9d80a22903d5","677f9d80a22903d5","677f9d80a22903d5","7c5cac810a27e934","7c5cac810a27e934","677f9d80a22903d5","677f9d80a22903d5","7c5cac810a27e934"]}, +{"id":"lit:lexical_extensions_test.go:214","input":"x'CAFE'","digests":["7b56d990b45cbf22","7f74e079fc2d6b08","7f74e079fc2d6b08","7f74e079fc2d6b08","7b56d990b45cbf22","7b56d990b45cbf22","7f74e079fc2d6b08","7f74e079fc2d6b08","7b56d990b45cbf22"]}, +{"id":"lit:lexical_extensions_test.go:215","input":"b'101'","digests":["4db63c1485899458","156a558cc32d8f58","156a558cc32d8f58","156a558cc32d8f58","4db63c1485899458","4db63c1485899458","4db63c1485899458","4db63c1485899458","4db63c1485899458"]}, +{"id":"lit:lexical_extensions_test.go:216","input":"B'101'","digests":["ca47a45cc2d78cdb","156a558cc32d8f58","156a558cc32d8f58","156a558cc32d8f58","ca47a45cc2d78cdb","ca47a45cc2d78cdb","ca47a45cc2d78cdb","ca47a45cc2d78cdb","ca47a45cc2d78cdb"]}, +{"id":"lit:lexical_extensions_test.go:217","input":"N'x'","digests":["439d97d3fe555d49","92cd5a30ac549142","92cd5a30ac549142","92cd5a30ac549142","92cd5a30ac549142","92cd5a30ac549142","439d97d3fe555d49","439d97d3fe555d49","439d97d3fe555d49"]}, +{"id":"lit:lexical_extensions_test.go:220","input":"E'tab\\there'","digests":["1262263742a2f174","85745e0022875899","1262263742a2f174","1262263742a2f174","0f353557d9261801","0f353557d9261801","0f353557d9261801","1262263742a2f174","1262263742a2f174"]}, +{"id":"lit:lexical_extensions_test.go:221","input":"E'it\\'s \\\\ ok'","digests":["2800b003cd54caad","7ee568f04db358a0","2800b003cd54caad","2800b003cd54caad","3a1a0cdea78ef011","3a1a0cdea78ef011","3a1a0cdea78ef011","2800b003cd54caad","2800b003cd54caad"]}, +{"id":"lit:lexical_extensions_test.go:221","input":"it's \\ ok","digests":["4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00","4f3f9de0fbf83b00"]}, +{"id":"lit:lexical_extensions_test.go:222","input":"e'a''b'","digests":["c44e4d65f931befe","3fdafd9716944426","c44e4d65f931befe","c44e4d65f931befe","c44e4d65f931befe","c44e4d65f931befe","c44e4d65f931befe","c44e4d65f931befe","c44e4d65f931befe"]}, +{"id":"lit:lexical_extensions_test.go:222","input":"a'b","digests":["21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350"]}, +{"id":"lit:lexical_extensions_test.go:223","input":"E'\\101\\x42C\\U00000044'","digests":["a5588ede6f3af506","d3979de77dfa01cd","fb01131c357c34b9","fb01131c357c34b9","a5588ede6f3af506","a5588ede6f3af506","a5588ede6f3af506","a5588ede6f3af506","a5588ede6f3af506"]}, +{"id":"lit:lexical_extensions_test.go:224","input":"E'\\q'","digests":["17ca55eb34174a47","86cbcac7720df8c7","7de1f47f99233ea7","7de1f47f99233ea7","17ca55eb34174a47","17ca55eb34174a47","17ca55eb34174a47","17ca55eb34174a47","17ca55eb34174a47"]}, +{"id":"lit:lexical_extensions_test.go:225","input":"E'é\\n世'","digests":["fb1095a0fbdd291c","62d20ee798229b9b","fb1095a0fbdd291c","fb1095a0fbdd291c","a832e546125037a9","a832e546125037a9","a832e546125037a9","fb1095a0fbdd291c","fb1095a0fbdd291c"]}, +{"id":"lit:lexical_extensions_test.go:226","input":"q'[It's quoted]'","digests":["b2ced3c8bbc9710f","b2ced3c8bbc9710f","b2ced3c8bbc9710f","b2ced3c8bbc9710f","b2ced3c8bbc9710f","ed4a29705009d54c","b2ced3c8bbc9710f","b2ced3c8bbc9710f","b2ced3c8bbc9710f"]}, +{"id":"lit:lexical_extensions_test.go:226","input":"It's quoted","digests":["fac8773ac2912263","fac8773ac2912263","fac8773ac2912263","fac8773ac2912263","fac8773ac2912263","fac8773ac2912263","fac8773ac2912263","fac8773ac2912263","fac8773ac2912263"]}, +{"id":"lit:lexical_extensions_test.go:227","input":"q'{a}'","digests":["3565b2c4714db467","3565b2c4714db467","3565b2c4714db467","3565b2c4714db467","3565b2c4714db467","5a094b40aca1a4a2","3565b2c4714db467","3565b2c4714db467","3565b2c4714db467"]}, +{"id":"lit:lexical_extensions_test.go:228","input":"Q'(b)'","digests":["d4abc8e205eb02f8","d4abc8e205eb02f8","d4abc8e205eb02f8","d4abc8e205eb02f8","d4abc8e205eb02f8","dd8837bbc714869e","d4abc8e205eb02f8","d4abc8e205eb02f8","d4abc8e205eb02f8"]}, +{"id":"lit:lexical_extensions_test.go:229","input":"q'\u003cc\u003e'","digests":["1924857024d94985","1924857024d94985","1924857024d94985","1924857024d94985","1924857024d94985","b2a6975d9ce6ab67","1924857024d94985","1924857024d94985","1924857024d94985"]}, +{"id":"lit:lexical_extensions_test.go:230","input":"q'!d]'e!'","digests":["6a35b70ccb9eecf9","6a35b70ccb9eecf9","6a35b70ccb9eecf9","6a35b70ccb9eecf9","6a35b70ccb9eecf9","d7dbe7cddc04502c","6a35b70ccb9eecf9","6a35b70ccb9eecf9","6a35b70ccb9eecf9"]}, +{"id":"lit:lexical_extensions_test.go:230","input":"d]'e","digests":["f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0","f9fe9fe64ec8d8e0"]}, +{"id":"lit:lexical_extensions_test.go:231","input":"q'[a]b]'","digests":["e27bcfc1d75b64e1","e27bcfc1d75b64e1","e27bcfc1d75b64e1","e27bcfc1d75b64e1","e27bcfc1d75b64e1","13b471245b935d33","e27bcfc1d75b64e1","e27bcfc1d75b64e1","e27bcfc1d75b64e1"]}, +{"id":"lit:lexical_extensions_test.go:261","input":"E'x'","digests":["58b2748c3935f309","05c4e072f818d001","58b2748c3935f309","58b2748c3935f309","58b2748c3935f309","58b2748c3935f309","58b2748c3935f309","58b2748c3935f309","58b2748c3935f309"]}, +{"id":"lit:lexical_extensions_test.go:264","input":"q'[x]'","digests":["afb9344e9a78cfb6","afb9344e9a78cfb6","afb9344e9a78cfb6","afb9344e9a78cfb6","afb9344e9a78cfb6","2b694030c7e3186a","afb9344e9a78cfb6","afb9344e9a78cfb6","afb9344e9a78cfb6"]}, +{"id":"lit:lexical_extensions_test.go:267","input":"b'1'","digests":["fa165738705e9e55","d94d591745741768","d94d591745741768","d94d591745741768","fa165738705e9e55","fa165738705e9e55","fa165738705e9e55","fa165738705e9e55","fa165738705e9e55"]}, +{"id":"lit:lexical_extensions_test.go:280","input":"SELECT q'[never closed' FROM dual","digests":["c2859b645e54f3ab","c2859b645e54f3ab","c2859b645e54f3ab","c2859b645e54f3ab","c2859b645e54f3ab","325d8c6ef3afe2c1","c2859b645e54f3ab","c2859b645e54f3ab","c2859b645e54f3ab"]}, +{"id":"lit:lexical_extensions_test.go:284","input":"q' x'","digests":["903fc0d40f120150","903fc0d40f120150","903fc0d40f120150","903fc0d40f120150","903fc0d40f120150","903fc0d40f120150","903fc0d40f120150","903fc0d40f120150","903fc0d40f120150"]}, +{"id":"lit:lexical_extensions_test.go:286","input":"q' x': got %v","digests":["bb24d526286905a3","bb24d526286905a3","bb24d526286905a3","bb24d526286905a3","bb24d526286905a3","bb24d526286905a3","bb24d526286905a3","bb24d526286905a3","bb24d526286905a3"]}, +{"id":"lit:lexical_extensions_test.go:289","input":"E'abc\\","digests":["5c0590cbb96bcc67","712dc220d99d36b4","5c0590cbb96bcc67","5c0590cbb96bcc67","712dc220d99d36b4","712dc220d99d36b4","712dc220d99d36b4","5c0590cbb96bcc67","5c0590cbb96bcc67"]}, {"id":"lit:literal_golden_test.go:198","input":"json:\"id\"","digests":["79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934","79dba12d1c1f3934"]}, {"id":"lit:literal_golden_test.go:199","input":"json:\"input,omitempty\"","digests":["1350235632b30ad7","1350235632b30ad7","1350235632b30ad7","1350235632b30ad7","1350235632b30ad7","1350235632b30ad7","1350235632b30ad7","1350235632b30ad7","1350235632b30ad7"]}, {"id":"lit:literal_golden_test.go:200","input":"json:\"input_b64,omitempty\"","digests":["da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697","da8fb8ac79589697"]}, @@ -1138,66 +1262,87 @@ {"id":"lit:literal_golden_test.go:206","input":"json:\"comment\"","digests":["00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e","00ab88d454394d5e"]}, {"id":"lit:literal_golden_test.go:207","input":"json:\"dialects\"","digests":["d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9","d6c0c6a204852ae9"]}, {"id":"lit:literal_golden_test.go:208","input":"json:\"entries\"","digests":["a962f350b7b33633","a962f350b7b33633","a962f350b7b33633","a962f350b7b33633","a962f350b7b33633","a962f350b7b33633","a962f350b7b33633","a962f350b7b33633","a962f350b7b33633"]}, -{"id":"lit:literal_golden_test.go:419","input":",\"entries\":[","digests":["2dd7cc975a561921","2dd7cc975a561921","2dd7cc975a561921","2dd7cc975a561921","0fb009d0a2966d91","2dd7cc975a561921","2dd7cc975a561921","2dd7cc975a561921","2dd7cc975a561921"]}, +{"id":"lit:literal_golden_test.go:419","input":",\"entries\":[","digests":["2dd7cc975a561921","2dd7cc975a561921","2dd7cc975a561921","2dd7cc975a561921","0fb009d0a2966d91","2dd7cc975a561921","0fb009d0a2966d91","2dd7cc975a561921","2dd7cc975a561921"]}, {"id":"lit:literal_golden_test.go:503","input":"'\"`‘’“”«»","digests":["4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05","4e00ba84ec219d05"]}, {"id":"lit:memory_leak_test.go:44","input":"SELECT id, name, email FROM users WHERE age \u003e 21 AND status = 'active' ORDER BY name","digests":["b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c","b3c6ae6984cab58c"]}, {"id":"lit:memory_leak_test.go:217","input":"SELECT id, name, email, created_at FROM users WHERE status = 'active' AND age BETWEEN 18 AND 65 ORDER BY created_at DESC LIMIT 100","digests":["25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf","25af55e1e76d4fbf"]}, {"id":"lit:operators_test.go:60","input":"data-\u003e\u003e'field'","digests":["29657ecbabade411","29657ecbabade411","29657ecbabade411","29657ecbabade411","29657ecbabade411","29657ecbabade411","29657ecbabade411","29657ecbabade411","29657ecbabade411"]}, {"id":"lit:operators_test.go:121","input":"data=\u003e\u003e'field'","digests":["439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1","439376b0ab643fb1"]}, -{"id":"lit:oracle_bind_param_test.go:245","input":"SELECT TO_DATE('12:30', 'HH24:MI') FROM dual","digests":["ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c"]}, +{"id":"lit:oracle_bind_param_test.go:244","input":"SELECT TO_DATE('12:30', 'HH24:MI') FROM dual","digests":["ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c","ec9a5d3fe7c2882c"]}, {"id":"lit:position_tracking_test.go:79","input":"SELECT \"名前\", 'héllo wörld' FROM ユーザー\n\tWHERE 名前 = 'ü'","digests":["4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da","4009279bfd4b02da"]}, {"id":"lit:position_tracking_test.go:83","input":"\tSELECT a,\tb\n\t\tFROM t\n\t\t\tWHERE c = 'x'","digests":["8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db","8e80b91b2867a7db"]}, {"id":"lit:position_tracking_test.go:289","input":"\tSELECT a,\t\tb FROM t WHERE x = 'long value'\n","digests":["63274898a2a849e2","63274898a2a849e2","63274898a2a849e2","63274898a2a849e2","63274898a2a849e2","63274898a2a849e2","63274898a2a849e2","63274898a2a849e2","63274898a2a849e2"]}, -{"id":"lit:question_param_test.go:151","input":"data ?| array['a','b']","digests":["1bc5e194818cd1f6","1bc5e194818cd1f6","86037a95e670c05c","86037a95e670c05c","a9139d577c16cef8","1bc5e194818cd1f6","86037a95e670c05c","1bc5e194818cd1f6","1bc5e194818cd1f6"]}, -{"id":"lit:question_param_test.go:152","input":"data ?\u0026 array['a','b']","digests":["dfd37883aa1223cf","dfd37883aa1223cf","10d06566b0398c59","10d06566b0398c59","17bb4c6f348a3717","dfd37883aa1223cf","10d06566b0398c59","dfd37883aa1223cf","dfd37883aa1223cf"]}, +{"id":"lit:question_param_test.go:151","input":"data ?| array['a','b']","digests":["1bc5e194818cd1f6","1bc5e194818cd1f6","86037a95e670c05c","86037a95e670c05c","a9139d577c16cef8","1bc5e194818cd1f6","a21d2cdee69415d3","1bc5e194818cd1f6","1bc5e194818cd1f6"]}, +{"id":"lit:question_param_test.go:152","input":"data ?\u0026 array['a','b']","digests":["dfd37883aa1223cf","dfd37883aa1223cf","10d06566b0398c59","10d06566b0398c59","17bb4c6f348a3717","dfd37883aa1223cf","013053d219b173c1","dfd37883aa1223cf","dfd37883aa1223cf"]}, {"id":"lit:quoted_zero_copy_test.go:79","input":"a'","digests":["df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e","df7d5dc054cacc4e"]}, -{"id":"lit:quoted_zero_copy_test.go:82","input":"'a\nb' x","digests":["a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6"]}, -{"id":"lit:quoted_zero_copy_test.go:84","input":"x 'abc\n","digests":["63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387"]}, -{"id":"lit:quoted_zero_copy_test.go:90","input":"a“b","digests":["0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86"]}, -{"id":"lit:quoted_zero_copy_test.go:91","input":"'a’; DROP TABLE pwn; --'","digests":["27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba"]}, -{"id":"lit:quoted_zero_copy_test.go:91","input":"a’; DROP TABLE pwn; --","digests":["8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979"]}, -{"id":"lit:quoted_zero_copy_test.go:92","input":"'a»b«c'","digests":["d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc"]}, -{"id":"lit:quoted_zero_copy_test.go:92","input":"a»b«c","digests":["f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef"]}, -{"id":"lit:quoted_zero_copy_test.go:93","input":"x’’y","digests":["9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90"]}, -{"id":"lit:quoted_zero_copy_test.go:96","input":"'a’b''c'","digests":["86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595"]}, -{"id":"lit:quoted_zero_copy_test.go:96","input":"a’b'c","digests":["95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9"]}, -{"id":"lit:quoted_zero_copy_test.go:97","input_b64":"J2HigJn/Yic=","digests":["2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a"]}, -{"id":"lit:quoted_zero_copy_test.go:97","input":"a’�b","digests":["2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b"]}, -{"id":"lit:quoted_zero_copy_test.go:100","input":"‘a''b’","digests":["c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48"]}, -{"id":"lit:quoted_zero_copy_test.go:100","input":"a'b","digests":["21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350","21e8ccbf69bb5350"]}, -{"id":"lit:quoted_zero_copy_test.go:101","input":"‘abc' x","digests":["0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345"]}, -{"id":"lit:quoted_zero_copy_test.go:102","input":"‘a“b’","digests":["37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400"]}, -{"id":"lit:quoted_zero_copy_test.go:102","input":"a\"b","digests":["891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52"]}, -{"id":"lit:quoted_zero_copy_test.go:111","input":"a‘b","digests":["89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea"]}, -{"id":"lit:quoted_zero_copy_test.go:112","input":"\"x”; DROP TABLE pwn; --“\"","digests":["53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47"]}, -{"id":"lit:quoted_zero_copy_test.go:113","input":"\"a””b\"","digests":["a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455"]}, -{"id":"lit:quoted_zero_copy_test.go:113","input":"a””b","digests":["0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead"]}, -{"id":"lit:quoted_zero_copy_test.go:114","input":"x \"Smart”","digests":["73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6"]}, -{"id":"lit:quoted_zero_copy_test.go:115","input":"\"a”b\"\"c\"","digests":["7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2"]}, -{"id":"lit:quoted_zero_copy_test.go:115","input":"a”b\"c","digests":["ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9"]}, -{"id":"lit:quoted_zero_copy_test.go:117","input":"“a’b”","digests":["f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf"]}, -{"id":"lit:quoted_zero_copy_test.go:118","input":"“a””b”","digests":["95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9"]}, -{"id":"lit:quoted_zero_copy_test.go:121","input":"x \"abc","digests":["00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f"]}, -{"id":"lit:quoted_zero_copy_test.go:128","input":"a`b","digests":["1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df"]}, -{"id":"lit:quoted_zero_copy_test.go:144","input":"N'a’; DROP TABLE pwn; --'","digests":["b5e9423ac9f2cb15","b5e9423ac9f2cb15","b5e9423ac9f2cb15","b5e9423ac9f2cb15","9a1bb011b8cb9d92","b5e9423ac9f2cb15","b5e9423ac9f2cb15","b5e9423ac9f2cb15","b5e9423ac9f2cb15"]}, -{"id":"lit:quoted_zero_copy_test.go:292","input":"SELECT 'it''s', 'clean', \"Quo\"\"ted\", \"Plain\", 'a\\tb' FROM t","digests":["c51062cf4a24d3d0","c51062cf4a24d3d0","c51062cf4a24d3d0","c51062cf4a24d3d0","e1c635e27066aa14","e1c635e27066aa14","e1c635e27066aa14","e1c635e27066aa14","e1c635e27066aa14"]}, -{"id":"lit:quoted_zero_copy_test.go:308","input":"SELECT '","digests":["e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d"]}, -{"id":"lit:quoted_zero_copy_test.go:308","input":"zz''","digests":["5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0"]}, -{"id":"lit:quoted_zero_copy_test.go:308","input":"', \"","digests":["0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f"]}, -{"id":"lit:quoted_zero_copy_test.go:308","input":"yy\"\"","digests":["7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326"]}, -{"id":"lit:quoted_zero_copy_test.go:308","input":"\" FROM t","digests":["9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac"]}, -{"id":"lit:quoted_zero_copy_test.go:312","input":"SELECT 'q''q', \"w\"\"w\"","digests":["d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3"]}, -{"id":"lit:quoted_zero_copy_test.go:337","input":"SELECT 'it''s'","digests":["613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3"]}, -{"id":"lit:quoted_zero_copy_test.go:345","input":"a''","digests":["cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e"]}, -{"id":"lit:quoted_zero_copy_test.go:345","input":"'","digests":["56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd"]}, -{"id":"lit:quoted_zero_copy_test.go:364","input":"', 'a''b' FROM t","digests":["11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be"]}, -{"id":"lit:quoted_zero_copy_test.go:396","input":"pooled tokenizer's litBuf holds a previous request's literal text","digests":["ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a"]}, -{"id":"lit:quoted_zero_copy_test.go:561","input":"'value%03d'","digests":["a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74"]}, -{"id":"lit:quoted_zero_copy_test.go:568","input":"\"Col%03d\"","digests":["d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551"]}, -{"id":"lit:quoted_zero_copy_test.go:573","input":"`col%03d`","digests":["d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624"]}, -{"id":"lit:quoted_zero_copy_test.go:581","input":"'abcd'","digests":["de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98"]}, -{"id":"lit:quoted_zero_copy_test.go:582","input":"'a''b'","digests":["1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773"]}, +{"id":"lit:quoted_zero_copy_test.go:83","input":"'50\\%\\_'","digests":["32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01","32f97a915c8ebb01"]}, +{"id":"lit:quoted_zero_copy_test.go:85","input":"'a\\' x","digests":["8df5a28d02280e3d","7186fda5a47764c4","8df5a28d02280e3d","8df5a28d02280e3d","7186fda5a47764c4","7186fda5a47764c4","7186fda5a47764c4","8df5a28d02280e3d","8df5a28d02280e3d"]}, +{"id":"lit:quoted_zero_copy_test.go:86","input":"'a\nb' x","digests":["a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6","a3cfded02dbb3ed6"]}, +{"id":"lit:quoted_zero_copy_test.go:88","input":"x 'abc\n","digests":["63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387","63ccf51423768387"]}, +{"id":"lit:quoted_zero_copy_test.go:94","input":"a“b","digests":["0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86","0d5f152091b52b86"]}, +{"id":"lit:quoted_zero_copy_test.go:95","input":"'a’; DROP TABLE pwn; --'","digests":["27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba","27c2e697959a01ba"]}, +{"id":"lit:quoted_zero_copy_test.go:95","input":"a’; DROP TABLE pwn; --","digests":["8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979","8933abf7e194c979"]}, +{"id":"lit:quoted_zero_copy_test.go:96","input":"'a»b«c'","digests":["d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc","d31eff129d8f0bbc"]}, +{"id":"lit:quoted_zero_copy_test.go:96","input":"a»b«c","digests":["f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef","f1b055b9606346ef"]}, +{"id":"lit:quoted_zero_copy_test.go:97","input":"x’’y","digests":["9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90","9ce73360ff724b90"]}, +{"id":"lit:quoted_zero_copy_test.go:100","input":"'a’b''c'","digests":["86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595","86d390525f1f9595"]}, +{"id":"lit:quoted_zero_copy_test.go:100","input":"a’b'c","digests":["95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9","95e8b4f8cf3218e9"]}, +{"id":"lit:quoted_zero_copy_test.go:101","input_b64":"J2HigJn/Yic=","digests":["2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a","2c49f6f9cf08507a"]}, +{"id":"lit:quoted_zero_copy_test.go:101","input":"a’�b","digests":["2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b","2febbe83b87f068b"]}, +{"id":"lit:quoted_zero_copy_test.go:104","input":"‘a''b’","digests":["c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48","c41d1d0ab3b9fa48"]}, +{"id":"lit:quoted_zero_copy_test.go:105","input":"‘abc' x","digests":["0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345","0f6d059f2acd0345"]}, +{"id":"lit:quoted_zero_copy_test.go:106","input":"‘a“b’","digests":["37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400","37776763f69c3400"]}, +{"id":"lit:quoted_zero_copy_test.go:106","input":"a\"b","digests":["891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52","891cf013cd824b52"]}, +{"id":"lit:quoted_zero_copy_test.go:115","input":"a‘b","digests":["89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea","89873116b95fcaea"]}, +{"id":"lit:quoted_zero_copy_test.go:116","input":"\"x”; DROP TABLE pwn; --“\"","digests":["53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47","53b84326b4464a47"]}, +{"id":"lit:quoted_zero_copy_test.go:117","input":"\"a””b\"","digests":["a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455","a5940cbdcf110455"]}, +{"id":"lit:quoted_zero_copy_test.go:117","input":"a””b","digests":["0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead","0237956d1c3e4ead"]}, +{"id":"lit:quoted_zero_copy_test.go:118","input":"x \"Smart”","digests":["73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6","73a97c67411551e6"]}, +{"id":"lit:quoted_zero_copy_test.go:119","input":"\"a”b\"\"c\"","digests":["7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2","7be2d4a9526a43c2"]}, +{"id":"lit:quoted_zero_copy_test.go:119","input":"a”b\"c","digests":["ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9","ed5fd43393da32f9"]}, +{"id":"lit:quoted_zero_copy_test.go:121","input":"“a’b”","digests":["f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf","f550ba48d4d4cdbf"]}, +{"id":"lit:quoted_zero_copy_test.go:122","input":"“a””b”","digests":["95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9","95570a839dd293d9"]}, +{"id":"lit:quoted_zero_copy_test.go:125","input":"x \"abc","digests":["00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f","00adc69c946d828f"]}, +{"id":"lit:quoted_zero_copy_test.go:132","input":"a`b","digests":["1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df","1911547a4d1584df"]}, +{"id":"lit:quoted_zero_copy_test.go:148","input":"N'a’; DROP TABLE pwn; --'","digests":["b5e9423ac9f2cb15","9a1bb011b8cb9d92","9a1bb011b8cb9d92","9a1bb011b8cb9d92","9a1bb011b8cb9d92","9a1bb011b8cb9d92","b5e9423ac9f2cb15","b5e9423ac9f2cb15","b5e9423ac9f2cb15"]}, +{"id":"lit:quoted_zero_copy_test.go:296","input":"SELECT 'it''s', 'clean', \"Quo\"\"ted\", \"Plain\", 'a\\tb' FROM t","digests":["c51062cf4a24d3d0","e1c635e27066aa14","c51062cf4a24d3d0","c51062cf4a24d3d0","e1c635e27066aa14","e1c635e27066aa14","e1c635e27066aa14","c51062cf4a24d3d0","c51062cf4a24d3d0"]}, +{"id":"lit:quoted_zero_copy_test.go:312","input":"SELECT '","digests":["e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d","e237ed01bdc4c80d"]}, +{"id":"lit:quoted_zero_copy_test.go:312","input":"zz''","digests":["5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0","5bfa9cdfd63b4cd0"]}, +{"id":"lit:quoted_zero_copy_test.go:312","input":"', \"","digests":["0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f","0d1b6381cf80c38f"]}, +{"id":"lit:quoted_zero_copy_test.go:312","input":"yy\"\"","digests":["7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326","7be7b83d02e73326"]}, +{"id":"lit:quoted_zero_copy_test.go:312","input":"\" FROM t","digests":["9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac","9d7472d8255f58ac"]}, +{"id":"lit:quoted_zero_copy_test.go:316","input":"SELECT 'q''q', \"w\"\"w\"","digests":["d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3","d161c95b343ff3a3"]}, +{"id":"lit:quoted_zero_copy_test.go:341","input":"SELECT 'it''s'","digests":["613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3","613661c77e3c36d3"]}, +{"id":"lit:quoted_zero_copy_test.go:349","input":"a''","digests":["cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e","cea7aa162b0a634e"]}, +{"id":"lit:quoted_zero_copy_test.go:349","input":"'","digests":["56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd","56a9f1e4148687cd"]}, +{"id":"lit:quoted_zero_copy_test.go:368","input":"', 'a''b' FROM t","digests":["11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be","11c27bce1d5cb4be"]}, +{"id":"lit:quoted_zero_copy_test.go:400","input":"pooled tokenizer's litBuf holds a previous request's literal text","digests":["ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a","ee523b587cf3484a"]}, +{"id":"lit:quoted_zero_copy_test.go:565","input":"'value%03d'","digests":["a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74","a2d87ede8bd5ef74"]}, +{"id":"lit:quoted_zero_copy_test.go:572","input":"\"Col%03d\"","digests":["d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551","d3a8eb6c248b2551"]}, +{"id":"lit:quoted_zero_copy_test.go:577","input":"`col%03d`","digests":["d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624","d093a8cf39ee8624"]}, +{"id":"lit:quoted_zero_copy_test.go:585","input":"'abcd'","digests":["de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98","de5ba984ea2e7b98"]}, +{"id":"lit:quoted_zero_copy_test.go:586","input":"'a''b'","digests":["1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773","1f150d15a0391773"]}, {"id":"lit:scalability_bench_test.go:227","input":"SELECT id FROM users WHERE name LIKE 'John%'","digests":["36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa","36aa69ab019de1aa"]}, +{"id":"lit:string_escape_modes_test.go:55","input":"SELECT 'a\\b'","digests":["935a3366825112bf","d39836481a4f7c67","935a3366825112bf","935a3366825112bf","d39836481a4f7c67","d39836481a4f7c67","d39836481a4f7c67","935a3366825112bf","935a3366825112bf"]}, +{"id":"lit:string_escape_modes_test.go:56","input":"SELECT 'x\\.y'","digests":["3a7a938aac619afa","3a7a938aac619afa","80064e25a36dd53d","80064e25a36dd53d","3a7a938aac619afa","3a7a938aac619afa","3a7a938aac619afa","3a7a938aac619afa","3a7a938aac619afa"]}, +{"id":"lit:string_escape_modes_test.go:57","input":"SELECT '\\'","digests":["c751359b15b07600","bc6f22e5b628b6a7","c751359b15b07600","c751359b15b07600","bc6f22e5b628b6a7","bc6f22e5b628b6a7","bc6f22e5b628b6a7","c751359b15b07600","c751359b15b07600"]}, +{"id":"lit:string_escape_modes_test.go:59","input":"SELECT 'a\\b' FROM dual","digests":["43b19ba15c3979b4","4753152a7d35ae24","43b19ba15c3979b4","43b19ba15c3979b4","4753152a7d35ae24","4753152a7d35ae24","4753152a7d35ae24","43b19ba15c3979b4","43b19ba15c3979b4"]}, +{"id":"lit:string_escape_modes_test.go:60","input":"SELECT 'C:\\dir\\n'","digests":["91803d02339c7ff5","29f6530fc4282b69","fe921a5d82584f5c","fe921a5d82584f5c","29f6530fc4282b69","29f6530fc4282b69","29f6530fc4282b69","91803d02339c7ff5","91803d02339c7ff5"]}, +{"id":"lit:string_escape_modes_test.go:61","input":"SELECT 'a\\nb'","digests":["1a1c995b88f0f821","f3e20239eff85f9e","1a1c995b88f0f821","1a1c995b88f0f821","f3e20239eff85f9e","f3e20239eff85f9e","f3e20239eff85f9e","1a1c995b88f0f821","1a1c995b88f0f821"]}, +{"id":"lit:string_escape_modes_test.go:65","input":"SELECT 'a\\\\b'","digests":["b86fd335220c816e","e2fb13c789b38d13","b86fd335220c816e","b86fd335220c816e","e2fb13c789b38d13","e2fb13c789b38d13","e2fb13c789b38d13","b86fd335220c816e","b86fd335220c816e"]}, +{"id":"lit:string_escape_modes_test.go:66","input":"SELECT 'it\\'s'","digests":["613661c77e3c36d3","8dda134694e8e86c","613661c77e3c36d3","613661c77e3c36d3","8dda134694e8e86c","8dda134694e8e86c","8dda134694e8e86c","613661c77e3c36d3","613661c77e3c36d3"]}, +{"id":"lit:string_escape_modes_test.go:67","input":"SELECT '50\\%'","digests":["2e06053970353ec2","2e06053970353ec2","2e06053970353ec2","2e06053970353ec2","2e06053970353ec2","2e06053970353ec2","2e06053970353ec2","2e06053970353ec2","2e06053970353ec2"]}, +{"id":"lit:string_escape_modes_test.go:68","input":"SELECT '\\_x'","digests":["4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf","4c1f53e847de86bf"]}, +{"id":"lit:string_escape_modes_test.go:69","input":"SELECT 'a\\qb'","digests":["03a060b3f0e1679f","03a060b3f0e1679f","a77a421c467414b7","a77a421c467414b7","03a060b3f0e1679f","03a060b3f0e1679f","03a060b3f0e1679f","03a060b3f0e1679f","03a060b3f0e1679f"]}, +{"id":"lit:string_escape_modes_test.go:70","input":"SELECT 'a\\0b'","digests":["7dd4f0c1fc356168","49975f506f783eb6","7dd4f0c1fc356168","7dd4f0c1fc356168","49975f506f783eb6","49975f506f783eb6","49975f506f783eb6","7dd4f0c1fc356168","7dd4f0c1fc356168"]}, +{"id":"lit:string_escape_modes_test.go:71","input":"SELECT 'a\\bb'","digests":["b55e641ceb46a9cc","db749b579d2b04be","b55e641ceb46a9cc","b55e641ceb46a9cc","db749b579d2b04be","db749b579d2b04be","db749b579d2b04be","b55e641ceb46a9cc","b55e641ceb46a9cc"]}, +{"id":"lit:string_escape_modes_test.go:72","input":"SELECT 'a\\Zb'","digests":["5bf6ed448ee083b9","05f398a219fc30b1","5bf6ed448ee083b9","5bf6ed448ee083b9","05f398a219fc30b1","05f398a219fc30b1","05f398a219fc30b1","5bf6ed448ee083b9","5bf6ed448ee083b9"]}, +{"id":"lit:string_escape_modes_test.go:75","input":"SELECT 'a\\'b'","digests":["20449429ac30c0df","6c0395075dd2a7fb","20449429ac30c0df","20449429ac30c0df","6c0395075dd2a7fb","6c0395075dd2a7fb","6c0395075dd2a7fb","20449429ac30c0df","20449429ac30c0df"]}, +{"id":"lit:string_escape_modes_test.go:79","input":"SELECT 'a\\tb'","digests":["370961290550b329","baf6de5675836751","370961290550b329","370961290550b329","baf6de5675836751","baf6de5675836751","baf6de5675836751","370961290550b329","370961290550b329"]}, +{"id":"lit:string_escape_modes_test.go:80","input":"SELECT '\\d+'","digests":["5ba8dcdc58452976","5ba8dcdc58452976","b700f59a753374a1","b700f59a753374a1","5ba8dcdc58452976","5ba8dcdc58452976","5ba8dcdc58452976","5ba8dcdc58452976","5ba8dcdc58452976"]}, +{"id":"lit:string_escape_modes_test.go:123","input":"SELECT 'a\\\nb', 1","digests":["161b9deeaa8d2f4c","161b9deeaa8d2f4c","99d14022c9abc0e0","99d14022c9abc0e0","161b9deeaa8d2f4c","161b9deeaa8d2f4c","161b9deeaa8d2f4c","161b9deeaa8d2f4c","161b9deeaa8d2f4c"]}, +{"id":"lit:string_escape_modes_test.go:141","input":"SELECT 'a\\'","digests":["383c8f29782a7bd3","4ac2b5a71b4bfe88","383c8f29782a7bd3","383c8f29782a7bd3","4ac2b5a71b4bfe88","4ac2b5a71b4bfe88","4ac2b5a71b4bfe88","383c8f29782a7bd3","383c8f29782a7bd3"]}, +{"id":"lit:string_escape_modes_test.go:146","input":"SELECT 'a\\","digests":["d57428ae505384c0","32fb78364a1ae6ef","d57428ae505384c0","d57428ae505384c0","32fb78364a1ae6ef","32fb78364a1ae6ef","32fb78364a1ae6ef","d57428ae505384c0","d57428ae505384c0"]}, {"id":"lit:token_capacity_test.go:64","input":"SELECT 用户.名前, café FROM пользователи WHERE 名前 = 'こんにちは'","digests":["a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab","a7f6209e65c45bab"]}, {"id":"lit:token_capacity_test.go:65","input":"SELECT a\u003e=b, a\u003c\u003eb, a||b, j-\u003e'k', j@\u003e'{}', x::int FROM t WHERE a\u003c=b AND c!=d","digests":["0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45","0757a7bc6b7a4a45"]}, {"id":"lit:token_capacity_test.go:67","input":"SELECT 'hello world and more words', \"quoted ident\" FROM t","digests":["c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013","c7ed30e3ddace013"]}, @@ -1219,17 +1364,17 @@ {"id":"lit:tokenizer_coverage_test.go:158","input":"She said \"Hello\" and 'Goodbye'","digests":["d16d978598861c5b","d16d978598861c5b","d16d978598861c5b","d16d978598861c5b","d16d978598861c5b","d16d978598861c5b","d16d978598861c5b","d16d978598861c5b","d16d978598861c5b"]}, {"id":"lit:tokenizer_coverage_test.go:170","input":"'''unterminated","digests":["db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2","db7766c3b9e5fdd2"]}, {"id":"lit:tokenizer_coverage_test.go:177","input":"\"\"\"SELECT * FROM users WHERE id = 1\"\"\"","digests":["bb91480dc0582187","bb91480dc0582187","bb91480dc0582187","bb91480dc0582187","bb91480dc0582187","bb91480dc0582187","bb91480dc0582187","bb91480dc0582187","bb91480dc0582187"]}, -{"id":"lit:tokenizer_coverage_test.go:232","input":"'hello\\nworld'","digests":["dcee6326640bcfa6","dcee6326640bcfa6","dcee6326640bcfa6","dcee6326640bcfa6","d0027b5436f3f558","d0027b5436f3f558","d0027b5436f3f558","d0027b5436f3f558","d0027b5436f3f558"]}, -{"id":"lit:tokenizer_coverage_test.go:238","input":"'hello\\tworld'","digests":["4653df4a88f83aec","4653df4a88f83aec","4653df4a88f83aec","4653df4a88f83aec","b83a8ee4af250f7f","b83a8ee4af250f7f","b83a8ee4af250f7f","b83a8ee4af250f7f","b83a8ee4af250f7f"]}, -{"id":"lit:tokenizer_coverage_test.go:244","input":"'hello\\rworld'","digests":["431dce1f3c9e2b70","431dce1f3c9e2b70","431dce1f3c9e2b70","431dce1f3c9e2b70","05cfe23a70bc1ac2","05cfe23a70bc1ac2","05cfe23a70bc1ac2","05cfe23a70bc1ac2","05cfe23a70bc1ac2"]}, -{"id":"lit:tokenizer_coverage_test.go:250","input":"'C:\\\\Program Files\\\\'","digests":["c02b38c675bb4e8a","c02b38c675bb4e8a","c02b38c675bb4e8a","c02b38c675bb4e8a","4248565da0004678","4248565da0004678","4248565da0004678","4248565da0004678","4248565da0004678"]}, -{"id":"lit:tokenizer_coverage_test.go:256","input":"'It\\'s working'","digests":["8dbb8fb768551d07","8dbb8fb768551d07","8dbb8fb768551d07","8dbb8fb768551d07","b90e7a15c9b7d120","b90e7a15c9b7d120","b90e7a15c9b7d120","b90e7a15c9b7d120","b90e7a15c9b7d120"]}, +{"id":"lit:tokenizer_coverage_test.go:232","input":"'hello\\nworld'","digests":["dcee6326640bcfa6","d0027b5436f3f558","dcee6326640bcfa6","dcee6326640bcfa6","d0027b5436f3f558","d0027b5436f3f558","d0027b5436f3f558","dcee6326640bcfa6","dcee6326640bcfa6"]}, +{"id":"lit:tokenizer_coverage_test.go:238","input":"'hello\\tworld'","digests":["4653df4a88f83aec","b83a8ee4af250f7f","4653df4a88f83aec","4653df4a88f83aec","b83a8ee4af250f7f","b83a8ee4af250f7f","b83a8ee4af250f7f","4653df4a88f83aec","4653df4a88f83aec"]}, +{"id":"lit:tokenizer_coverage_test.go:244","input":"'hello\\rworld'","digests":["431dce1f3c9e2b70","05cfe23a70bc1ac2","431dce1f3c9e2b70","431dce1f3c9e2b70","05cfe23a70bc1ac2","05cfe23a70bc1ac2","05cfe23a70bc1ac2","431dce1f3c9e2b70","431dce1f3c9e2b70"]}, +{"id":"lit:tokenizer_coverage_test.go:250","input":"'C:\\\\Program Files\\\\'","digests":["c02b38c675bb4e8a","4248565da0004678","c02b38c675bb4e8a","c02b38c675bb4e8a","4248565da0004678","4248565da0004678","4248565da0004678","c02b38c675bb4e8a","c02b38c675bb4e8a"]}, +{"id":"lit:tokenizer_coverage_test.go:256","input":"'It\\'s working'","digests":["8dbb8fb768551d07","b90e7a15c9b7d120","8dbb8fb768551d07","8dbb8fb768551d07","b90e7a15c9b7d120","b90e7a15c9b7d120","b90e7a15c9b7d120","8dbb8fb768551d07","8dbb8fb768551d07"]}, {"id":"lit:tokenizer_coverage_test.go:257","input":"It's working","digests":["5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97","5a0cd94f43563c97"]}, {"id":"lit:tokenizer_coverage_test.go:262","input":"\"She said \\\"Hello\\\"\"","digests":["9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8","9dc03f7d6d1a4ac8"]}, {"id":"lit:tokenizer_coverage_test.go:263","input":"She said \"Hello\"","digests":["a965054af252c050","a965054af252c050","a965054af252c050","a965054af252c050","a965054af252c050","a965054af252c050","a965054af252c050","a965054af252c050","a965054af252c050"]}, {"id":"lit:tokenizer_coverage_test.go:268","input":"`name\\`escaped`","digests":["e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae","e4f158fa8979ffae"]}, {"id":"lit:tokenizer_coverage_test.go:269","input":"name`escaped","digests":["503533b508e358eb","503533b508e358eb","503533b508e358eb","503533b508e358eb","503533b508e358eb","503533b508e358eb","503533b508e358eb","503533b508e358eb","503533b508e358eb"]}, -{"id":"lit:tokenizer_coverage_test.go:274","input":"'Line 1\\nLine 2\\tTabbed\\nLine 3\\\\'","digests":["98a2e4bc628d1844","98a2e4bc628d1844","98a2e4bc628d1844","98a2e4bc628d1844","d6298a3a90c3072e","d6298a3a90c3072e","d6298a3a90c3072e","d6298a3a90c3072e","d6298a3a90c3072e"]}, +{"id":"lit:tokenizer_coverage_test.go:274","input":"'Line 1\\nLine 2\\tTabbed\\nLine 3\\\\'","digests":["98a2e4bc628d1844","d6298a3a90c3072e","98a2e4bc628d1844","98a2e4bc628d1844","d6298a3a90c3072e","d6298a3a90c3072e","d6298a3a90c3072e","98a2e4bc628d1844","98a2e4bc628d1844"]}, {"id":"lit:tokenizer_coverage_test.go:539","input":"\"user_id\"","digests":["29b35059c7787966","29b35059c7787966","29b35059c7787966","29b35059c7787966","29b35059c7787966","29b35059c7787966","29b35059c7787966","29b35059c7787966","29b35059c7787966"]}, {"id":"lit:tokenizer_coverage_test.go:545","input":"\"user name\"","digests":["c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec","c22bbfa7175252ec"]}, {"id":"lit:tokenizer_coverage_test.go:551","input":"\"user-id@domain\"","digests":["1454c86719233b9b","1454c86719233b9b","1454c86719233b9b","1454c86719233b9b","1454c86719233b9b","1454c86719233b9b","1454c86719233b9b","1454c86719233b9b","1454c86719233b9b"]}, @@ -1276,7 +1421,7 @@ {"id":"lit:tokenizer_fuzz_test.go:270","input":"SELECT ''","digests":["c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb","c5e770f37cd996eb"]}, {"id":"lit:tokenizer_fuzz_test.go:271","input":"SELECT 'line1\nline2'","digests":["6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1","6923d9dc9cf478b1"]}, {"id":"lit:tokenizer_fuzz_test.go:272","input":"SELECT 'tab\ttab'","digests":["7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4","7c08ca0465b00ae4"]}, -{"id":"lit:tokenizer_fuzz_test.go:273","input":"SELECT '\\n'","digests":["b3a60b1365928f91","b3a60b1365928f91","b3a60b1365928f91","b3a60b1365928f91","b4015b173bf0ec3a","b4015b173bf0ec3a","b4015b173bf0ec3a","b4015b173bf0ec3a","b4015b173bf0ec3a"]}, +{"id":"lit:tokenizer_fuzz_test.go:273","input":"SELECT '\\n'","digests":["b3a60b1365928f91","b4015b173bf0ec3a","b3a60b1365928f91","b3a60b1365928f91","b4015b173bf0ec3a","b4015b173bf0ec3a","b4015b173bf0ec3a","b3a60b1365928f91","b3a60b1365928f91"]}, {"id":"lit:tokenizer_fuzz_test.go:274","input":"SELECT 'O''Reilly'","digests":["8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2","8bde39d35f7f2fd2"]}, {"id":"lit:tokenizer_fuzz_test.go:488","input":"abc' x","digests":["46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45","46bfcad28de38a45"]}, {"id":"lit:tokenizer_fuzz_test.go:489","input":"a''b' 'c\\'d'","digests":["d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f","d042ae5bb07d676f"]}, @@ -1347,25 +1492,25 @@ {"id":"lit:tokenizer_test.go:669","input":"SELECT \"имя FROM пользователи","digests":["4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87","4aae93b71e5f7c87"]}, {"id":"lit:tokenizer_test.go:706","input":"SELECT * FROM users WHERE status = '✅'","digests":["bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f","bfe90b761c75130f"]}, {"id":"lit:tokenizer_test.go:707","input":"SELECT 'café', 'naïve', 'Zürich'","digests":["248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad","248ab5d49545dcad"]}, -{"id":"lit:tsql_test.go:174","input":"N'hello'","digests":["3e9e69a586918ae1","3e9e69a586918ae1","3e9e69a586918ae1","3e9e69a586918ae1","1fb1a83a5787c6ce","3e9e69a586918ae1","3e9e69a586918ae1","3e9e69a586918ae1","3e9e69a586918ae1"]}, -{"id":"lit:tsql_test.go:175","input":"n'hello' lowercase","digests":["be4b58504d7e6373","be4b58504d7e6373","be4b58504d7e6373","be4b58504d7e6373","cfb48998f40a0491","be4b58504d7e6373","be4b58504d7e6373","be4b58504d7e6373","be4b58504d7e6373"]}, -{"id":"lit:tsql_test.go:175","input":"n'hello'","digests":["08ff9003645b4202","08ff9003645b4202","08ff9003645b4202","08ff9003645b4202","1fb1a83a5787c6ce","08ff9003645b4202","08ff9003645b4202","08ff9003645b4202","08ff9003645b4202"]}, -{"id":"lit:tsql_test.go:176","input":"N'unicode chars'","digests":["8fa426dfee7503b1","8fa426dfee7503b1","8fa426dfee7503b1","8fa426dfee7503b1","42c2de43b8064a04","8fa426dfee7503b1","8fa426dfee7503b1","8fa426dfee7503b1","8fa426dfee7503b1"]}, -{"id":"lit:tsql_test.go:176","input":"N'こんにちは'","digests":["aebcc9eb8a00b467","aebcc9eb8a00b467","aebcc9eb8a00b467","aebcc9eb8a00b467","f9b37fd4a36d629e","aebcc9eb8a00b467","aebcc9eb8a00b467","aebcc9eb8a00b467","aebcc9eb8a00b467"]}, -{"id":"lit:tsql_test.go:177","input":"N'' empty","digests":["b5e4cac7d4f3313a","b5e4cac7d4f3313a","b5e4cac7d4f3313a","b5e4cac7d4f3313a","c81cc8506927607d","b5e4cac7d4f3313a","b5e4cac7d4f3313a","b5e4cac7d4f3313a","b5e4cac7d4f3313a"]}, -{"id":"lit:tsql_test.go:178","input":"SELECT N'test'","digests":["2ed8f0740332242d","2ed8f0740332242d","2ed8f0740332242d","2ed8f0740332242d","110393bccb9750fa","2ed8f0740332242d","2ed8f0740332242d","2ed8f0740332242d","2ed8f0740332242d"]}, +{"id":"lit:tsql_test.go:174","input":"N'hello'","digests":["3e9e69a586918ae1","1fb1a83a5787c6ce","1fb1a83a5787c6ce","1fb1a83a5787c6ce","1fb1a83a5787c6ce","1fb1a83a5787c6ce","3e9e69a586918ae1","3e9e69a586918ae1","3e9e69a586918ae1"]}, +{"id":"lit:tsql_test.go:175","input":"n'hello' lowercase","digests":["be4b58504d7e6373","cfb48998f40a0491","cfb48998f40a0491","cfb48998f40a0491","cfb48998f40a0491","cfb48998f40a0491","be4b58504d7e6373","be4b58504d7e6373","be4b58504d7e6373"]}, +{"id":"lit:tsql_test.go:175","input":"n'hello'","digests":["08ff9003645b4202","1fb1a83a5787c6ce","1fb1a83a5787c6ce","1fb1a83a5787c6ce","1fb1a83a5787c6ce","1fb1a83a5787c6ce","08ff9003645b4202","08ff9003645b4202","08ff9003645b4202"]}, +{"id":"lit:tsql_test.go:176","input":"N'unicode chars'","digests":["8fa426dfee7503b1","42c2de43b8064a04","42c2de43b8064a04","42c2de43b8064a04","42c2de43b8064a04","42c2de43b8064a04","8fa426dfee7503b1","8fa426dfee7503b1","8fa426dfee7503b1"]}, +{"id":"lit:tsql_test.go:176","input":"N'こんにちは'","digests":["aebcc9eb8a00b467","f9b37fd4a36d629e","f9b37fd4a36d629e","f9b37fd4a36d629e","f9b37fd4a36d629e","f9b37fd4a36d629e","aebcc9eb8a00b467","aebcc9eb8a00b467","aebcc9eb8a00b467"]}, +{"id":"lit:tsql_test.go:177","input":"N'' empty","digests":["b5e4cac7d4f3313a","c81cc8506927607d","c81cc8506927607d","c81cc8506927607d","c81cc8506927607d","c81cc8506927607d","b5e4cac7d4f3313a","b5e4cac7d4f3313a","b5e4cac7d4f3313a"]}, +{"id":"lit:tsql_test.go:178","input":"SELECT N'test'","digests":["2ed8f0740332242d","110393bccb9750fa","110393bccb9750fa","110393bccb9750fa","110393bccb9750fa","110393bccb9750fa","2ed8f0740332242d","2ed8f0740332242d","2ed8f0740332242d"]}, {"id":"lit:tsql_test.go:191","input":"expected Quote='N', got %q","digests":["7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18","7808a2313f77aa18"]}, -{"id":"lit:tsql_test.go:228","input":"expected 2 tokens for PostgreSQL N'hello', got %d","digests":["fbb2ac483bf54aa4","fbb2ac483bf54aa4","fbb2ac483bf54aa4","fbb2ac483bf54aa4","db87c500c6e33f84","fbb2ac483bf54aa4","fbb2ac483bf54aa4","fbb2ac483bf54aa4","fbb2ac483bf54aa4"]}, -{"id":"lit:tsql_test.go:261","input":"expected value 'My Column', got %q","digests":["7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e"]}, +{"id":"lit:tsql_test.go:229","input":"expected 2 tokens for SQLite N'hello', got %d","digests":["8c403842bca51cb8","3f54de5631cff365","3f54de5631cff365","3f54de5631cff365","3f54de5631cff365","3f54de5631cff365","8c403842bca51cb8","8c403842bca51cb8","8c403842bca51cb8"]}, +{"id":"lit:tsql_test.go:262","input":"expected value 'My Column', got %q","digests":["7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e","7f7a8b1e9a73772e"]}, {"id":"lit:zero_copy_bench_test.go:73","input":"('alpha','beta','gamma','delta')","digests":["c41fe693936827fd","c41fe693936827fd","c41fe693936827fd","c41fe693936827fd","c41fe693936827fd","c41fe693936827fd","c41fe693936827fd","c41fe693936827fd","c41fe693936827fd"]}, {"id":"lit:zero_copy_bench_test.go:79","input":"\"Col%d\"","digests":["bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63","bc0bcc24637b2d63"]}, {"id":"lit:zero_copy_bench_test.go:81","input":" FROM \"Schema\".\"Table\"","digests":["4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8","4d2f8ceb85b277a8"]}, -{"id":"lit:zero_copy_test.go:60","input":"SELECT N'abc', [a b], [], #t, ##g, # FROM [dbo].[My Table]","digests":["57b0ce5c26155fe0","57b0ce5c26155fe0","57b0ce5c26155fe0","57b0ce5c26155fe0","53d1c98245146fdc","57b0ce5c26155fe0","57b0ce5c26155fe0","57b0ce5c26155fe0","57b0ce5c26155fe0"]}, +{"id":"lit:zero_copy_test.go:60","input":"SELECT N'abc', [a b], [], #t, ##g, # FROM [dbo].[My Table]","digests":["57b0ce5c26155fe0","67584d84adad459b","67584d84adad459b","67584d84adad459b","53d1c98245146fdc","67584d84adad459b","df13f535d38017e1","57b0ce5c26155fe0","57b0ce5c26155fe0"]}, {"id":"lit:zero_copy_test.go:62","input":"SELECT 名前 FROM ユーザー WHERE 'こんにちは' = x","digests":["709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe","709d6ff4a5641fbe"]}, {"id":"lit:zero_copy_test.go:68","input":"SELECT `back tick`, \"Quoted Col\" FROM t","digests":["bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897","bcc02bf543ffd897"]}, {"id":"lit:zero_copy_test.go:69","input":"SELECT 'it''s', \"a\"\"b\" FROM t","digests":["c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7","c6a3d0a0ff66a9d7"]}, -{"id":"lit:zero_copy_test.go:79","input":"SELECT 'x","digests":["2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a"]}, -{"id":"lit:zero_copy_test.go:221","input":"select \"Quoted Col\" from t","digests":["6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a"]}, -{"id":"lit:zero_copy_test.go:450","input":"TokenizeString allocs for N=1500: %v, want at most N=10's %v + 8","digests":["c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff"]}, -{"id":"lit:zero_copy_test.go:461","input":"Tokenize([]byte) uses %v allocs, want at most TokenizeString's %v + 1","digests":["82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d"]} +{"id":"lit:zero_copy_test.go:78","input":"SELECT 'x","digests":["2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a","2b3df1bbe4df305a"]}, +{"id":"lit:zero_copy_test.go:220","input":"select \"Quoted Col\" from t","digests":["6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a","6f83458dafad946a"]}, +{"id":"lit:zero_copy_test.go:449","input":"TokenizeString allocs for N=1500: %v, want at most N=10's %v + 8","digests":["c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff","c46e73fbf1ed43ff"]}, +{"id":"lit:zero_copy_test.go:460","input":"Tokenize([]byte) uses %v allocs, want at most TokenizeString's %v + 1","digests":["82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d","82a0779c2c8ade9d"]} ]} diff --git a/pkg/sql/tokenizer/tokenizer.go b/pkg/sql/tokenizer/tokenizer.go index 5cfbee68..ddd0a924 100644 --- a/pkg/sql/tokenizer/tokenizer.go +++ b/pkg/sql/tokenizer/tokenizer.go @@ -26,6 +26,7 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/metrics" "github.com/ajitpratap0/GoSQLX/pkg/models" + sqldialect "github.com/ajitpratap0/GoSQLX/pkg/sql/dialect" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" ) @@ -311,8 +312,13 @@ type Tokenizer struct { lineStarts []int // Byte offsets of line starts (for position tracking) line int // Current line number (1-based) dialect keywords.SQLDialect // SQL dialect for dialect-specific keyword recognition - logger *slog.Logger // Optional structured logger for verbose tracing - Comments []models.Comment // Comments captured during tokenization + // strEscape holds the backslash rules for string literals. It stays + // StringEscapeLenient (the zero value) until a dialect is chosen + // explicitly, so the default PostgreSQL keyword set still accepts the + // C-style escapes of existing callers. + strEscape sqldialect.StringEscape + logger *slog.Logger // Optional structured logger for verbose tracing + Comments []models.Comment // Comments captured during tokenization // emitted is the number of tokens produced so far by the current call. // Together with len(Comments) it is checked against MaxTokens. @@ -370,14 +376,12 @@ func New() (*Tokenizer, error) { // tokenize time. The returned error is always nil; it is retained for API // compatibility. func NewWithDialect(dialect keywords.SQLDialect) (*Tokenizer, error) { - if dialect == "" || dialect == keywords.DialectUnknown { - dialect = keywords.DialectPostgreSQL - } - return &Tokenizer{ - dialect: dialect, + t := &Tokenizer{ pos: NewPosition(1, 0), lineStarts: []int{0}, - }, nil + } + t.SetDialect(dialect) + return t, nil } // Dialect returns the SQL dialect configured for this tokenizer. @@ -392,9 +396,12 @@ func (t *Tokenizer) Dialect() keywords.SQLDialect { // driven by the configured dialect at tokenize time. func (t *Tokenizer) SetDialect(dialect keywords.SQLDialect) { if dialect == "" || dialect == keywords.DialectUnknown { - dialect = keywords.DialectPostgreSQL + t.dialect = keywords.DialectPostgreSQL + t.strEscape = sqldialect.StringEscapeLenient + return } t.dialect = dialect + t.strEscape = sqldialect.Parse(string(dialect)).StringEscape() } // NewWithKeywords initializes a Tokenizer from a keyword classifier. @@ -594,6 +601,14 @@ func (t *Tokenizer) tokenize(ctx context.Context, input []byte, src string, have // Pre-scan input to build line start indices and estimate token count estimatedTokens := t.prescan() + // A leading UTF-8 byte order mark (U+FEFF) is not part of the SQL: files + // saved by Windows editors start with one. Start after it and make the + // first line begin there so columns count from the first real character. + if len(input) >= 3 && input[0] == 0xEF && input[1] == 0xBB && input[2] == 0xBF { + t.pos.Index = 3 + t.lineStarts[0] = 3 + } + tokens := make([]models.TokenWithSpan, 0, estimatedTokens) done := ctx.Done() @@ -973,18 +988,12 @@ func (t *Tokenizer) readIdentifier() (models.Token, error) { ident := t.src[start:t.pos.Index] - // SQL Server dialect: N'...' national string literal - if t.dialect == keywords.DialectSQLServer && (ident == "N" || ident == "n") { - if t.pos.Index < len(t.input) && t.input[t.pos.Index] == '\'' { - tok, err := t.readQuotedString('\'') - if err != nil { - return models.Token{}, err - } - return models.Token{ - Type: models.TokenTypeNationalStringLiteral, - Value: tok.Value, - Quote: 'N', - }, nil + // Prefixed string literal: N'..', X'..', B'..', E'..', q'[..]' (see + // prefixed_string.go). Only a single letter directly followed by a quote + // qualifies. + if len(ident) == 1 && t.pos.Index < len(t.input) && t.input[t.pos.Index] == '\'' { + if tok, ok, err := t.readPrefixedString(ident[0]); ok || err != nil { + return tok, err } } @@ -1148,15 +1157,13 @@ func isASCII(s string) bool { } // backslashEscapes reports whether '\' starts an escape sequence inside a -// quoted string in the configured dialect. Dialects that don't use C-style -// backslash escapes (Oracle, SQL Server, SQLite, Snowflake, ClickHouse) treat -// '\' as a literal character inside string literals. Only MySQL and -// PostgreSQL (and their derivatives) use backslash escaping; the zero-value -// dialect does too. Both the fast and slow string scanners use this +// quoted string in the configured dialect. PostgreSQL (standard strings), +// Oracle, SQLite and SQL Server treat '\' as an ordinary character; every +// other dialect, and an unconfigured tokenizer, decodes C-style escapes (see +// sqldialect.StringEscape). Both the fast and slow string scanners use this // predicate so they cannot drift apart. func (t *Tokenizer) backslashEscapes() bool { - return t.dialect == keywords.DialectMySQL || t.dialect == keywords.DialectMariaDB || - t.dialect == keywords.DialectPostgreSQL || t.dialect == "" + return t.strEscape != sqldialect.StringEscapeNone } // needsRewrite reports whether the slow quoted readers would write a rune @@ -1732,7 +1739,15 @@ func (t *Tokenizer) readTripleQuotedStringSlow(startPos Position, quote rune) (m ) } -// handleEscapeSequence handles escape sequences in string literals +// handleEscapeSequence decodes one backslash escape in a string literal whose +// dialect treats '\' as an escape character (see backslashEscapes). On entry +// t.pos is at the backslash. +// +// Recognised everywhere: \\ \' \" \` \n \r \t \0 \b \Z. \% and \_ keep their +// backslash (the MySQL rule that lets LIKE patterns survive). Any other +// sequence is not an error: MySQL and MariaDB drop the backslash, every other +// dialect keeps both characters, so the literal always tokenizes and +// re-escaping the value (backslash doubled) reads back identically. func (t *Tokenizer) handleEscapeSequence(buf *bytes.Buffer) error { t.pos.Index++ t.pos.Column++ @@ -1751,16 +1766,23 @@ func (t *Tokenizer) handleEscapeSequence(buf *bytes.Buffer) error { buf.WriteRune('\r') case 't': buf.WriteRune('\t') + case '0': + buf.WriteByte(0) + case 'b': + buf.WriteByte('\b') + case 'Z': + buf.WriteByte(0x1a) + case '%', '_': + buf.WriteByte('\\') + buf.WriteRune(r) default: - return errors.InvalidSyntaxError( - fmt.Sprintf("invalid escape sequence '\\%c'", r), - t.getCurrentPosition(), - t.src, - ) + if t.strEscape != sqldialect.StringEscapeMySQL { + buf.WriteByte('\\') + } + buf.WriteRune(r) } - t.pos.Index += size - t.pos.Column++ + t.pos.AdvanceRune(r, size) return nil } @@ -1773,6 +1795,21 @@ func (t *Tokenizer) readNumber(buf []byte) (models.Token, error) { start = t.pos.Index - len(buf) } + // Hexadecimal literal: 0x1F (MySQL, MariaDB, SQLite, ClickHouse, + // PostgreSQL 16+). Only when a hex digit follows the prefix, so "0x" alone + // stays the number 0 followed by a word. + if t.input[t.pos.Index] == '0' && t.pos.Index+2 < len(t.input) && + (t.input[t.pos.Index+1] == 'x' || t.input[t.pos.Index+1] == 'X') && + isHexDigit(t.input[t.pos.Index+2]) { + t.pos.Index += 2 + t.pos.Column += 2 + for t.pos.Index < len(t.input) && isHexDigit(t.input[t.pos.Index]) { + t.pos.Index++ + t.pos.Column++ + } + return models.Token{Type: models.TokenTypeNumber, Value: t.src[start:t.pos.Index]}, nil + } + // Read integer part for t.pos.Index < len(t.input) { r, size := utf8.DecodeRune(t.input[t.pos.Index:]) @@ -1792,25 +1829,22 @@ func (t *Tokenizer) readNumber(buf []byte) (models.Token, error) { // Look for decimal point r, size := utf8.DecodeRune(t.input[t.pos.Index:]) if r == '.' { - t.pos.AdvanceRune(r, size) - - // Must have at least one digit after decimal - if t.pos.Index >= len(t.input) { - value := t.src[start:t.pos.Index] - return models.Token{}, errors.InvalidNumberError(value+" (expected digit after decimal point)", t.getCurrentPosition(), t.src) - } - - r, _ = utf8.DecodeRune(t.input[t.pos.Index:]) - if r < '0' || r > '9' { - value := t.src[start:t.pos.Index] - return models.Token{}, errors.InvalidNumberError(value+" (expected digit after decimal point)", t.getCurrentPosition(), t.src) + // "5." is a complete number (a trailing decimal point) unless the dot + // belongs to what follows: a second dot or a word ("1.foo", "1..2"). + // An exponent after the dot ("1.e5") still belongs to the number. + if next := t.pos.Index + 1; next < len(t.input) && + (t.input[next] == '.' || (nextIsWordStart(t.input[next:]) && !t.exponentAt(next))) { + return models.Token{ + Type: models.TokenTypeNumber, + Value: t.src[start:t.pos.Index], + }, nil } + t.pos.AdvanceRune(r, size) - // Read fractional part + // Fractional digits are optional ("5."). for t.pos.Index < len(t.input) { r, size = utf8.DecodeRune(t.input[t.pos.Index:]) if r < '0' || r > '9' { - _ = size // Mark as intentionally unused break } t.pos.AdvanceRune(r, size) @@ -1861,6 +1895,52 @@ func (t *Tokenizer) readNumber(buf []byte) (models.Token, error) { }, nil } +// isHexDigit reports whether c is an ASCII hexadecimal digit. +func isHexDigit(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} + +// nextIsWordStart reports whether b begins with a character that can start an +// identifier. +func nextIsWordStart(b []byte) bool { + r, _ := utf8.DecodeRune(b) + return isIdentifierStart(r) +} + +// exponentAt reports whether the bytes at idx form an exponent: e or E, an +// optional sign, then a digit. +func (t *Tokenizer) exponentAt(idx int) bool { + if idx >= len(t.input) || (t.input[idx] != 'e' && t.input[idx] != 'E') { + return false + } + idx++ + if idx < len(t.input) && (t.input[idx] == '+' || t.input[idx] == '-') { + idx++ + } + return idx < len(t.input) && t.input[idx] >= '0' && t.input[idx] <= '9' +} + +// leadingDotNumber reports whether the '.' at t.pos starts a number such as +// ".5": a digit follows and the dot is not a qualifier separator, i.e. the +// previous byte is not part of a name, a closing bracket or a quote ("t.5", +// "(x).5"). +func (t *Tokenizer) leadingDotNumber() bool { + i := t.pos.Index + if i+1 >= len(t.input) || t.input[i+1] < '0' || t.input[i+1] > '9' { + return false + } + if i == 0 { + return true + } + switch c := t.input[i-1]; { + case c >= utf8.RuneSelf, c == '_', c == '$', c == '#', c == ')', c == ']', c == '"', c == '`', c == '\'': + return false + case c >= '0' && c <= '9', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': + return false + } + return true +} + // readPunctuation picks out punctuation or operator tokens func (t *Tokenizer) readPunctuation() (models.Token, error) { if t.pos.Index >= len(t.input) { @@ -1875,8 +1955,8 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { t.pos.AdvanceRune(r, size) return models.Token{Type: models.TokenTypeRightParen, Value: ")"}, nil case '[': - // SQL Server dialect: [identifier] is a quoted identifier - if t.dialect == keywords.DialectSQLServer { + // SQL Server and SQLite: [identifier] is a quoted identifier + if t.dialect == keywords.DialectSQLServer || t.dialect == keywords.DialectSQLite { t.pos.AdvanceRune(r, size) // Consume [ contentStart := t.pos.Index for t.pos.Index < len(t.input) { @@ -1907,6 +1987,9 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { t.pos.AdvanceRune(r, size) return models.Token{Type: models.TokenTypeSemicolon, Value: ";"}, nil case '.': + if t.leadingDotNumber() { + return t.readNumber(nil) + } t.pos.AdvanceRune(r, size) return models.Token{Type: models.TokenTypeDot, Value: "."}, nil case '+': @@ -1946,6 +2029,10 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { t.pos.AdvanceRune(nxtR, nxtSize) return models.Token{Type: models.TokenTypeRArrow, Value: "=>"}, nil } + if nxtR == '=' { + t.pos.AdvanceRune(nxtR, nxtSize) + return models.Token{Type: models.TokenTypeDoubleEq, Value: "=="}, nil + } } return models.Token{Type: models.TokenTypeEq, Value: "="}, nil case '<': @@ -1954,7 +2041,23 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { nxtR, nxtSize := utf8.DecodeRune(t.input[t.pos.Index:]) if nxtR == '=' { t.pos.AdvanceRune(nxtR, nxtSize) + // <=> is the NULL-safe equality operator (MySQL) and the + // pgvector cosine-distance operator (PostgreSQL). + if t.pos.Index < len(t.input) && t.input[t.pos.Index] == '>' { + t.pos.Index++ + t.pos.Column++ + return models.Token{Type: models.TokenTypeSpaceship, Value: "<=>"}, nil + } return models.Token{Type: models.TokenTypeLtEq, Value: "<="}, nil + } else if nxtR == '<' { + t.pos.AdvanceRune(nxtR, nxtSize) + return models.Token{Type: models.TokenTypeShiftLeft, Value: "<<"}, nil + } else if nxtR == '-' && t.pos.Index+1 < len(t.input) && t.input[t.pos.Index+1] == '>' { + // <-> is the distance operator (PostgreSQL pg_trgm, PostGIS, + // pgvector). + t.pos.Index += 2 + t.pos.Column += 2 + return models.Token{Type: models.TokenTypeCustomBinaryOperator, Value: "<->"}, nil } else if nxtR == '>' { t.pos.AdvanceRune(nxtR, nxtSize) return models.Token{Type: models.TokenTypeNeq, Value: "<>"}, nil @@ -1973,6 +2076,10 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { t.pos.AdvanceRune(nxtR, nxtSize) return models.Token{Type: models.TokenTypeGtEq, Value: ">="}, nil } + if nxtR == '>' { + t.pos.AdvanceRune(nxtR, nxtSize) + return models.Token{Type: models.TokenTypeShiftRight, Value: ">>"}, nil + } } return models.Token{Type: models.TokenTypeGt, Value: ">"}, nil case '!': @@ -2008,10 +2115,15 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { t.pos.AdvanceRune(nxtR, nxtSize) return models.Token{Type: models.TokenTypeDoubleColon, Value: "::"}, nil } + if nxtR == '=' { + t.pos.AdvanceRune(nxtR, nxtSize) + return models.Token{Type: models.TokenTypeAssignment, Value: ":="}, nil + } // Oracle bind variables: positional :1, :2 and named :name (#520). - // Gated to Oracle because other dialects use ':' for array slices - // (arr[1:n]) and Snowflake VARIANT paths (col:field). - if t.dialect == keywords.DialectOracle { + // Gated to Oracle and SQLite (named :name and @name parameters) + // because other dialects use ':' for array slices (arr[1:n]) and + // Snowflake VARIANT paths (col:field). + if t.dialect == keywords.DialectOracle || t.dialect == keywords.DialectSQLite { if nxtR >= '0' && nxtR <= '9' { for t.pos.Index < len(t.input) { c := t.input[t.pos.Index] @@ -2164,6 +2276,7 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { // Just a standalone # symbol return models.Token{Type: models.TokenTypeSharp, Value: "#"}, nil case '?': + qStart := t.pos.Index t.pos.AdvanceRune(r, size) // MySQL, MariaDB, and SQLite use a bare ? as a positional parameter // placeholder (e.g. WHERE id = ?). These dialects have no JSON ? @@ -2172,6 +2285,14 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { if t.dialect == keywords.DialectMySQL || t.dialect == keywords.DialectMariaDB || t.dialect == keywords.DialectSQLite { + // SQLite also numbers its parameters: ?1, ?2. + if t.dialect == keywords.DialectSQLite { + for t.pos.Index < len(t.input) && t.input[t.pos.Index] >= '0' && t.input[t.pos.Index] <= '9' { + t.pos.Index++ + t.pos.Column++ + } + return models.Token{Type: models.TokenTypePlaceholder, Value: t.src[qStart:t.pos.Index]}, nil + } return models.Token{Type: models.TokenTypePlaceholder, Value: "?"}, nil } // Check for PostgreSQL JSON operators @@ -2272,6 +2393,31 @@ func (t *Tokenizer) readPunctuation() (models.Token, error) { } // Standalone $ - treat as placeholder return models.Token{Type: models.TokenTypePlaceholder, Value: "$"}, nil + case '^': + t.pos.AdvanceRune(r, size) + // ^@ is the PostgreSQL starts-with operator. + if t.pos.Index < len(t.input) && t.input[t.pos.Index] == '@' { + t.pos.Index++ + t.pos.Column++ + return models.Token{Type: models.TokenTypeCaretAt, Value: "^@"}, nil + } + return models.Token{Type: models.TokenTypeCaret, Value: "^"}, nil + case '{': + if t.dialect == keywords.DialectClickHouse { + if end := t.clickHouseParamEnd(); end > 0 { + start := t.pos.Index + for t.pos.Index < end { + cr, cs := utf8.DecodeRune(t.input[t.pos.Index:]) + t.pos.AdvanceRune(cr, cs) + } + return models.Token{Type: models.TokenTypePlaceholder, Value: t.src[start:end]}, nil + } + } + t.pos.AdvanceRune(r, size) + return models.Token{Type: models.TokenTypeLBrace, Value: "{"}, nil + case '}': + t.pos.AdvanceRune(r, size) + return models.Token{Type: models.TokenTypeRBrace, Value: "}"}, nil case '~': // Handle PostgreSQL regex operators ~ and ~* t.pos.AdvanceRune(r, size) @@ -2391,6 +2537,15 @@ func (t *Tokenizer) scanIdentifierTail() { for t.pos.Index < len(t.input) { if c := t.input[t.pos.Index]; c < utf8.RuneSelf { if !asciiIdentPart[c] { + // '$' continues a name (Oracle v$session, Snowflake + // SYSTEM$TYPEOF, PostgreSQL a$b) and so does '#' in Oracle (a#). + // Neither can start one; scanIdentifierTail is only called + // after the first character. + if c == '$' || (c == '#' && t.dialect == keywords.DialectOracle) { + t.pos.Index++ + t.pos.Column++ + continue + } return } t.pos.Index++ diff --git a/pkg/sql/tokenizer/tokenizer_coverage_test.go b/pkg/sql/tokenizer/tokenizer_coverage_test.go index 77bd146c..2d4759fc 100644 --- a/pkg/sql/tokenizer/tokenizer_coverage_test.go +++ b/pkg/sql/tokenizer/tokenizer_coverage_test.go @@ -383,10 +383,10 @@ func TestNumberFormats(t *testing.T) { wantErr: false, }, { - name: "Invalid decimal - no digit after point", + name: "Trailing decimal point", input: "123.", - wantValue: "", - wantErr: true, + wantValue: "123.", + wantErr: false, }, { name: "Invalid scientific - no digit in exponent", diff --git a/pkg/sql/tokenizer/tsql_test.go b/pkg/sql/tokenizer/tsql_test.go index 7cf9b8e9..51a24f25 100644 --- a/pkg/sql/tokenizer/tsql_test.go +++ b/pkg/sql/tokenizer/tsql_test.go @@ -217,15 +217,16 @@ func TestTSQL_NWithoutQuoteIsIdentifier(t *testing.T) { } } -func TestTSQL_NStringNotInPostgreSQL(t *testing.T) { - // In PostgreSQL dialect, N'hello' should be IDENTIFIER + STRING (two tokens) - tkz, err := NewWithDialect(keywords.DialectPostgreSQL) +func TestTSQL_NStringNotInSQLite(t *testing.T) { + // SQLite has no national strings, so N'hello' is IDENTIFIER + STRING + // (two tokens). + tkz, err := NewWithDialect(keywords.DialectSQLite) if err != nil { t.Fatalf("NewWithDialect error = %v", err) } tokens := tokenize(t, tkz, "N'hello'") if len(tokens) != 2 { - t.Fatalf("expected 2 tokens for PostgreSQL N'hello', got %d", len(tokens)) + t.Fatalf("expected 2 tokens for SQLite N'hello', got %d", len(tokens)) } if tokens[0].Token.Type != models.TokenTypeIdentifier { t.Errorf("expected first token to be identifier, got %v", tokens[0].Token.Type) diff --git a/pkg/sql/tokenizer/zero_copy_test.go b/pkg/sql/tokenizer/zero_copy_test.go index f3cff1b3..40634bf9 100644 --- a/pkg/sql/tokenizer/zero_copy_test.go +++ b/pkg/sql/tokenizer/zero_copy_test.go @@ -74,7 +74,6 @@ var zeroCopyInvalidInputs = []struct { sql string dialect keywords.SQLDialect }{ - {"SELECT 1.", keywords.DialectPostgreSQL}, {"SELECT 1e", keywords.DialectPostgreSQL}, {"SELECT 'x", keywords.DialectPostgreSQL}, {"/* open", keywords.DialectPostgreSQL}, diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index be9163ad..e61eb1ee 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -6,7 +6,6 @@ clickhouse/01_app_crud.sql#10 parse clickhouse/01_app_crud.sql#11 parse clickhouse/01_app_crud.sql#12 parse clickhouse/01_app_crud.sql#13 parse -clickhouse/01_app_crud.sql#18 parse clickhouse/02_reporting.sql#17 drop clickhouse/02_reporting.sql#18 drop clickhouse/02_reporting.sql#19 drop @@ -87,7 +86,6 @@ clickhouse/05_dialect_specific.sql#64 parse clickhouse/05_dialect_specific.sql#65 parse clickhouse/05_dialect_specific.sql#66 parse clickhouse/05_dialect_specific.sql#67 parse -clickhouse/05_dialect_specific.sql#68 idempotent clickhouse/05_dialect_specific.sql#7 drop clickhouse/05_dialect_specific.sql#8 drop clickhouse/05_dialect_specific.sql#9 drop @@ -117,7 +115,6 @@ clickhouse/06_materialized_views.sql#4 parse clickhouse/06_materialized_views.sql#5 parse clickhouse/06_materialized_views.sql#6 drop clickhouse/06_materialized_views.sql#8 drop -clickhouse/06_materialized_views.sql#9 parse clickhouse/07_observability.sql#0 drop clickhouse/07_observability.sql#16 parse clickhouse/07_observability.sql#27 parse @@ -292,7 +289,6 @@ mariadb/06_etl_admin.sql#2 parse mariadb/06_etl_admin.sql#22 parse mariadb/06_etl_admin.sql#24 parse mariadb/06_etl_admin.sql#25 parse -mariadb/06_etl_admin.sql#35 parse mariadb/06_etl_admin.sql#36 parse mariadb/06_etl_admin.sql#37 parse mariadb/06_etl_admin.sql#38 parse @@ -447,12 +443,10 @@ mysql/05_dialect_specific.sql#62 parse mysql/05_dialect_specific.sql#63 parse mysql/05_dialect_specific.sql#64 parse mysql/05_dialect_specific.sql#65 parse -mysql/05_dialect_specific.sql#67 parse mysql/05_dialect_specific.sql#68 parse mysql/05_dialect_specific.sql#69 parse mysql/05_dialect_specific.sql#8 parse mysql/05_dialect_specific.sql#9 reparse -mysql/06_wordpress.sql#12 parse mysql/06_wordpress.sql#13 parse mysql/06_wordpress.sql#19 parse mysql/06_wordpress.sql#20 parse @@ -552,7 +546,6 @@ oracle/01_app_crud.sql#10 parse oracle/01_app_crud.sql#24 parse oracle/01_app_crud.sql#25 parse oracle/01_app_crud.sql#30 parse -oracle/01_app_crud.sql#31 idempotent oracle/01_app_crud.sql#33 parse oracle/01_app_crud.sql#34 parse oracle/02_reporting.sql#17 drop @@ -684,14 +677,8 @@ oracle/07_admin_ops.sql#30 parse oracle/07_admin_ops.sql#31 parse oracle/07_admin_ops.sql#34 parse oracle/07_admin_ops.sql#35 parse -oracle/07_admin_ops.sql#36 parse -oracle/07_admin_ops.sql#37 parse oracle/07_admin_ops.sql#4 parse -oracle/07_admin_ops.sql#40 parse -oracle/07_admin_ops.sql#44 parse -oracle/07_admin_ops.sql#45 parse oracle/07_admin_ops.sql#5 parse -oracle/07_admin_ops.sql#50 parse oracle/07_admin_ops.sql#6 parse oracle/07_admin_ops.sql#7 parse oracle/07_admin_ops.sql#8 parse @@ -754,14 +741,12 @@ postgresql/05_dialect_specific.sql#12 parse postgresql/05_dialect_specific.sql#14 parse postgresql/05_dialect_specific.sql#15 parse postgresql/05_dialect_specific.sql#17 parse -postgresql/05_dialect_specific.sql#27 parse postgresql/05_dialect_specific.sql#28 parse -postgresql/05_dialect_specific.sql#29 parse postgresql/05_dialect_specific.sql#30 parse postgresql/05_dialect_specific.sql#31 parse postgresql/05_dialect_specific.sql#32 reparse postgresql/05_dialect_specific.sql#33 parse -postgresql/05_dialect_specific.sql#35 parse +postgresql/05_dialect_specific.sql#35 drop postgresql/05_dialect_specific.sql#36 parse postgresql/05_dialect_specific.sql#38 parse postgresql/05_dialect_specific.sql#39 parse @@ -862,8 +847,6 @@ snowflake/04_window_analytics.sql#14 drop snowflake/04_window_analytics.sql#2 drop snowflake/04_window_analytics.sql#3 drop snowflake/04_window_analytics.sql#4 drop -snowflake/05_dialect_specific.sql#14 idempotent -snowflake/05_dialect_specific.sql#15 idempotent snowflake/05_dialect_specific.sql#16 drop snowflake/05_dialect_specific.sql#17 drop snowflake/05_dialect_specific.sql#18 drop @@ -874,11 +857,9 @@ snowflake/05_dialect_specific.sql#22 parse snowflake/05_dialect_specific.sql#23 drop snowflake/05_dialect_specific.sql#24 parse snowflake/05_dialect_specific.sql#25 parse -snowflake/05_dialect_specific.sql#27 parse snowflake/05_dialect_specific.sql#28 parse snowflake/05_dialect_specific.sql#40 parse -snowflake/05_dialect_specific.sql#42 idempotent -snowflake/05_dialect_specific.sql#43 parse +snowflake/05_dialect_specific.sql#42 drop snowflake/05_dialect_specific.sql#45 parse snowflake/05_dialect_specific.sql#46 parse snowflake/05_dialect_specific.sql#47 parse @@ -890,7 +871,6 @@ snowflake/05_dialect_specific.sql#8 parse snowflake/06_etl_merge.sql#10 parse snowflake/06_etl_merge.sql#13 reparse snowflake/06_etl_merge.sql#14 reparse -snowflake/06_etl_merge.sql#15 parse snowflake/06_etl_merge.sql#16 reparse snowflake/06_etl_merge.sql#17 reparse snowflake/06_etl_merge.sql#18 parse @@ -942,8 +922,6 @@ snowflake/07_admin.sql#35 parse snowflake/07_admin.sql#36 parse snowflake/07_admin.sql#37 parse snowflake/07_admin.sql#4 reparse -snowflake/07_admin.sql#43 parse -snowflake/07_admin.sql#44 parse snowflake/07_admin.sql#46 parse snowflake/07_admin.sql#47 parse snowflake/07_admin.sql#48 parse @@ -976,8 +954,6 @@ snowflake/10_complex_nested.sql#26 parse snowflake/10_complex_nested.sql#34 parse snowflake/10_complex_nested.sql#39 parse sqlite/01_app_crud.sql#0 drop -sqlite/01_app_crud.sql#4 parse -sqlite/01_app_crud.sql#7 parse sqlite/01_app_crud.sql#8 parse sqlite/02_upsert_replace.sql#0 drop sqlite/02_upsert_replace.sql#13 parse @@ -1025,8 +1001,6 @@ sqlite/05_dialect_specific.sql#12 parse sqlite/05_dialect_specific.sql#14 parse sqlite/05_dialect_specific.sql#15 parse sqlite/05_dialect_specific.sql#16 parse -sqlite/05_dialect_specific.sql#17 parse -sqlite/05_dialect_specific.sql#18 parse sqlite/05_dialect_specific.sql#20 parse sqlite/05_dialect_specific.sql#23 parse sqlite/05_dialect_specific.sql#26 parse @@ -1051,8 +1025,6 @@ sqlite/07_ctes_recursive.sql#11 parse sqlite/07_ctes_recursive.sql#2 parse sqlite/07_ctes_recursive.sql#7 drop sqlite/07_ctes_recursive.sql#8 drop -sqlite/08_reporting.sql#13 parse -sqlite/08_reporting.sql#15 parse sqlite/08_reporting.sql#16 parse sqlite/09_etl_admin.sql#1 drop sqlite/09_etl_admin.sql#10 parse @@ -1085,44 +1057,24 @@ sqlite/10_mobile_offline_sync.sql#7 parse sqlite/10_mobile_offline_sync.sql#8 parse sqlite/11_deep_nesting.sql#14 parse sqlite/11_deep_nesting.sql#19 parse -sqlite/11_deep_nesting.sql#30 parse -sqlite/11_deep_nesting.sql#31 parse sqlite/11_deep_nesting.sql#7 parse sqlite/12_orm_generated.sql#10 parse sqlite/12_orm_generated.sql#11 drop sqlite/12_orm_generated.sql#12 drop sqlite/12_orm_generated.sql#14 parse -sqlite/12_orm_generated.sql#16 parse sqlite/12_orm_generated.sql#8 parse sqlite/12_orm_generated.sql#9 drop -sqlserver/01_app_crud.sql#1 parse sqlserver/01_app_crud.sql#10 parse -sqlserver/01_app_crud.sql#11 parse sqlserver/01_app_crud.sql#12 parse sqlserver/01_app_crud.sql#13 parse sqlserver/01_app_crud.sql#14 parse -sqlserver/01_app_crud.sql#16 parse -sqlserver/01_app_crud.sql#18 parse -sqlserver/01_app_crud.sql#2 parse sqlserver/01_app_crud.sql#20 parse -sqlserver/01_app_crud.sql#3 parse -sqlserver/01_app_crud.sql#4 parse -sqlserver/01_app_crud.sql#5 parse -sqlserver/01_app_crud.sql#6 parse -sqlserver/01_app_crud.sql#7 parse sqlserver/01_app_crud.sql#8 parse sqlserver/01_app_crud.sql#9 parse -sqlserver/02_reporting.sql#10 parse -sqlserver/02_reporting.sql#17 parse -sqlserver/02_reporting.sql#18 parse sqlserver/02_reporting.sql#19 parse -sqlserver/02_reporting.sql#20 parse sqlserver/02_reporting.sql#21 drop -sqlserver/02_reporting.sql#23 parse -sqlserver/02_reporting.sql#24 parse sqlserver/02_reporting.sql#7 parse sqlserver/02_reporting.sql#8 drop -sqlserver/02_reporting.sql#9 parse sqlserver/03_ddl_migrations.sql#0 parse sqlserver/03_ddl_migrations.sql#1 parse sqlserver/03_ddl_migrations.sql#10 parse @@ -1150,14 +1102,12 @@ sqlserver/05_dialect_specific.sql#0 reparse sqlserver/05_dialect_specific.sql#1 reparse sqlserver/05_dialect_specific.sql#11 parse sqlserver/05_dialect_specific.sql#12 parse -sqlserver/05_dialect_specific.sql#13 parse sqlserver/05_dialect_specific.sql#14 parse sqlserver/05_dialect_specific.sql#16 parse sqlserver/05_dialect_specific.sql#17 parse sqlserver/05_dialect_specific.sql#18 parse sqlserver/05_dialect_specific.sql#19 parse sqlserver/05_dialect_specific.sql#2 parse -sqlserver/05_dialect_specific.sql#26 parse sqlserver/05_dialect_specific.sql#27 parse sqlserver/05_dialect_specific.sql#28 parse sqlserver/05_dialect_specific.sql#3 parse @@ -1183,10 +1133,8 @@ sqlserver/06_merge_etl.sql#4 parse sqlserver/07_ctes_deep.sql#0 parse sqlserver/07_ctes_deep.sql#1 parse sqlserver/07_ctes_deep.sql#11 parse -sqlserver/07_ctes_deep.sql#14 parse sqlserver/07_ctes_deep.sql#15 parse sqlserver/07_ctes_deep.sql#16 parse -sqlserver/07_ctes_deep.sql#2 parse sqlserver/08_procs_batches.sql#0 parse sqlserver/08_procs_batches.sql#1 parse sqlserver/08_procs_batches.sql#10 parse @@ -1228,17 +1176,12 @@ sqlserver/08_procs_batches.sql#7 parse sqlserver/08_procs_batches.sql#8 parse sqlserver/08_procs_batches.sql#9 parse sqlserver/09_admin_dmv.sql#1 parse -sqlserver/09_admin_dmv.sql#13 parse sqlserver/09_admin_dmv.sql#15 parse sqlserver/09_admin_dmv.sql#16 parse sqlserver/09_admin_dmv.sql#17 parse sqlserver/09_admin_dmv.sql#18 parse sqlserver/09_admin_dmv.sql#19 parse -sqlserver/09_admin_dmv.sql#3 parse sqlserver/09_admin_dmv.sql#4 parse -sqlserver/09_admin_dmv.sql#7 parse -sqlserver/09_admin_dmv.sql#9 parse -sqlserver/10_etl_mixed.sql#1 parse sqlserver/10_etl_mixed.sql#10 parse sqlserver/10_etl_mixed.sql#11 parse sqlserver/10_etl_mixed.sql#12 parse @@ -1246,10 +1189,5 @@ sqlserver/10_etl_mixed.sql#13 parse sqlserver/10_etl_mixed.sql#14 parse sqlserver/10_etl_mixed.sql#15 parse sqlserver/10_etl_mixed.sql#18 drop -sqlserver/10_etl_mixed.sql#19 parse -sqlserver/10_etl_mixed.sql#2 parse -sqlserver/10_etl_mixed.sql#20 parse -sqlserver/10_etl_mixed.sql#21 parse sqlserver/10_etl_mixed.sql#25 reparse -sqlserver/10_etl_mixed.sql#28 parse sqlserver/10_etl_mixed.sql#8 parse From 109d352d6922004bb45012bbaf045cd527118707 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 18:19:42 +0530 Subject: [PATCH 07/95] fix(parser): accept keywords used as column names FIRST, LAST, NEXT, FILTER, SHARE, SOURCE, TARGET, MATCHED, FINAL, TEMPORARY and the role-option words were rejected where a column reference starts (SELECT first, key FROM t), although no clause gives them a keyword meaning there. Accept them as column names and qualified name parts; KEY, INDEX and VIEW stay rejected in MySQL, MariaDB, SQL Server and Oracle, where they are reserved, and none of the words becomes a function name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/parser/expressions_literal.go | 2 +- pkg/sql/parser/keyword_identifiers_test.go | 26 +++++++++++++++++ pkg/sql/parser/utility_stmt.go | 34 ++++++++++++++++++---- testdata/realworld/known_failures.txt | 4 --- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 19b458a8..d524d777 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -597,6 +597,6 @@ func (p *Parser) parseMultiPartColumnRef(ident *ast.Identifier) error { // qualified column reference: an identifier, a non-reserved keyword (t.KEY, // schema.INDEX), or - in ClickHouse - a numeric tuple element (d.1). func (p *Parser) isQualifiedNamePart() bool { - return p.isIdentifier() || p.isNonReservedKeyword() || + return p.isIdentifier() || p.isNonReservedKeyword() || p.isKeywordColumnName() || (p.dialect == string(keywords.DialectClickHouse) && p.isNumericLiteral()) } diff --git a/pkg/sql/parser/keyword_identifiers_test.go b/pkg/sql/parser/keyword_identifiers_test.go index 1138abfe..258e32a7 100644 --- a/pkg/sql/parser/keyword_identifiers_test.go +++ b/pkg/sql/parser/keyword_identifiers_test.go @@ -88,6 +88,16 @@ func TestParser_KeywordColumnNames(t *testing.T) { {keywords.DialectMySQL, "SELECT url FROM links", "url", ""}, {keywords.DialectPostgreSQL, "SELECT member FROM teams WHERE member = 1", "member", ""}, {keywords.DialectSnowflake, "SELECT policy FROM t", "policy", ""}, + {keywords.DialectPostgreSQL, "SELECT key FROM settings WHERE key = 'a'", "key", ""}, + {keywords.DialectSQLite, "SELECT s.key FROM settings s", "key", "s"}, + {keywords.DialectPostgreSQL, "SELECT index FROM t", "index", ""}, + {keywords.DialectPostgreSQL, "SELECT first, last FROM people", "first", ""}, + {keywords.DialectMySQL, "SELECT next FROM t", "next", ""}, + {keywords.DialectMySQL, "SELECT source, target FROM edges", "source", ""}, + {keywords.DialectPostgreSQL, "SELECT filter FROM t", "filter", ""}, + {keywords.DialectMySQL, "SELECT share FROM t", "share", ""}, + {keywords.DialectSnowflake, "SELECT final FROM t", "final", ""}, + {keywords.DialectPostgreSQL, "SELECT temporary FROM t", "temporary", ""}, } for _, tt := range tests { t.Run(string(tt.dialect)+"/"+tt.sql, func(t *testing.T) { @@ -101,6 +111,22 @@ func TestParser_KeywordColumnNames(t *testing.T) { } }) } + + // KEY, INDEX and VIEW are reserved in MySQL, MariaDB, SQL Server's and + // Oracle's grammars; a keyword followed by '(' is not a column. + for _, tc := range []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectMySQL, "SELECT key FROM t"}, + {keywords.DialectMariaDB, "SELECT index FROM t"}, + {keywords.DialectOracle, "SELECT view FROM t"}, + {keywords.DialectPostgreSQL, "SELECT share(1)"}, + } { + if _, err := ParseWithDialect(tc.sql, tc.dialect); err == nil { + t.Errorf("%s: %q parsed, want an error", tc.dialect, tc.sql) + } + } } // A delimited identifier is a name, never a keyword: `key`, `from` and diff --git a/pkg/sql/parser/utility_stmt.go b/pkg/sql/parser/utility_stmt.go index 5a7f2c8b..74e1ad11 100644 --- a/pkg/sql/parser/utility_stmt.go +++ b/pkg/sql/parser/utility_stmt.go @@ -727,17 +727,41 @@ func (p *Parser) isAliasAfterAS() bool { } // isKeywordColumnName reports whether the current token is a keyword that -// PostgreSQL and the other dialects accept unquoted as a column or table name -// (SELECT owner FROM t). These words are keyword tokens only so that role and -// ALTER statements can match them by type; as an operand they are plain -// names. +// may be written unquoted as a column name in the current dialect +// (SELECT owner, first, key FROM t). The parser reaches it only where an +// operand starts, where none of these words has a keyword meaning: role +// options (OWNER, URL, VALID, ...) are keyword tokens only so the role and +// ALTER statements can match them, and FIRST / LAST / NEXT / FILTER / +// SHARE / SOURCE / TARGET / MATCHED / FINAL are keywords only inside their +// clauses. KEY, INDEX and VIEW are reserved in MySQL, MariaDB, SQL Server +// and Oracle, so there they must stay quoted. A word followed by '(' is not +// taken as a name, so these keywords do not become function names. func (p *Parser) isKeywordColumnName() bool { + if p.peekToken().Token.Type == models.TokenTypeLParen { + return false + } switch p.currentToken.Token.Type { case models.TokenTypeOwner, models.TokenTypeUrl, models.TokenTypeMember, models.TokenTypeConnector, models.TokenTypePolicy, models.TokenTypeUntil, models.TokenTypeValid, models.TokenTypeDcproperties, models.TokenTypeNologin, - models.TokenTypeNocreatedb, models.TokenTypeNocreaterole, models.TokenTypeNosuperuser: + models.TokenTypeNocreatedb, models.TokenTypeNocreaterole, models.TokenTypeNosuperuser, + models.TokenTypeFirst, models.TokenTypeLast, models.TokenTypeFilter, + models.TokenTypeShare, models.TokenTypeSource, models.TokenTypeTarget, + models.TokenTypeMatched: + return true + case models.TokenTypeNext: + // NEXT VALUE FOR seq is a sequence expression. + return !strings.EqualFold(p.peekToken().Token.Value, "VALUE") + case models.TokenTypeKey, models.TokenTypeIndex, models.TokenTypeView: + switch p.dialect { + case string(keywords.DialectMySQL), string(keywords.DialectMariaDB), + string(keywords.DialectSQLServer), string(keywords.DialectOracle): + return false + } return true + case models.TokenTypeKeyword: + v := p.currentToken.Token.Value + return strings.EqualFold(v, "FINAL") || strings.EqualFold(v, "TEMPORARY") } return false } diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index 885486d3..a2e4768d 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -629,7 +629,6 @@ postgresql/06_cte_dml_etl.sql#22 parse postgresql/06_cte_dml_etl.sql#3 drop postgresql/06_cte_dml_etl.sql#5 parse postgresql/06_cte_dml_etl.sql#6 parse -postgresql/06_cte_dml_etl.sql#7 parse postgresql/06_cte_dml_etl.sql#9 parse postgresql/07_admin_ops.sql#20 parse postgresql/07_admin_ops.sql#41 parse @@ -713,7 +712,6 @@ snowflake/06_etl_merge.sql#2 parse snowflake/06_etl_merge.sql#20 reparse snowflake/06_etl_merge.sql#21 parse snowflake/06_etl_merge.sql#22 reparse -snowflake/06_etl_merge.sql#23 parse snowflake/07_admin.sql#21 reparse snowflake/07_admin.sql#22 parse snowflake/07_admin.sql#23 parse @@ -811,13 +809,11 @@ sqlite/06_json.sql#18 parse sqlite/06_json.sql#21 parse sqlite/06_json.sql#22 parse sqlite/07_ctes_recursive.sql#10 parse -sqlite/07_ctes_recursive.sql#11 parse sqlite/07_ctes_recursive.sql#2 parse sqlite/07_ctes_recursive.sql#7 drop sqlite/07_ctes_recursive.sql#8 drop sqlite/08_reporting.sql#13 parse sqlite/08_reporting.sql#15 parse -sqlite/08_reporting.sql#16 parse sqlite/09_etl_admin.sql#1 drop sqlite/09_etl_admin.sql#10 parse sqlite/09_etl_admin.sql#2 parse From 263f6579cda518f1a9fd0e4de0c5e01071ee3ce4 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 18:20:35 +0530 Subject: [PATCH 08/95] fix(parser): parse and render SELECT-body dialect clauses without dropping them LIMIT/OFFSET/FETCH values are now expressions when they are not plain integers ($1, ?, @n, -1, 1 + 1, subqueries), with LIMIT ALL, OFFSET before LIMIT, OFFSET n ROWS, FETCH FIRST ROW ONLY and SQLite LIMIT a, b. The new SelectStatement fields (LimitExpr, OffsetExpr, LimitAll, OffsetRows, ...) sit beside the existing *int fields, which stay filled for integers. The formatter and SelectStatement.SQL() now render every SELECT field: QUALIFY, START WITH / CONNECT BY, PREWHERE, ARRAY JOIN, TOP inside derived tables (keeping TOP (n) parentheses), OFFSET ... ROWS before FETCH, and CROSS/OUTER APPLY without a trailing JOIN. A reflection test fails when a new SelectStatement field is not rendered by both renderers. New syntax, dialect-gated: - MySQL select modifiers (SQL_CALC_FOUND_ROWS, STRAIGHT_JOIN, ...), STRAIGHT_JOIN joins, JOIN without ON, LOCK IN SHARE MODE, INTO @vars / OUTFILE / DUMPFILE; MariaDB/Oracle FOR UPDATE WAIT n - SELECT ... INTO table (SQL Server, PostgreSQL) and WHERE / GROUP BY / HAVING without FROM - GROUP BY ... WITH ROLLUP / CUBE / TOTALS kept as written (MySQL rejects ROLLUP(...)), GROUP BY ALL (Snowflake, ClickHouse, BigQuery) - ClickHouse LIMIT n BY, LIMIT n WITH TIES, FORMAT, INTERPOLATE, GLOBAL and ANY/ALL/ASOF/SEMI/ANTI join strictness, * EXCEPT / REPLACE - Snowflake TOP n, * EXCLUDE / REPLACE / RENAME / ILIKE, ASOF JOIN ... MATCH_CONDITION - SQL Server FOR JSON / FOR XML / FOR BROWSE, OPTION (...) query hints, HASH/LOOP/MERGE/REMOTE join hints; Oracle CROSS/OUTER APPLY, ORDER SIBLINGS BY, START WITH after CONNECT BY; PostgreSQL ORDER BY ... USING op - a comma-separated table after an explicit join Non-integer LIMIT literals such as 10.5 are no longer truncated to an int; they are kept verbatim in LimitExpr so formatting never changes the row count. The real-world corpus ratchet drops 95 known failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_corpus_test.go | 4 +- cmd/gosqlx/cmd/parser_cmd.go | 4 +- cmd/gosqlx/internal/output/json.go | 2 +- pkg/advisor/rules_advanced.go | 4 +- pkg/formatter/render.go | 120 ++- pkg/formatter/render_select_ext.go | 389 +++++++ pkg/formatter/select_ext_render_test.go | 276 +++++ .../rules/naming/missing_order_by_limit.go | 4 +- pkg/sql/ast/ast_clauses.go | 46 +- pkg/sql/ast/ast_statements.go | 18 +- pkg/sql/ast/pool_statement_release.go | 12 +- pkg/sql/ast/select_ext.go | 807 +++++++++++++++ pkg/sql/ast/select_ext_test.go | 225 ++++ pkg/sql/ast/sql.go | 78 +- pkg/sql/ast/sql_golden_test.go | 6 +- pkg/sql/ast/star_expression.go | 135 +++ pkg/sql/parser/clause_alloc_free_test.go | 104 +- pkg/sql/parser/clickhouse_select_settings.go | 25 +- pkg/sql/parser/parser.go | 4 +- pkg/sql/parser/select.go | 137 ++- pkg/sql/parser/select_clauses.go | 373 ++++--- pkg/sql/parser/select_ext.go | 976 ++++++++++++++++++ pkg/sql/parser/select_ext_test.go | 535 ++++++++++ pkg/sql/parser/select_set_ops.go | 6 +- pkg/sql/parser/select_subquery.go | 4 +- pkg/transform/limit.go | 8 + testdata/realworld/known_failures.txt | 95 -- 27 files changed, 3976 insertions(+), 421 deletions(-) create mode 100644 pkg/formatter/render_select_ext.go create mode 100644 pkg/formatter/select_ext_render_test.go create mode 100644 pkg/sql/ast/select_ext.go create mode 100644 pkg/sql/ast/select_ext_test.go create mode 100644 pkg/sql/ast/star_expression.go create mode 100644 pkg/sql/parser/select_ext.go create mode 100644 pkg/sql/parser/select_ext_test.go diff --git a/cmd/gosqlx/cmd/format_corpus_test.go b/cmd/gosqlx/cmd/format_corpus_test.go index cb8afde4..ed6d4a35 100644 --- a/cmd/gosqlx/cmd/format_corpus_test.go +++ b/cmd/gosqlx/cmd/format_corpus_test.go @@ -30,11 +30,9 @@ import ( // rendering gap. For these the CLI must refuse to format (return an error) // rather than emit changed SQL; once the gap is fixed they simply pass. var corpusFormatKnownGaps = map[string]string{ + "corpus/orm/sqlx_queries.sql": "follow-up: x @> ARRAY[$1]::varchar[] binds :: over @> and renders CAST(... AS varchar[]), which does not re-parse", "corpus/orm/gorm_joins.sql": "follow-up: a name quoted only for case (\"Company\") renders bare; the AST does not record quoting, so the CLI refuses", - "mssql/13_cross_apply.sql": "follow-up: CROSS APPLY rendered as CROSS APPLY JOIN, TOP lost in derived table", - "mssql/14_outer_apply.sql": "follow-up: OUTER APPLY rendered as OUTER APPLY JOIN, TOP lost in derived table", "mysql/29_full_text_search.sql": "follow-up: MATCH ... AGAINST rendered as AGAINST AGAINST(...)", - "oracle/08_connect_by_path.sql": "follow-up: START WITH / CONNECT BY not rendered", "oracle/19_regexp_functions.sql": "follow-up: string literal backslashes doubled for a dialect that keeps them literal", } diff --git a/cmd/gosqlx/cmd/parser_cmd.go b/cmd/gosqlx/cmd/parser_cmd.go index 09716e68..f0d9e3c4 100644 --- a/cmd/gosqlx/cmd/parser_cmd.go +++ b/cmd/gosqlx/cmd/parser_cmd.go @@ -292,7 +292,7 @@ func (p *Parser) displayTree(astObj *ast.AST) error { if len(s.OrderBy) > 0 { fmt.Fprintf(p.Out, "%s├── OrderBy\n", childPrefix) } - if s.Limit != nil { + if s.HasLimit() { fmt.Fprintf(p.Out, "%s└── Limit\n", childPrefix) } } @@ -331,7 +331,7 @@ func convertStatement(stmt ast.Statement) StatementDisplay { if len(s.OrderBy) > 0 { details["has_order_by"] = true } - if s.Limit != nil { + if s.HasLimit() { details["has_limit"] = true } if s.With != nil { diff --git a/cmd/gosqlx/internal/output/json.go b/cmd/gosqlx/internal/output/json.go index 46649dad..54d52af5 100644 --- a/cmd/gosqlx/internal/output/json.go +++ b/cmd/gosqlx/internal/output/json.go @@ -510,7 +510,7 @@ func convertStatementToJSON(stmt ast.Statement) JSONStatement { result.Details["has_where"] = s.Where != nil result.Details["has_group_by"] = len(s.GroupBy) > 0 result.Details["has_order_by"] = len(s.OrderBy) > 0 - result.Details["has_limit"] = s.Limit != nil + result.Details["has_limit"] = s.HasLimit() result.Details["has_distinct"] = s.Distinct if s.With != nil { result.Details["has_with"] = true diff --git a/pkg/advisor/rules_advanced.go b/pkg/advisor/rules_advanced.go index ad116af2..c0c05fb5 100644 --- a/pkg/advisor/rules_advanced.go +++ b/pkg/advisor/rules_advanced.go @@ -117,7 +117,7 @@ func (r *RedundantOrderByInCTERule) Analyze(stmt ast.Statement) []Suggestion { continue } // ORDER BY without LIMIT/FETCH/TOP is redundant in a CTE - hasLimit := cteQuery.Limit != nil || cteQuery.Fetch != nil || cteQuery.Top != nil + hasLimit := cteQuery.Limit != nil || cteQuery.LimitExpr != nil || cteQuery.Fetch != nil || cteQuery.Top != nil if len(cteQuery.OrderBy) > 0 && !hasLimit { suggestions = append(suggestions, Suggestion{ RuleID: r.ID(), @@ -323,7 +323,7 @@ func (r *MissingLimitRule) Analyze(stmt ast.Statement) []Suggestion { if !ok { return nil } - hasLimit := sel.Limit != nil || sel.Fetch != nil || sel.Top != nil + hasLimit := sel.Limit != nil || sel.LimitExpr != nil || sel.Fetch != nil || sel.Top != nil if len(sel.OrderBy) > 0 && !hasLimit { return []Suggestion{{ RuleID: r.ID(), diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 27e8c32f..3fb76fc9 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -231,24 +231,16 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(" ") } - // Render TOP clause (SQL Server). Emitted between SELECT [DISTINCT] and columns. - if s.Top != nil { - sb.WriteString(f.kw("TOP")) - sb.WriteString(" ") - sb.WriteString(FormatExpression(s.Top.Count, opts)) - if s.Top.IsPercent { - sb.WriteString(" ") - sb.WriteString(f.kw("PERCENT")) - } - if s.Top.WithTies { - sb.WriteString(" ") - sb.WriteString(f.kw("WITH TIES")) - } - sb.WriteString(" ") - } + // MySQL select modifiers and TOP, between SELECT [DISTINCT] and columns. + writeSelectPrologue(sb, s, f) sb.WriteString(exprListSQL(s.Columns)) + if s.Into != nil && !s.Into.Trailing { + sb.WriteString(" ") + sb.WriteString(selectIntoSQL(s.Into, f)) + } + if len(s.From) > 0 { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("FROM")) @@ -266,11 +258,23 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(joinSQL(&j, f)) } + if s.ArrayJoin != nil { + sb.WriteString(f.clauseSep()) + sb.WriteString(arrayJoinSQL(s.ArrayJoin, f)) + } + if s.Sample != nil { sb.WriteString(f.clauseSep()) sb.WriteString(sampleSQL(s.Sample, f)) } + if s.PrewhereClause != nil { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("PREWHERE")) + sb.WriteString(" ") + sb.WriteString(FormatExpression(s.PrewhereClause, opts)) + } + if s.Where != nil { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("WHERE")) @@ -278,11 +282,9 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(FormatExpression(s.Where, opts)) } - if len(s.GroupBy) > 0 { + if gb := groupBySQL(s, f); gb != "" { sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("GROUP BY")) - sb.WriteString(" ") - sb.WriteString(exprListSQL(s.GroupBy)) + sb.WriteString(gb) } if s.Having != nil { @@ -292,6 +294,9 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(FormatExpression(s.Having, opts)) } + // QUALIFY, START WITH, CONNECT BY + writeHierarchical(sb, s, f) + if len(s.Windows) > 0 { sb.WriteString(f.clauseSep()) sb.WriteString(f.kw("WINDOW")) @@ -305,39 +310,36 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { if len(s.OrderBy) > 0 { sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("ORDER BY")) + if s.OrderSiblings { + sb.WriteString(f.kw("ORDER SIBLINGS BY")) + } else { + sb.WriteString(f.kw("ORDER BY")) + } sb.WriteString(" ") sb.WriteString(orderBySQL(s.OrderBy)) } - - if s.Limit != nil { - sb.WriteString(f.clauseSep()) - fmt.Fprintf(sb, "%s %d", f.kw("LIMIT"), *s.Limit) + if s.Interpolate != nil { + sb.WriteString(interpolateSQL(s.Interpolate, f)) } - if s.Offset != nil { - sb.WriteString(f.clauseSep()) - fmt.Fprintf(sb, "%s %d", f.kw("OFFSET"), *s.Offset) - } + // LIMIT BY, LIMIT, OFFSET + writeLimitOffsetClauses(sb, s, f) if s.Fetch != nil { sb.WriteString(fetchSQL(s.Fetch, f)) } + if s.Into != nil && s.Into.Trailing { + sb.WriteString(f.clauseSep()) + sb.WriteString(selectIntoSQL(s.Into, f)) + } + if s.For != nil { sb.WriteString(forSQL(s.For)) } - if len(s.Settings) > 0 { - sb.WriteString(f.clauseSep()) - sb.WriteString(f.kw("SETTINGS")) - sb.WriteString(" ") - parts := make([]string, len(s.Settings)) - for i := range s.Settings { - parts[i] = s.Settings[i].SQL() - } - sb.WriteString(strings.Join(parts, ", ")) - } + // FOR JSON/XML, SETTINGS, OPTION (...), FORMAT + writeSelectEpilogue(sb, s, f) if opts.AddSemicolon { sb.WriteString(";") @@ -1344,7 +1346,9 @@ func orderBySQL(orders []ast.OrderByExpression) string { parts := make([]string, len(orders)) for i, o := range orders { s := exprSQL(o.Expression) - if !o.Ascending { + if op := ast.OrderUsingOperatorText(o.UsingOperator); op != "" { + s += " USING " + op + } else if !o.Ascending { s += " DESC" } if o.NullsFirst != nil { @@ -1512,14 +1516,22 @@ func sampleSQL(s *ast.SampleClause, f *nodeFormatter) string { // joinSQL renders a JOIN clause. func joinSQL(j *ast.JoinClause, f *nodeFormatter) string { var sb strings.Builder - // An unknown join type is dropped, leaving a plain JOIN. - if t := ast.JoinTypeText(j.Type); t != "" { - sb.WriteString(f.kw(t)) - sb.WriteString(" ") + if j.Comma { + sb.WriteString(", ") + sb.WriteString(tableRefSQL(&j.Right, f)) + return sb.String() } - sb.WriteString(f.kw("JOIN")) + // An unknown join type is dropped, leaving a plain JOIN. + sb.WriteString(joinKeywordsSQL(j, f)) sb.WriteString(" ") sb.WriteString(tableRefSQL(&j.Right, f)) + if j.MatchCondition != nil { + sb.WriteString(" ") + sb.WriteString(f.kw("MATCH_CONDITION")) + sb.WriteString(" (") + sb.WriteString(exprSQL(j.MatchCondition)) + sb.WriteString(")") + } if len(j.Using) > 0 { sb.WriteString(" ") sb.WriteString(f.kw("USING")) @@ -1595,13 +1607,16 @@ func fetchSQL(fc *ast.FetchClause, f *nodeFormatter) string { fmt.Fprintf(&sb, " %s %s", f.kw("FETCH"), f.kw(fetchType)) if fc.FetchValue != nil { fmt.Fprintf(&sb, " %d", *fc.FetchValue) + } else if fc.FetchExpr != nil { + sb.WriteString(" ") + sb.WriteString(exprSQL(fc.FetchExpr)) } if fc.IsPercent { sb.WriteString(" ") sb.WriteString(f.kw("PERCENT")) } sb.WriteString(" ") - sb.WriteString(f.kw("ROWS")) + sb.WriteString(f.kw(ast.FetchRowsKeyword(fc))) if fc.WithTies { sb.WriteString(" ") sb.WriteString(f.kw("WITH TIES")) @@ -1615,6 +1630,17 @@ func fetchSQL(fc *ast.FetchClause, f *nodeFormatter) string { // forSQL renders a FOR UPDATE/SHARE clause. func forSQL(f *ast.ForClause) string { var sb strings.Builder + if f.LockInShareMode { + sb.WriteString(" LOCK IN SHARE MODE") + if f.NoWait { + sb.WriteString(" NOWAIT") + } + if f.Wait != nil { + sb.WriteString(" WAIT ") + sb.WriteString(exprSQL(f.Wait)) + } + return sb.String() + } sb.WriteString(" FOR ") sb.WriteString(ast.LockTypeText(f.LockType)) if len(f.Tables) > 0 { @@ -1627,6 +1653,10 @@ func forSQL(f *ast.ForClause) string { if f.SkipLocked { sb.WriteString(" SKIP LOCKED") } + if f.Wait != nil { + sb.WriteString(" WAIT ") + sb.WriteString(exprSQL(f.Wait)) + } return sb.String() } diff --git a/pkg/formatter/render_select_ext.go b/pkg/formatter/render_select_ext.go new file mode 100644 index 00000000..2de8ac24 --- /dev/null +++ b/pkg/formatter/render_select_ext.go @@ -0,0 +1,389 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// render_select_ext.go renders the SELECT clauses that renderSelect used to +// drop or mangle: select modifiers, TOP parentheses, INTO, ARRAY JOIN, +// PREWHERE, GROUP BY modifiers, QUALIFY, START WITH / CONNECT BY, ORDER +// SIBLINGS BY, LIMIT BY, LIMIT/OFFSET expressions, FOR JSON/XML, OPTION (...) +// and FORMAT. + +package formatter + +import ( + "strconv" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// writeSelectPrologue writes the MySQL select modifiers and the TOP clause, +// each followed by a space. +func writeSelectPrologue(sb *strings.Builder, s *ast.SelectStatement, f *nodeFormatter) { + for _, m := range s.SelectModifiers { + if t := ast.SelectModifierText(m); t != "" { + sb.WriteString(f.kw(t)) + sb.WriteString(" ") + } + } + if s.Top == nil || s.Top.Count == nil { + return + } + sb.WriteString(f.kw("TOP")) + sb.WriteString(" ") + if ast.TopNeedsParens(s.Top) { + sb.WriteString("(") + sb.WriteString(FormatExpression(s.Top.Count, f.opts)) + sb.WriteString(")") + } else { + sb.WriteString(FormatExpression(s.Top.Count, f.opts)) + } + if s.Top.IsPercent { + sb.WriteString(" ") + sb.WriteString(f.kw("PERCENT")) + } + if s.Top.WithTies { + sb.WriteString(" ") + sb.WriteString(f.kw("WITH TIES")) + } + sb.WriteString(" ") +} + +// selectIntoSQL renders "INTO ..." (without a leading separator). +func selectIntoSQL(in *ast.SelectInto, f *nodeFormatter) string { + var sb strings.Builder + sb.WriteString(f.kw("INTO")) + sb.WriteString(" ") + switch in.Kind { + case ast.SelectIntoVariables: + sb.WriteString(exprListSQL(in.Targets)) + case ast.SelectIntoOutfile, ast.SelectIntoDumpfile: + sb.WriteString(f.kw(in.Kind)) + sb.WriteString(" ") + sb.WriteString(exprSQL(in.File)) + if in.Kind == ast.SelectIntoOutfile { + if in.CharacterSet != "" { + sb.WriteString(" ") + sb.WriteString(f.kw("CHARACTER SET")) + sb.WriteString(" ") + sb.WriteString(ast.QuoteName(in.CharacterSet)) + } + writeIntoFileOptions(&sb, "FIELDS", in.FieldsOptions, f) + writeIntoFileOptions(&sb, "LINES", in.LinesOptions, f) + } + default: + if t := ast.SelectIntoTemporaryText(in.Temporary); t != "" { + sb.WriteString(f.kw(t)) + sb.WriteString(" ") + } + if in.TableKeyword { + sb.WriteString(f.kw("TABLE")) + sb.WriteString(" ") + } + sb.WriteString(ast.QuoteQualifiedName(in.Table)) + } + return sb.String() +} + +func writeIntoFileOptions(sb *strings.Builder, clause string, opts []ast.IntoFileOption, f *nodeFormatter) { + wrote := false + for _, o := range opts { + kw := ast.IntoFileOptionText(o.Option) + if kw == "" || o.Value == nil { + continue + } + if !wrote { + sb.WriteString(" ") + sb.WriteString(f.kw(clause)) + wrote = true + } + sb.WriteString(" ") + sb.WriteString(f.kw(kw)) + sb.WriteString(" ") + sb.WriteString(exprSQL(o.Value)) + } +} + +// arrayJoinSQL renders a ClickHouse [LEFT] ARRAY JOIN clause. +func arrayJoinSQL(a *ast.ArrayJoinClause, f *nodeFormatter) string { + var sb strings.Builder + if a.Left { + sb.WriteString(f.kw("LEFT ARRAY JOIN")) + } else { + sb.WriteString(f.kw("ARRAY JOIN")) + } + sb.WriteString(" ") + for i, e := range a.Elements { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(exprSQL(e.Expr)) + if e.Alias != "" { + sb.WriteString(" ") + sb.WriteString(f.kw("AS")) + sb.WriteString(" ") + sb.WriteString(ast.QuoteName(e.Alias)) + } + } + return sb.String() +} + +// groupBySQL renders the GROUP BY clause with its modifiers, or "" when the +// statement has none. +func groupBySQL(s *ast.SelectStatement, f *nodeFormatter) string { + m := s.GroupByModifiers + if len(s.GroupBy) == 0 && !m.All { + return "" + } + var sb strings.Builder + sb.WriteString(f.kw("GROUP BY")) + sb.WriteString(" ") + if m.All { + sb.WriteString(f.kw("ALL")) + } else { + sb.WriteString(exprListSQL(s.GroupBy)) + } + switch { + case m.WithRollup: + sb.WriteString(" ") + sb.WriteString(f.kw("WITH ROLLUP")) + case m.WithCube: + sb.WriteString(" ") + sb.WriteString(f.kw("WITH CUBE")) + } + if m.WithTotals { + sb.WriteString(" ") + sb.WriteString(f.kw("WITH TOTALS")) + } + return sb.String() +} + +// writeHierarchical writes QUALIFY and the Oracle / MariaDB START WITH and +// CONNECT BY clauses, each preceded by a clause separator. +func writeHierarchical(sb *strings.Builder, s *ast.SelectStatement, f *nodeFormatter) { + if s.Qualify != nil { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("QUALIFY")) + sb.WriteString(" ") + sb.WriteString(FormatExpression(s.Qualify, f.opts)) + } + if s.StartWith != nil { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("START WITH")) + sb.WriteString(" ") + sb.WriteString(FormatExpression(s.StartWith, f.opts)) + } + if s.ConnectBy != nil { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("CONNECT BY")) + sb.WriteString(" ") + if s.ConnectBy.NoCycle { + sb.WriteString(f.kw("NOCYCLE")) + sb.WriteString(" ") + } + sb.WriteString(FormatExpression(s.ConnectBy.Condition, f.opts)) + } +} + +// writeLimitOffsetClauses writes LIMIT BY, LIMIT and OFFSET, each preceded by +// a clause separator. OFFSET keeps ROWS when it was written or a FETCH +// clause follows. +func writeLimitOffsetClauses(sb *strings.Builder, s *ast.SelectStatement, f *nodeFormatter) { + if lb := s.LimitBy; lb != nil && lb.Count != nil && len(lb.Exprs) > 0 { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("LIMIT")) + sb.WriteString(" ") + sb.WriteString(exprSQL(lb.Count)) + if lb.Offset != nil { + sb.WriteString(" ") + sb.WriteString(f.kw("OFFSET")) + sb.WriteString(" ") + sb.WriteString(exprSQL(lb.Offset)) + } + sb.WriteString(" ") + sb.WriteString(f.kw("BY")) + sb.WriteString(" ") + sb.WriteString(exprListSQL(lb.Exprs)) + } + if s.HasLimit() { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("LIMIT")) + sb.WriteString(" ") + switch { + case s.Limit != nil: + sb.WriteString(strconv.Itoa(*s.Limit)) + case s.LimitExpr != nil: + sb.WriteString(exprSQL(s.LimitExpr)) + default: + sb.WriteString(f.kw("ALL")) + } + if s.LimitWithTies { + sb.WriteString(" ") + sb.WriteString(f.kw("WITH TIES")) + } + } + if s.HasOffset() { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("OFFSET")) + sb.WriteString(" ") + if s.Offset != nil { + sb.WriteString(strconv.Itoa(*s.Offset)) + } else { + sb.WriteString(exprSQL(s.OffsetExpr)) + } + if s.OffsetRows || s.Fetch != nil { + sb.WriteString(" ") + sb.WriteString(f.kw(ast.OffsetRowsKeyword(s))) + } + } +} + +// forOutputSQL renders " FOR JSON|XML|BROWSE ..." (with a leading space), or +// "" for an unknown kind. +func forOutputSQL(fo *ast.ForOutputClause, f *nodeFormatter) string { + kind := ast.ForOutputKindText(fo.Kind) + if kind == "" { + return "" + } + var sb strings.Builder + sb.WriteString(" ") + sb.WriteString(f.kw("FOR")) + sb.WriteString(" ") + sb.WriteString(f.kw(kind)) + if mode := ast.ForOutputModeText(fo.Mode); mode != "" { + sb.WriteString(" ") + sb.WriteString(f.kw(mode)) + if fo.ModeArg != nil { + sb.WriteString("(") + sb.WriteString(exprSQL(fo.ModeArg)) + sb.WriteString(")") + } + } + for _, o := range fo.Options { + name := ast.ForOutputOptionText(o.Name) + if name == "" { + continue + } + sb.WriteString(", ") + sb.WriteString(f.kw(name)) + if o.Arg != nil { + sb.WriteString("(") + sb.WriteString(exprSQL(o.Arg)) + sb.WriteString(")") + } + } + return sb.String() +} + +// queryHintsSQL renders "OPTION (hint, ...)" (without a leading separator). +func queryHintsSQL(hints []ast.QueryHint, f *nodeFormatter) string { + var sb strings.Builder + sb.WriteString(f.kw("OPTION")) + sb.WriteString(" (") + first := true + for i := range hints { + h := &hints[i] + name := ast.QueryHintNameText(h.Name) + if name == "" { + continue + } + if !first { + sb.WriteString(", ") + } + first = false + sb.WriteString(f.kw(name)) + if h.Value != nil { + if h.Equals { + sb.WriteString(" = ") + } else { + sb.WriteString(" ") + } + sb.WriteString(exprSQL(h.Value)) + } + if h.HasArgs { + sb.WriteString(" (") + for j, a := range h.Args { + if j > 0 { + sb.WriteString(", ") + } + sb.WriteString(exprSQL(a.Expr)) + if a.Unknown { + sb.WriteString(" ") + sb.WriteString(f.kw("UNKNOWN")) + } + } + sb.WriteString(")") + } + } + sb.WriteString(")") + return sb.String() +} + +// writeSelectEpilogue writes the clauses after FOR UPDATE: FOR JSON/XML, +// SETTINGS, OPTION (...) and FORMAT. +func writeSelectEpilogue(sb *strings.Builder, s *ast.SelectStatement, f *nodeFormatter) { + if s.ForOutput != nil { + sb.WriteString(forOutputSQL(s.ForOutput, f)) + } + if len(s.Settings) > 0 { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("SETTINGS")) + sb.WriteString(" ") + parts := make([]string, len(s.Settings)) + for i := range s.Settings { + parts[i] = s.Settings[i].SQL() + } + sb.WriteString(strings.Join(parts, ", ")) + } + if len(s.QueryHints) > 0 { + sb.WriteString(f.clauseSep()) + sb.WriteString(queryHintsSQL(s.QueryHints, f)) + } + if n := ast.FormatNameText(s.OutputFormat); n != "" { + sb.WriteString(f.clauseSep()) + sb.WriteString(f.kw("FORMAT")) + sb.WriteString(" ") + sb.WriteString(n) + } +} + +// joinKeywordsSQL renders the keywords of a join ("LEFT JOIN", "CROSS +// APPLY", "GLOBAL ANY LEFT JOIN", ...) with the formatter's keyword case. +func joinKeywordsSQL(j *ast.JoinClause, f *nodeFormatter) string { + return f.kw(ast.JoinKeywordsText(j)) +} + +// interpolateSQL renders " INTERPOLATE [(col [AS expr], ...)]". +func interpolateSQL(c *ast.InterpolateClause, f *nodeFormatter) string { + var sb strings.Builder + sb.WriteString(" ") + sb.WriteString(f.kw("INTERPOLATE")) + if len(c.Items) == 0 { + return sb.String() + } + sb.WriteString(" (") + for i, it := range c.Items { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(ast.QuoteName(it.Column)) + if it.Expr != nil { + sb.WriteString(" ") + sb.WriteString(f.kw("AS")) + sb.WriteString(" ") + sb.WriteString(exprSQL(it.Expr)) + } + } + sb.WriteString(")") + return sb.String() +} diff --git a/pkg/formatter/select_ext_render_test.go b/pkg/formatter/select_ext_render_test.go new file mode 100644 index 00000000..ae3fac10 --- /dev/null +++ b/pkg/formatter/select_ext_render_test.go @@ -0,0 +1,276 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestRender_SelectBodyClauses checks that SELECT-body clauses survive the +// formatter and tree.SQL(): the compact output must equal want, re-parse with +// the same dialect, and format to itself again. +func TestRender_SelectBodyClauses(t *testing.T) { + cases := []struct { + name string + dialect keywords.SQLDialect + sql string + want string + }{ + {"limit placeholder", keywords.DialectPostgreSQL, "SELECT 1 FROM t LIMIT $1 OFFSET $2", "SELECT 1 FROM t LIMIT $1 OFFSET $2"}, + {"limit all offset rows", keywords.DialectPostgreSQL, "SELECT * FROM t LIMIT ALL OFFSET 5 ROWS", "SELECT * FROM t LIMIT ALL OFFSET 5 ROWS"}, + {"offset before limit", keywords.DialectPostgreSQL, "SELECT * FROM t OFFSET 10 LIMIT 5", "SELECT * FROM t LIMIT 5 OFFSET 10"}, + {"limit expression", keywords.DialectPostgreSQL, "SELECT * FROM t LIMIT (SELECT 1)", "SELECT * FROM t LIMIT (SELECT 1)"}, + {"fetch first row", keywords.DialectPostgreSQL, "SELECT * FROM t FETCH FIRST ROW ONLY", "SELECT * FROM t FETCH FIRST ROW ONLY"}, + {"order by using", keywords.DialectPostgreSQL, "SELECT a FROM t ORDER BY a USING <", "SELECT a FROM t ORDER BY a USING <"}, + {"select into postgres", keywords.DialectPostgreSQL, "SELECT * INTO TEMP TABLE n FROM o WHERE x > 1", "SELECT * INTO TEMP TABLE n FROM o WHERE x > 1"}, + {"where without from", keywords.DialectPostgreSQL, "SELECT 1 WHERE 1 = 1", "SELECT 1 WHERE 1 = 1"}, + {"mysql limit offset count", keywords.DialectMySQL, "SELECT * FROM t LIMIT ?, ?", "SELECT * FROM t LIMIT ? OFFSET ?"}, + {"mysql with rollup", keywords.DialectMySQL, "SELECT a, b FROM t GROUP BY a, b WITH ROLLUP", "SELECT a, b FROM t GROUP BY a, b WITH ROLLUP"}, + {"mysql modifiers", keywords.DialectMySQL, "SELECT SQL_CALC_FOUND_ROWS * FROM t", "SELECT SQL_CALC_FOUND_ROWS * FROM t"}, + {"mysql straight join", keywords.DialectMySQL, "SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id", "SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id"}, + {"mysql join without on", keywords.DialectMySQL, "SELECT * FROM a JOIN b", "SELECT * FROM a INNER JOIN b"}, + {"mysql comma after join", keywords.DialectMySQL, "SELECT * FROM a LEFT JOIN b ON a.id = b.id, t3", "SELECT * FROM a LEFT JOIN b ON a.id = b.id , t3"}, + {"mysql lock in share mode", keywords.DialectMySQL, "SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE", "SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE"}, + {"mysql into vars", keywords.DialectMySQL, "SELECT a, b INTO @x, @y FROM t", "SELECT a, b INTO @x, @y FROM t"}, + {"mysql outfile", keywords.DialectMySQL, "SELECT * FROM o INTO OUTFILE '/tmp/o.csv' FIELDS TERMINATED BY ',' LINES TERMINATED BY ';'", "SELECT * FROM o INTO OUTFILE '/tmp/o.csv' FIELDS TERMINATED BY ',' LINES TERMINATED BY ';'"}, + {"mariadb wait", keywords.DialectMariaDB, "SELECT * FROM t FOR UPDATE WAIT 5", "SELECT * FROM t FOR UPDATE WAIT 5"}, + {"sqlite negative limit", keywords.DialectSQLite, "SELECT a FROM t LIMIT -1 OFFSET 5", "SELECT a FROM t LIMIT -1 OFFSET 5"}, + {"sqlserver offset rows fetch", keywords.DialectSQLServer, "SELECT a FROM t ORDER BY a OFFSET 5 ROWS FETCH NEXT 5 ROWS ONLY", "SELECT a FROM t ORDER BY a OFFSET 5 ROWS FETCH NEXT 5 ROWS ONLY"}, + {"sqlserver offset fetch vars", keywords.DialectSQLServer, "SELECT * FROM t ORDER BY id OFFSET @s ROWS FETCH NEXT @n ROWS ONLY", "SELECT * FROM t ORDER BY id OFFSET @s ROWS FETCH NEXT @n ROWS ONLY"}, + {"sqlserver top in derived table", keywords.DialectSQLServer, "SELECT * FROM (SELECT TOP (10) a FROM t ORDER BY a) AS s", "SELECT * FROM (SELECT TOP (10) a FROM t ORDER BY a) s"}, + {"sqlserver top variable", keywords.DialectSQLServer, "SELECT TOP (@n) * FROM t", "SELECT TOP (@n) * FROM t"}, + {"sqlserver cross apply", keywords.DialectSQLServer, "SELECT * FROM a CROSS APPLY (SELECT 1 AS x) AS t", "SELECT * FROM a CROSS APPLY (SELECT 1 AS x) t"}, + {"sqlserver hash join", keywords.DialectSQLServer, "SELECT o.Id FROM o INNER HASH JOIN c ON c.Id = o.CId", "SELECT o.Id FROM o INNER HASH JOIN c ON c.Id = o.CId"}, + {"sqlserver into temp", keywords.DialectSQLServer, "SELECT * INTO #t FROM u", "SELECT * INTO #t FROM u"}, + {"sqlserver for json", keywords.DialectSQLServer, "SELECT a FROM t FOR JSON PATH, ROOT('r'), INCLUDE_NULL_VALUES", "SELECT a FROM t FOR JSON PATH, ROOT('r'), INCLUDE_NULL_VALUES"}, + {"sqlserver for xml", keywords.DialectSQLServer, "SELECT a FROM t FOR XML PATH('x'), ROOT('y'), TYPE", "SELECT a FROM t FOR XML PATH('x'), ROOT('y'), TYPE"}, + {"sqlserver option", keywords.DialectSQLServer, "SELECT * FROM t WHERE c = @c OPTION (MAXDOP 1, RECOMPILE, OPTIMIZE FOR (@c UNKNOWN))", "SELECT * FROM t WHERE c = @c OPTION (MAXDOP 1, RECOMPILE, OPTIMIZE FOR (@c UNKNOWN))"}, + {"sqlserver with rollup", keywords.DialectSQLServer, "SELECT a, SUM(b) FROM t GROUP BY a WITH ROLLUP", "SELECT a, SUM(b) FROM t GROUP BY a WITH ROLLUP"}, + {"snowflake qualify", keywords.DialectSnowflake, "SELECT * FROM t QUALIFY ROW_NUMBER() OVER (ORDER BY a) = 1", "SELECT * FROM t QUALIFY ROW_NUMBER() OVER (ORDER BY a) = 1"}, + {"snowflake top", keywords.DialectSnowflake, "SELECT TOP 10 a FROM t", "SELECT TOP 10 a FROM t"}, + {"snowflake star modifiers", keywords.DialectSnowflake, "SELECT * EXCLUDE (a, b) REPLACE (UPPER(n) AS n) FROM t", "SELECT * EXCLUDE (a, b) REPLACE (UPPER(n) AS n) FROM t"}, + {"snowflake ilike rename", keywords.DialectSnowflake, "SELECT * ILIKE '%id%', b.* RENAME (id AS b_id) FROM b", "SELECT * ILIKE '%id%', b.* RENAME (id AS b_id) FROM b"}, + {"snowflake group by all", keywords.DialectSnowflake, "SELECT a, COUNT(*) FROM t GROUP BY ALL", "SELECT a, COUNT(*) FROM t GROUP BY ALL"}, + {"snowflake asof", keywords.DialectSnowflake, "SELECT * FROM t1 ASOF JOIN t2 MATCH_CONDITION (t1.ts >= t2.ts) ON t1.sym = t2.sym", "SELECT * FROM t1 ASOF JOIN t2 MATCH_CONDITION (t1.ts >= t2.ts) ON t1.sym = t2.sym"}, + {"oracle connect by", keywords.DialectOracle, "SELECT id FROM e START WITH id = 1 CONNECT BY PRIOR id = m", "SELECT id FROM e START WITH id = 1 CONNECT BY PRIOR id = m"}, + {"oracle row generator", keywords.DialectOracle, "SELECT LEVEL FROM dual CONNECT BY LEVEL <= 10", "SELECT LEVEL FROM dual CONNECT BY LEVEL <= 10"}, + {"oracle siblings", keywords.DialectOracle, "SELECT n FROM e START WITH m IS NULL CONNECT BY PRIOR id = m ORDER SIBLINGS BY n", "SELECT n FROM e START WITH m IS NULL CONNECT BY PRIOR id = m ORDER SIBLINGS BY n"}, + {"oracle fetch first 1 row", keywords.DialectOracle, "SELECT a FROM t FETCH FIRST 1 ROW ONLY", "SELECT a FROM t FETCH FIRST 1 ROW ONLY"}, + {"oracle outer apply", keywords.DialectOracle, "SELECT d.n FROM d OUTER APPLY (SELECT l FROM e WHERE e.d = d.id) e", "SELECT d.n FROM d OUTER APPLY (SELECT l FROM e WHERE e.d = d.id) e"}, + {"clickhouse prewhere", keywords.DialectClickHouse, "SELECT * FROM t PREWHERE a = 1 WHERE b = 2", "SELECT * FROM t PREWHERE a = 1 WHERE b = 2"}, + {"clickhouse array join", keywords.DialectClickHouse, "SELECT id, tag FROM t LEFT ARRAY JOIN tags AS tag", "SELECT id, tag FROM t LEFT ARRAY JOIN tags AS tag"}, + {"clickhouse limit by", keywords.DialectClickHouse, "SELECT u, p FROM e ORDER BY ts DESC LIMIT 2 BY u, p LIMIT 100", "SELECT u, p FROM e ORDER BY ts DESC LIMIT 2 BY u, p LIMIT 100"}, + {"clickhouse with totals", keywords.DialectClickHouse, "SELECT d, sum(x) FROM s GROUP BY d WITH TOTALS ORDER BY d", "SELECT d, sum(x) FROM s GROUP BY d WITH TOTALS ORDER BY d"}, + {"clickhouse global any left join", keywords.DialectClickHouse, "SELECT * FROM a GLOBAL ANY LEFT JOIN b USING (id)", "SELECT * FROM a GLOBAL ANY LEFT JOIN b USING (id)"}, + {"clickhouse asof join", keywords.DialectClickHouse, "SELECT a FROM t ASOF JOIN u ON t.a = u.a AND t.ts >= u.ts", "SELECT a FROM t ASOF JOIN u ON t.a = u.a AND t.ts >= u.ts"}, + {"clickhouse left anti join", keywords.DialectClickHouse, "SELECT * FROM a LEFT ANTI JOIN b ON a.id = b.id", "SELECT * FROM a ANTI LEFT JOIN b ON a.id = b.id"}, + {"clickhouse star except", keywords.DialectClickHouse, "SELECT * EXCEPT (a) FROM t", "SELECT * EXCEPT (a) FROM t"}, + {"clickhouse format", keywords.DialectClickHouse, "SELECT a FROM t SETTINGS max_threads = 2 FORMAT JSONEachRow", "SELECT a FROM t SETTINGS max_threads = 2 FORMAT JSONEachRow"}, + {"clickhouse limit with ties", keywords.DialectClickHouse, "SELECT x FROM t ORDER BY x LIMIT 10 WITH TIES", "SELECT x FROM t ORDER BY x LIMIT 10 WITH TIES"}, + {"clickhouse interpolate", keywords.DialectClickHouse, "SELECT d, c FROM t ORDER BY d WITH FILL STEP 1 INTERPOLATE (c AS c + 1)", "SELECT d, c FROM t ORDER BY d WITH FILL STEP 1 INTERPOLATE (c AS c + 1)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatalf("parse %q: %v", tc.sql, err) + } + got := formatter.FormatAST(tree, ast.CompactStyle()) + if got != tc.want { + t.Errorf("formatter:\n got: %s\nwant: %s", got, tc.want) + } + if sql := tree.SQL(); sql != tc.want { + t.Errorf("SQL():\n got: %s\nwant: %s", sql, tc.want) + } + tree2, err := gosqlx.ParseWithDialect(got, tc.dialect) + if err != nil { + t.Fatalf("re-parse %q: %v", got, err) + } + if again := formatter.FormatAST(tree2, ast.CompactStyle()); again != got { + t.Errorf("not idempotent:\n first: %s\nsecond: %s", got, again) + } + readable := formatter.FormatAST(tree, ast.ReadableStyle()) + if _, err := gosqlx.ParseWithDialect(readable, tc.dialect); err != nil { + t.Errorf("readable output does not re-parse: %v\n%s", err, readable) + } + }) + } +} + +// selectFieldSamples gives a non-zero sample for every exported +// SelectStatement field that must be rendered. A new field fails +// TestRender_SelectRendersEveryField until it is added here (and rendered) +// or to selectFieldsNotRendered. +func selectFieldSamples() map[string]func(*ast.SelectStatement) { + ident := func(n string) *ast.Identifier { return &ast.Identifier{Name: n} } + n := func(v int) *int { return &v } + i64 := func(v int64) *int64 { return &v } + return map[string]func(*ast.SelectStatement){ + "With": func(s *ast.SelectStatement) { + s.With = &ast.WithClause{CTEs: []*ast.CommonTableExpr{{Name: "c", Statement: &ast.SelectStatement{Columns: []ast.Expression{ident("x")}}}}} + }, + "Distinct": func(s *ast.SelectStatement) { s.Distinct = true }, + "DistinctOnColumns": func(s *ast.SelectStatement) { s.DistinctOnColumns = []ast.Expression{ident("d")} }, + "Top": func(s *ast.SelectStatement) { + s.Top = &ast.TopClause{Count: &ast.LiteralValue{Value: "5", Type: "int"}} + }, + "Columns": func(s *ast.SelectStatement) { s.Columns = append(s.Columns, ident("extra")) }, + "From": func(s *ast.SelectStatement) { s.From = append(s.From, ast.TableReference{Name: "u"}) }, + "Joins": func(s *ast.SelectStatement) { + s.Joins = []ast.JoinClause{{Type: "LEFT", Right: ast.TableReference{Name: "j"}, Condition: ident("ok")}} + }, + "ArrayJoin": func(s *ast.SelectStatement) { + s.ArrayJoin = &ast.ArrayJoinClause{Elements: []ast.ArrayJoinElement{{Expr: ident("arr")}}} + }, + "PrewhereClause": func(s *ast.SelectStatement) { s.PrewhereClause = ident("pw") }, + "Sample": func(s *ast.SelectStatement) { s.Sample = &ast.SampleClause{Value: "0.1"} }, + "Where": func(s *ast.SelectStatement) { s.Where = ident("w") }, + "GroupBy": func(s *ast.SelectStatement) { s.GroupBy = []ast.Expression{ident("g")} }, + "Having": func(s *ast.SelectStatement) { s.Having = ident("h") }, + "Qualify": func(s *ast.SelectStatement) { s.Qualify = ident("q") }, + "StartWith": func(s *ast.SelectStatement) { s.StartWith = ident("sw") }, + "ConnectBy": func(s *ast.SelectStatement) { s.ConnectBy = &ast.ConnectByClause{Condition: ident("cb")} }, + "Windows": func(s *ast.SelectStatement) { s.Windows = []ast.WindowSpec{{Name: "win"}} }, + "OrderBy": func(s *ast.SelectStatement) { + s.OrderBy = []ast.OrderByExpression{{Expression: ident("o"), Ascending: true}} + }, + "Limit": func(s *ast.SelectStatement) { s.Limit = n(7) }, + "Offset": func(s *ast.SelectStatement) { s.Offset = n(8) }, + "Fetch": func(s *ast.SelectStatement) { s.Fetch = &ast.FetchClause{FetchType: "FIRST", FetchValue: i64(3)} }, + "For": func(s *ast.SelectStatement) { s.For = &ast.ForClause{LockType: "UPDATE"} }, + "Settings": func(s *ast.SelectStatement) { + s.Settings = []ast.QuerySetting{{Name: "max_threads", Value: &ast.LiteralValue{Value: "2", Type: "int"}}} + }, + "SelectModifiers": func(s *ast.SelectStatement) { s.SelectModifiers = []string{"SQL_NO_CACHE"} }, + "Into": func(s *ast.SelectStatement) { s.Into = &ast.SelectInto{Kind: ast.SelectIntoTable, Table: "dest"} }, + "GroupByModifiers": func(s *ast.SelectStatement) { + s.GroupBy = []ast.Expression{ident("g")} + s.GroupByModifiers = ast.GroupByModifiers{WithRollup: true} + }, + "OrderSiblings": func(s *ast.SelectStatement) { + s.OrderBy = []ast.OrderByExpression{{Expression: ident("o"), Ascending: true}} + s.OrderSiblings = true + }, + "Interpolate": func(s *ast.SelectStatement) { + s.OrderBy = []ast.OrderByExpression{{Expression: ident("o"), Ascending: true}} + s.Interpolate = &ast.InterpolateClause{Items: []ast.InterpolateItem{{Column: "c"}}} + }, + "LimitExpr": func(s *ast.SelectStatement) { s.LimitExpr = &ast.Identifier{Name: "$1"} }, + "OffsetExpr": func(s *ast.SelectStatement) { s.OffsetExpr = &ast.Identifier{Name: "$2"} }, + "LimitAll": func(s *ast.SelectStatement) { s.LimitAll = true }, + "OffsetRows": func(s *ast.SelectStatement) { + s.Offset = n(8) + s.OffsetRows = true + }, + "LimitWithTies": func(s *ast.SelectStatement) { + s.Limit = n(7) + s.LimitWithTies = true + }, + "LimitBy": func(s *ast.SelectStatement) { + s.LimitBy = &ast.LimitByClause{Count: &ast.LiteralValue{Value: "1", Type: "int"}, Exprs: []ast.Expression{ident("lb")}} + }, + "ForOutput": func(s *ast.SelectStatement) { s.ForOutput = &ast.ForOutputClause{Kind: "JSON", Mode: "AUTO"} }, + "QueryHints": func(s *ast.SelectStatement) { s.QueryHints = []ast.QueryHint{{Name: "RECOMPILE"}} }, + "OutputFormat": func(s *ast.SelectStatement) { s.OutputFormat = "JSONEachRow" }, + } +} + +// selectFieldsNotRendered are SelectStatement fields that carry no SQL text. +var selectFieldsNotRendered = map[string]string{ + "TableName": "pool/compatibility mirror of From[0].Name", + "Pos": "source position", +} + +// TestRender_SelectRendersEveryField sets each exported SelectStatement +// field in turn and asserts that both pkg/formatter and SelectStatement.SQL() +// output change, so a parsed clause cannot be silently dropped by either +// renderer. +func TestRender_SelectRendersEveryField(t *testing.T) { + base := func() *ast.SelectStatement { + return &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "a"}}, + From: []ast.TableReference{{Name: "t"}}, + } + } + // Some samples need a companion field set in the baseline too, so that + // only the field under test differs. + baseFor := map[string]func(*ast.SelectStatement){ + "GroupByModifiers": func(s *ast.SelectStatement) { s.GroupBy = []ast.Expression{&ast.Identifier{Name: "g"}} }, + "OrderSiblings": func(s *ast.SelectStatement) { + s.OrderBy = []ast.OrderByExpression{{Expression: &ast.Identifier{Name: "o"}, Ascending: true}} + }, + "Interpolate": func(s *ast.SelectStatement) { + s.OrderBy = []ast.OrderByExpression{{Expression: &ast.Identifier{Name: "o"}, Ascending: true}} + }, + "OffsetRows": func(s *ast.SelectStatement) { v := 8; s.Offset = &v }, + "LimitWithTies": func(s *ast.SelectStatement) { v := 7; s.Limit = &v }, + } + samples := selectFieldSamples() + typ := reflect.TypeOf(ast.SelectStatement{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + name := field.Name + if _, skip := selectFieldsNotRendered[name]; skip { + continue + } + set, ok := samples[name] + if !ok { + t.Errorf("SelectStatement.%s has no render sample: add one to selectFieldSamples and render it", name) + continue + } + t.Run(name, func(t *testing.T) { + before, after := base(), base() + if prep := baseFor[name]; prep != nil { + prep(before) + prep(after) + } + set(after) + opts := ast.CompactStyle() + if f0, f1 := formatter.FormatStatement(before, opts), formatter.FormatStatement(after, opts); f0 == f1 { + t.Errorf("formatter ignores %s: %s", name, f1) + } + if s0, s1 := before.SQL(), after.SQL(); s0 == s1 { + t.Errorf("SQL() ignores %s: %s", name, s1) + } + }) + } +} + +// TestRender_SelectExtKeywordCase checks that the new clause keywords follow +// the formatter's keyword case. +func TestRender_SelectExtKeywordCase(t *testing.T) { + tree, err := gosqlx.ParseWithDialect("SELECT a FROM t GROUP BY a WITH ROLLUP ORDER BY a OFFSET 5 ROWS FETCH NEXT 5 ROWS ONLY", keywords.DialectSQLServer) + if err != nil { + t.Fatalf("parse: %v", err) + } + opts := ast.CompactStyle() + opts.KeywordCase = ast.KeywordLower + got := formatter.FormatAST(tree, opts) + for _, kw := range []string{"with rollup", "offset 5 rows", "fetch next 5 rows only"} { + if !strings.Contains(got, kw) { + t.Errorf("lower-case output %q lacks %q", got, kw) + } + } +} diff --git a/pkg/linter/rules/naming/missing_order_by_limit.go b/pkg/linter/rules/naming/missing_order_by_limit.go index 55c0168b..7edf8c92 100644 --- a/pkg/linter/rules/naming/missing_order_by_limit.go +++ b/pkg/linter/rules/naming/missing_order_by_limit.go @@ -51,11 +51,11 @@ func (r *MissingOrderByLimitRule) Check(ctx *linter.Context) ([]linter.Violation if !ok { return true } - hasLimit := sel.Limit != nil || sel.Fetch != nil + hasLimit := sel.Limit != nil || sel.LimitExpr != nil || sel.Fetch != nil if !hasLimit { return true } - hasOffset := sel.Offset != nil || (sel.Fetch != nil && sel.Fetch.OffsetValue != nil) + hasOffset := sel.HasOffset() || (sel.Fetch != nil && sel.Fetch.OffsetValue != nil) hasOrderBy := len(sel.OrderBy) > 0 if !hasOrderBy { msg := "LIMIT without ORDER BY produces non-deterministic results" diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 30f9cacc..7431ec77 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -79,6 +79,12 @@ type JoinClause struct { Condition Expression // ON condition (nil for USING / CROSS / NATURAL) Using []Expression // USING (col, ...) columns; when set, Condition is nil and renderers emit USING Pos models.Location // Source position of the JOIN keyword (1-based line and column) + + Global bool // ClickHouse GLOBAL JOIN + Strictness string // ClickHouse ANY / ALL / ASOF / SEMI / ANTI, Snowflake ASOF + Hint string // SQL Server HASH / LOOP / MERGE / REMOTE join hint, or MySQL STRAIGHT_JOIN + MatchCondition Expression // Snowflake ASOF JOIN ... MATCH_CONDITION (expr) + Comma bool // MySQL ", t" after an explicit join: rendered as a comma, Type is CROSS } func (j *JoinClause) expressionNode() {} @@ -96,6 +102,18 @@ func (j JoinClause) Children() []Node { return j.childrenInPlace() } // childrenInPlace is Children for a JoinClause reached through a pointer: // Left and Right are returned as pointers into *j itself. func (j *JoinClause) childrenInPlace() []Node { + if j.MatchCondition != nil { + nodes := []Node{&j.Left, &j.Right, j.MatchCondition} + if j.Condition != nil { + nodes = append(nodes, j.Condition) + } + for _, u := range j.Using { + if u != nil { + nodes = append(nodes, u) + } + } + return nodes + } if len(j.Using) == 0 { if j.Condition != nil { return []Node{&j.Left, &j.Right, j.Condition} @@ -263,6 +281,9 @@ type OrderByExpression struct { // WithFill — ClickHouse "WITH FILL FROM x TO y STEP z" modifier. // Fields are nil when the corresponding part is absent. WithFill *WithFillClause + // UsingOperator is the PostgreSQL ORDER BY expr USING sort + // operator (<, >, <=, >=); when set it replaces ASC / DESC. + UsingOperator string } // WithFillClause — ClickHouse "WITH FILL [FROM x] [TO y] [STEP z]". @@ -396,6 +417,9 @@ type TopClause struct { Count Expression // Number of rows (or percentage) as an expression IsPercent bool // Whether PERCENT keyword was specified WithTies bool // Whether WITH TIES was specified (SQL Server) + // Parenthesized records TOP (n); renderers keep the parentheses, and add + // them for any count that is not a plain number. + Parenthesized bool } func (t *TopClause) expressionNode() {} @@ -424,11 +448,19 @@ type FetchClause struct { IsPercent bool // WithTies indicates FETCH ... WITH TIES (includes tied rows) WithTies bool + // FetchExpr is the row count when it is not a plain integer + // (FETCH NEXT @n ROWS ONLY); FetchValue wins when both are set. + FetchExpr Expression } func (f *FetchClause) expressionNode() {} func (f FetchClause) TokenLiteral() string { return "FETCH" } -func (f FetchClause) Children() []Node { return nil } +func (f FetchClause) Children() []Node { + if f.FetchExpr != nil { + return []Node{f.FetchExpr} + } + return nil +} // ForClause represents row-level locking clauses in SELECT statements (SQL:2003, PostgreSQL, MySQL) // Syntax: FOR {UPDATE | SHARE | NO KEY UPDATE | KEY SHARE} [OF table_name [, ...]] [NOWAIT | SKIP LOCKED] @@ -452,11 +484,21 @@ type ForClause struct { NoWait bool // SkipLocked indicates SKIP LOCKED option (skip rows that can't be locked) SkipLocked bool + // Wait is the Oracle / MariaDB WAIT n timeout, or nil. + Wait Expression + // LockInShareMode records MySQL's LOCK IN SHARE MODE spelling of a + // SHARE lock (LockType "SHARE"). + LockInShareMode bool } func (f *ForClause) expressionNode() {} func (f ForClause) TokenLiteral() string { return "FOR" } -func (f ForClause) Children() []Node { return nil } +func (f ForClause) Children() []Node { + if f.Wait != nil { + return []Node{f.Wait} + } + return nil +} // OnConflict represents ON CONFLICT DO UPDATE/NOTHING clause type OnConflict struct { diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index 8eddae16..cd5e86fd 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -158,6 +158,22 @@ type SelectStatement struct { For *ForClause // Row-level locking clause (SQL:2003, PostgreSQL, MySQL) Settings []QuerySetting // ClickHouse trailing SETTINGS name = value, ... (query-level settings) Pos models.Location // Source position of the SELECT keyword (1-based line and column) + + // SELECT-body clauses added for dialect coverage (see select_ext.go). + SelectModifiers []string // MySQL/MariaDB HIGH_PRIORITY, STRAIGHT_JOIN, SQL_CALC_FOUND_ROWS, ... after SELECT [DISTINCT] + Into *SelectInto // SELECT ... INTO table / variables / OUTFILE + GroupByModifiers GroupByModifiers // GROUP BY ALL, WITH ROLLUP / CUBE / TOTALS + OrderSiblings bool // Oracle ORDER SIBLINGS BY + Interpolate *InterpolateClause // ClickHouse ORDER BY ... WITH FILL INTERPOLATE (col AS expr, ...) + LimitExpr Expression // LIMIT value that is not a plain integer ($1, ?, -1, 1 + 1); Limit wins when both are set + OffsetExpr Expression // OFFSET value that is not a plain integer; Offset wins when both are set + LimitAll bool // PostgreSQL LIMIT ALL + OffsetRows bool // OFFSET n ROW / ROWS + LimitWithTies bool // ClickHouse LIMIT n WITH TIES + LimitBy *LimitByClause // ClickHouse LIMIT n [OFFSET m] BY expr, ... + ForOutput *ForOutputClause // SQL Server FOR JSON / FOR XML / FOR BROWSE + QueryHints []QueryHint // SQL Server OPTION (hint, ...) + OutputFormat string // ClickHouse FORMAT name } func (s *SelectStatement) statementNode() {} @@ -260,7 +276,7 @@ func (s SelectStatement) Children() []Node { children = append(children, v) } } - return children + return s.appendSelectExtChildren(children) } // InsertStatement represents an INSERT SQL statement diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index ae6980c3..8437ad5f 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -383,6 +383,7 @@ func releaseSelectStatement(stmt *SelectStatement, mode releaseMode) { for i := range stmt.Joins { releaseTableReference(&stmt.Joins[i].Right, mode) releaseExpression(stmt.Joins[i].Condition, mode) + releaseExpression(stmt.Joins[i].MatchCondition, mode) for _, u := range stmt.Joins[i].Using { releaseExpression(u, mode) } @@ -475,10 +476,19 @@ func releaseSelectStatement(stmt *SelectStatement, mode releaseMode) { stmt.Limit = nil stmt.Offset = nil - // ── FETCH / FOR (no Expression children) ─────────────────────────── + // ── FETCH / FOR ──────────────────────────────────────────────────── + if stmt.Fetch != nil { + releaseExpression(stmt.Fetch.FetchExpr, mode) + } stmt.Fetch = nil + if stmt.For != nil { + releaseExpression(stmt.For.Wait, mode) + } stmt.For = nil + // ── INTO, LIMIT BY, FOR JSON/XML, OPTION, ... (select_ext.go) ────── + releaseSelectExt(stmt, mode) + // ── ClickHouse SETTINGS ──────────────────────────────────────────── for i := range stmt.Settings { releaseExpression(stmt.Settings[i].Value, mode) diff --git a/pkg/sql/ast/select_ext.go b/pkg/sql/ast/select_ext.go new file mode 100644 index 00000000..ae4ef1a4 --- /dev/null +++ b/pkg/sql/ast/select_ext.go @@ -0,0 +1,807 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// select_ext.go holds the SELECT-body clause nodes added for dialect +// coverage: LIMIT/OFFSET expressions, ClickHouse LIMIT BY and FORMAT, GROUP BY +// modifiers, SELECT ... INTO, SQL Server FOR JSON/XML and OPTION (...) query +// hints, MySQL select modifiers, and their SQL rendering. + +package ast + +import ( + "bytes" + "strings" +) + +// GroupByModifiers are the GROUP BY modifiers that are not grouping +// expressions themselves. +// +// - All: GROUP BY ALL (Snowflake, ClickHouse, DuckDB, Databricks); GroupBy +// is then empty. +// - WithRollup / WithCube: MySQL, MariaDB, SQL Server and ClickHouse +// trailing GROUP BY a, b WITH ROLLUP / WITH CUBE. These are kept as +// written because MySQL and MariaDB reject the ROLLUP(a, b) form. +// - WithTotals: ClickHouse GROUP BY ... WITH TOTALS. +type GroupByModifiers struct { + All bool + WithRollup bool + WithCube bool + WithTotals bool +} + +// IsZero reports whether no modifier is set. +func (m GroupByModifiers) IsZero() bool { + return !m.All && !m.WithRollup && !m.WithCube && !m.WithTotals +} + +// LimitByClause is the ClickHouse "LIMIT n [OFFSET m] BY expr, ..." clause, +// which keeps the first n rows of each group of Exprs. It may be followed by +// an ordinary LIMIT clause. +type LimitByClause struct { + Count Expression + Offset Expression // nil when absent + Exprs []Expression +} + +func (l *LimitByClause) expressionNode() {} +func (l LimitByClause) TokenLiteral() string { return "LIMIT" } + +// Children returns Count, Offset (when present) and the BY expressions. +func (l LimitByClause) Children() []Node { + nodes := make([]Node, 0, 2+len(l.Exprs)) + if l.Count != nil { + nodes = append(nodes, l.Count) + } + if l.Offset != nil { + nodes = append(nodes, l.Offset) + } + for _, e := range l.Exprs { + if e != nil { + nodes = append(nodes, e) + } + } + return nodes +} + +// InterpolateClause is the ClickHouse "INTERPOLATE [(col [AS expr], ...)]" +// clause that follows an ORDER BY ... WITH FILL list. +type InterpolateClause struct { + Items []InterpolateItem +} + +// InterpolateItem is one INTERPOLATE column with its optional expression. +type InterpolateItem struct { + Column string + Expr Expression // nil when written without AS +} + +func (c *InterpolateClause) expressionNode() {} +func (c InterpolateClause) TokenLiteral() string { return "INTERPOLATE" } + +// Children returns the item expressions. +func (c InterpolateClause) Children() []Node { + nodes := make([]Node, 0, len(c.Items)) + for _, it := range c.Items { + if it.Expr != nil { + nodes = append(nodes, it.Expr) + } + } + return nodes +} + +// SelectInto kinds. +const ( + SelectIntoTable = "TABLE" // SELECT ... INTO new_table (SQL Server, PostgreSQL) + SelectIntoVariables = "VARIABLES" // SELECT ... INTO @a, @b / :v (MySQL, MariaDB, Oracle PL/SQL) + SelectIntoOutfile = "OUTFILE" // MySQL / MariaDB INTO OUTFILE 'file' + SelectIntoDumpfile = "DUMPFILE" // MySQL / MariaDB INTO DUMPFILE 'file' +) + +// SelectInto is the INTO clause of a SELECT statement. +// +// SELECT * INTO #tmp FROM t -- Kind TABLE, Table "#tmp" +// SELECT * INTO TEMP TABLE t2 FROM t -- PostgreSQL, Temporary "TEMP", TableKeyword +// SELECT a, b INTO @x, @y FROM t -- Kind VARIABLES +// SELECT * FROM t INTO OUTFILE '/tmp/t.csv' -- Kind OUTFILE, Trailing +// FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' +type SelectInto struct { + Kind string + // Table is the target table name (Kind TABLE), possibly qualified. + Table string + // Temporary is the PostgreSQL TEMP / TEMPORARY / UNLOGGED modifier. + Temporary string + // TableKeyword records the optional PostgreSQL TABLE keyword. + TableKeyword bool + // Targets are the variables of Kind VARIABLES. + Targets []Expression + // File is the file name (a string literal) of Kind OUTFILE / DUMPFILE. + File Expression + // CharacterSet is the OUTFILE CHARACTER SET name. + CharacterSet string + // FieldsOptions / LinesOptions are the OUTFILE export options. + FieldsOptions []IntoFileOption + LinesOptions []IntoFileOption + // Trailing records that the clause was written after the FROM ... + // clauses (MySQL), rather than right after the select list. + Trailing bool +} + +// IntoFileOption is one OUTFILE export option such as TERMINATED BY ','. +// Option is one of TERMINATED BY, ENCLOSED BY, OPTIONALLY ENCLOSED BY, +// ESCAPED BY or STARTING BY. +type IntoFileOption struct { + Option string + Value Expression +} + +func (s *SelectInto) expressionNode() {} +func (s SelectInto) TokenLiteral() string { return "INTO" } + +// Children returns the variable targets, the file name and the option values. +func (s SelectInto) Children() []Node { + nodes := make([]Node, 0, len(s.Targets)+1+len(s.FieldsOptions)+len(s.LinesOptions)) + for _, t := range s.Targets { + if t != nil { + nodes = append(nodes, t) + } + } + if s.File != nil { + nodes = append(nodes, s.File) + } + for _, o := range s.FieldsOptions { + if o.Value != nil { + nodes = append(nodes, o.Value) + } + } + for _, o := range s.LinesOptions { + if o.Value != nil { + nodes = append(nodes, o.Value) + } + } + return nodes +} + +// ForOutputClause is the SQL Server FOR JSON / FOR XML / FOR BROWSE clause. +// +// FOR JSON PATH, ROOT('orders'), INCLUDE_NULL_VALUES +// FOR XML PATH('row'), ROOT('rows'), TYPE +type ForOutputClause struct { + Kind string // JSON, XML or BROWSE + Mode string // PATH, AUTO, RAW or EXPLICIT (empty for BROWSE) + ModeArg Expression // PATH('row') / RAW('row') element name, or nil + Options []ForOutputOption +} + +// ForOutputOption is one option of a FOR JSON / FOR XML clause such as +// ROOT('x'), TYPE, ELEMENTS XSINIL or WITHOUT_ARRAY_WRAPPER. +type ForOutputOption struct { + Name string + Arg Expression // ROOT('x') argument, or nil +} + +func (f *ForOutputClause) expressionNode() {} +func (f ForOutputClause) TokenLiteral() string { return "FOR" } + +// Children returns the mode and option arguments. +func (f ForOutputClause) Children() []Node { + var nodes []Node + if f.ModeArg != nil { + nodes = append(nodes, f.ModeArg) + } + for _, o := range f.Options { + if o.Arg != nil { + nodes = append(nodes, o.Arg) + } + } + return nodes +} + +// QueryHint is one SQL Server OPTION (...) query hint. +// +// RECOMPILE -- Name "RECOMPILE" +// MAXDOP 1 -- Name "MAXDOP", Value 1 +// LABEL = 'x' -- Name "LABEL", Equals, Value 'x' +// USE HINT ('A', 'B') -- Name "USE HINT", Args +// OPTIMIZE FOR (@p = 1, @q UNKNOWN) +// OPTIMIZE FOR UNKNOWN -- Name "OPTIMIZE FOR UNKNOWN" +// +// Name is one or more upper-case words separated by single spaces. +type QueryHint struct { + Name string + Equals bool + Value Expression + HasArgs bool + Args []QueryHintArg +} + +// QueryHintArg is one parenthesized argument of a query hint; Unknown marks +// the OPTIMIZE FOR (@p UNKNOWN) form. +type QueryHintArg struct { + Expr Expression + Unknown bool +} + +// The keyword sets of the new SELECT clauses. Renderers write a word only +// when it is one of these (any case), so a hand-built AST cannot inject SQL. +var ( + selectModifierKeywords = []string{ + "HIGH_PRIORITY", "STRAIGHT_JOIN", "SQL_SMALL_RESULT", "SQL_BIG_RESULT", + "SQL_BUFFER_RESULT", "SQL_NO_CACHE", "SQL_CACHE", "SQL_CALC_FOUND_ROWS", + } + selectIntoTemporaryKeywords = []string{"TEMP", "TEMPORARY", "UNLOGGED"} + intoFileOptionKeywords = []string{ + "TERMINATED BY", "ENCLOSED BY", "OPTIONALLY ENCLOSED BY", "ESCAPED BY", "STARTING BY", + } + forOutputKindKeywords = []string{"JSON", "XML", "BROWSE"} + forOutputModeKeywords = []string{"PATH", "AUTO", "RAW", "EXPLICIT"} + forOutputOptionKeywords = []string{ + "ROOT", "INCLUDE_NULL_VALUES", "WITHOUT_ARRAY_WRAPPER", "TYPE", "ELEMENTS", + "ELEMENTS XSINIL", "ELEMENTS ABSENT", "BINARY BASE64", "XMLDATA", "XMLSCHEMA", + } + joinStrictnessKeywords = []string{"ANY", "ALL", "ASOF", "SEMI", "ANTI"} + joinHintKeywords = []string{"HASH", "LOOP", "MERGE", "REMOTE"} +) + +// SelectModifierText returns s when it is a MySQL select modifier +// (SQL_CALC_FOUND_ROWS, STRAIGHT_JOIN, ...; any case) and "" otherwise. +func SelectModifierText(s string) string { return keywordOneOf(s, selectModifierKeywords) } + +// SelectIntoTemporaryText returns s when it is TEMP, TEMPORARY or UNLOGGED +// (any case) and "" otherwise. +func SelectIntoTemporaryText(s string) string { return keywordOneOf(s, selectIntoTemporaryKeywords) } + +// IntoFileOptionText returns s when it is an OUTFILE export option keyword +// (TERMINATED BY, ENCLOSED BY, ...; any case) and "" otherwise. +func IntoFileOptionText(s string) string { return keywordOneOf(s, intoFileOptionKeywords) } + +// ForOutputKindText returns s when it is JSON, XML or BROWSE (any case) and +// "" otherwise. +func ForOutputKindText(s string) string { return keywordOneOf(s, forOutputKindKeywords) } + +// ForOutputModeText returns s when it is PATH, AUTO, RAW or EXPLICIT (any +// case) and "" otherwise. +func ForOutputModeText(s string) string { return keywordOneOf(s, forOutputModeKeywords) } + +// ForOutputOptionText returns s when it is a FOR JSON / FOR XML option name +// (ROOT, TYPE, INCLUDE_NULL_VALUES, ...; any case) and "" otherwise. +func ForOutputOptionText(s string) string { return keywordOneOf(s, forOutputOptionKeywords) } + +// JoinStrictnessText returns s when it is a ClickHouse / Snowflake join +// strictness (ANY, ALL, ASOF, SEMI, ANTI; any case) and "" otherwise. +func JoinStrictnessText(s string) string { return keywordOneOf(s, joinStrictnessKeywords) } + +// JoinHintText returns s when it is a SQL Server join hint (HASH, LOOP, +// MERGE, REMOTE; any case) and "" otherwise. +func JoinHintText(s string) string { return keywordOneOf(s, joinHintKeywords) } + +// QueryHintNameText returns name when it is one or more words of letters, +// digits and underscores separated by single spaces (a query hint name such +// as RECOMPILE or OPTIMIZE FOR UNKNOWN), and "" otherwise. +func QueryHintNameText(name string) string { + if name == "" { + return "" + } + for _, w := range strings.Split(name, " ") { + if w == "" || !isBareNameChars(w, false) { + return "" + } + } + return name +} + +// FormatNameText returns name when it is a plain word usable as a ClickHouse +// FORMAT name (JSONEachRow, TabSeparated, Null, ...), and "" otherwise. +func FormatNameText(name string) string { + if name == "" || !isBareNameChars(name, false) { + return "" + } + return name +} + +// HasLimit reports whether the statement has a LIMIT value in any form +// (integer, expression or ALL). +func (s *SelectStatement) HasLimit() bool { + return s != nil && (s.Limit != nil || s.LimitExpr != nil || s.LimitAll) +} + +// HasOffset reports whether the statement has an OFFSET value in any form. +func (s *SelectStatement) HasOffset() bool { + return s != nil && (s.Offset != nil || s.OffsetExpr != nil) +} + +// appendSelectExtChildren appends the expression children of the clauses +// declared in this file to children. +func (s *SelectStatement) appendSelectExtChildren(children []Node) []Node { + if s.Into != nil { + children = append(children, s.Into) + } + if s.Interpolate != nil { + children = append(children, s.Interpolate) + } + if s.LimitBy != nil { + children = append(children, s.LimitBy) + } + if s.LimitExpr != nil { + children = append(children, s.LimitExpr) + } + if s.OffsetExpr != nil { + children = append(children, s.OffsetExpr) + } + if s.ForOutput != nil { + children = append(children, s.ForOutput) + } + for i := range s.QueryHints { + h := &s.QueryHints[i] + if h.Value != nil { + children = append(children, h.Value) + } + for _, a := range h.Args { + if a.Expr != nil { + children = append(children, a.Expr) + } + } + } + return children +} + +// releaseSelectExt releases and clears the fields declared for the clauses +// in this file; releaseSelectStatement calls it. +func releaseSelectExt(stmt *SelectStatement, mode releaseMode) { + stmt.SelectModifiers = stmt.SelectModifiers[:0] + if stmt.Into != nil { + for _, t := range stmt.Into.Targets { + releaseExpression(t, mode) + } + releaseExpression(stmt.Into.File, mode) + for _, o := range stmt.Into.FieldsOptions { + releaseExpression(o.Value, mode) + } + for _, o := range stmt.Into.LinesOptions { + releaseExpression(o.Value, mode) + } + stmt.Into = nil + } + stmt.GroupByModifiers = GroupByModifiers{} + stmt.OrderSiblings = false + if stmt.Interpolate != nil { + for _, it := range stmt.Interpolate.Items { + releaseExpression(it.Expr, mode) + } + stmt.Interpolate = nil + } + releaseExpression(stmt.LimitExpr, mode) + stmt.LimitExpr = nil + releaseExpression(stmt.OffsetExpr, mode) + stmt.OffsetExpr = nil + stmt.LimitAll = false + stmt.OffsetRows = false + stmt.LimitWithTies = false + if stmt.LimitBy != nil { + releaseExpression(stmt.LimitBy.Count, mode) + releaseExpression(stmt.LimitBy.Offset, mode) + for _, e := range stmt.LimitBy.Exprs { + releaseExpression(e, mode) + } + stmt.LimitBy = nil + } + if stmt.ForOutput != nil { + releaseExpression(stmt.ForOutput.ModeArg, mode) + for _, o := range stmt.ForOutput.Options { + releaseExpression(o.Arg, mode) + } + stmt.ForOutput = nil + } + for i := range stmt.QueryHints { + releaseExpression(stmt.QueryHints[i].Value, mode) + for _, a := range stmt.QueryHints[i].Args { + releaseExpression(a.Expr, mode) + } + stmt.QueryHints[i] = QueryHint{} + } + stmt.QueryHints = stmt.QueryHints[:0] + stmt.OutputFormat = "" +} + +// ─── rendering ─────────────────────────────────────────────────────────────── + +// writeSelectModifiers writes the MySQL select modifiers, each followed by a +// space. Unknown words are dropped. +func writeSelectModifiers(buf *bytes.Buffer, mods []string) { + for _, m := range mods { + if t := SelectModifierText(m); t != "" { + buf.WriteString(t) + buf.WriteByte(' ') + } + } +} + +// writeTop writes a TOP clause followed by a space. The count keeps its +// parentheses when they were written, and always gets them when it is not a +// plain number (TOP (@n), TOP (SELECT ...)), which T-SQL requires. +func writeTop(buf *bytes.Buffer, t *TopClause) { + if t == nil || t.Count == nil { + return + } + buf.WriteString("TOP ") + if TopNeedsParens(t) { + buf.WriteByte('(') + writeExpr(buf, t.Count) + buf.WriteByte(')') + } else { + writeExpr(buf, t.Count) + } + if t.IsPercent { + buf.WriteString(" PERCENT") + } + if t.WithTies { + buf.WriteString(" WITH TIES") + } + buf.WriteByte(' ') +} + +// TopNeedsParens reports whether a TOP count is written in parentheses: when +// it was parenthesized in the source, or when it is anything but a numeric +// literal. +func TopNeedsParens(t *TopClause) bool { + if t == nil { + return false + } + if t.Parenthesized { + return true + } + lit, ok := t.Count.(*LiteralValue) + if !ok { + return true + } + switch v := lit.Value.(type) { + case int, int64, int32, uint64, float64: + return false + case string: + return !IsPlainNumberText(v) + } + return true +} + +// writeSelectInto writes " INTO ..." (with a leading space). +func writeSelectInto(buf *bytes.Buffer, in *SelectInto) { + if in == nil { + return + } + buf.WriteString(" INTO ") + switch in.Kind { + case SelectIntoVariables: + writeExprList(buf, in.Targets) + case SelectIntoOutfile, SelectIntoDumpfile: + buf.WriteString(in.Kind) + buf.WriteByte(' ') + writeExpr(buf, in.File) + if in.Kind == SelectIntoOutfile { + if in.CharacterSet != "" { + buf.WriteString(" CHARACTER SET ") + writeName(buf, in.CharacterSet) + } + writeIntoFileOptions(buf, "FIELDS", in.FieldsOptions) + writeIntoFileOptions(buf, "LINES", in.LinesOptions) + } + default: + if t := SelectIntoTemporaryText(in.Temporary); t != "" { + buf.WriteString(t) + buf.WriteByte(' ') + } + if in.TableKeyword { + buf.WriteString("TABLE ") + } + writeQualifiedName(buf, in.Table) + } +} + +func writeIntoFileOptions(buf *bytes.Buffer, clause string, opts []IntoFileOption) { + wrote := false + for _, o := range opts { + kw := IntoFileOptionText(o.Option) + if kw == "" || o.Value == nil { + continue + } + if !wrote { + buf.WriteByte(' ') + buf.WriteString(clause) + wrote = true + } + buf.WriteByte(' ') + buf.WriteString(kw) + buf.WriteByte(' ') + writeExpr(buf, o.Value) + } +} + +// writeGroupBy writes the GROUP BY clause including its modifiers. +func writeGroupBy(buf *bytes.Buffer, s *SelectStatement) { + m := s.GroupByModifiers + if len(s.GroupBy) == 0 && !m.All { + return + } + buf.WriteString(" GROUP BY ") + if m.All { + buf.WriteString("ALL") + } else { + writeExprList(buf, s.GroupBy) + } + switch { + case m.WithRollup: + buf.WriteString(" WITH ROLLUP") + case m.WithCube: + buf.WriteString(" WITH CUBE") + } + if m.WithTotals { + buf.WriteString(" WITH TOTALS") + } +} + +// writeLimitValue writes the LIMIT count: Limit, else LimitExpr, else ALL. +func writeLimitValue(buf *bytes.Buffer, s *SelectStatement) { + switch { + case s.Limit != nil: + writeInt(buf, int64(*s.Limit)) + case s.LimitExpr != nil: + writeExpr(buf, s.LimitExpr) + default: + buf.WriteString("ALL") + } +} + +// writeOffsetValue writes the OFFSET value: Offset, else OffsetExpr. +func writeOffsetValue(buf *bytes.Buffer, s *SelectStatement) { + if s.Offset != nil { + writeInt(buf, int64(*s.Offset)) + return + } + writeExpr(buf, s.OffsetExpr) +} + +// writeLimitOffset writes the LIMIT BY, LIMIT and OFFSET clauses. OFFSET +// keeps ROWS when it was written or when FETCH follows (OFFSET n ROWS FETCH +// ... is required by SQL Server, Oracle and MariaDB). +func writeLimitOffset(buf *bytes.Buffer, s *SelectStatement) { + if lb := s.LimitBy; lb != nil && lb.Count != nil && len(lb.Exprs) > 0 { + buf.WriteString(" LIMIT ") + writeExpr(buf, lb.Count) + if lb.Offset != nil { + buf.WriteString(" OFFSET ") + writeExpr(buf, lb.Offset) + } + buf.WriteString(" BY ") + writeExprList(buf, lb.Exprs) + } + if s.HasLimit() { + buf.WriteString(" LIMIT ") + writeLimitValue(buf, s) + if s.LimitWithTies { + buf.WriteString(" WITH TIES") + } + } + if s.HasOffset() { + buf.WriteString(" OFFSET ") + writeOffsetValue(buf, s) + if s.OffsetRows || s.Fetch != nil { + buf.WriteByte(' ') + buf.WriteString(OffsetRowsKeyword(s)) + } + } +} + +// writeForOutput writes " FOR JSON|XML|BROWSE ...". +func writeForOutput(buf *bytes.Buffer, f *ForOutputClause) { + if f == nil { + return + } + kind := ForOutputKindText(f.Kind) + if kind == "" { + return + } + buf.WriteString(" FOR ") + buf.WriteString(kind) + if mode := ForOutputModeText(f.Mode); mode != "" { + buf.WriteByte(' ') + buf.WriteString(mode) + if f.ModeArg != nil { + buf.WriteByte('(') + writeExpr(buf, f.ModeArg) + buf.WriteByte(')') + } + } + for _, o := range f.Options { + name := ForOutputOptionText(o.Name) + if name == "" { + continue + } + buf.WriteString(", ") + buf.WriteString(name) + if o.Arg != nil { + buf.WriteByte('(') + writeExpr(buf, o.Arg) + buf.WriteByte(')') + } + } +} + +// writeQueryHints writes " OPTION (hint, ...)". +func writeQueryHints(buf *bytes.Buffer, hints []QueryHint) { + if len(hints) == 0 { + return + } + buf.WriteString(" OPTION (") + first := true + for i := range hints { + h := &hints[i] + name := QueryHintNameText(h.Name) + if name == "" { + continue + } + if !first { + buf.WriteString(", ") + } + first = false + buf.WriteString(name) + if h.Value != nil { + if h.Equals { + buf.WriteString(" = ") + } else { + buf.WriteByte(' ') + } + writeExpr(buf, h.Value) + } + if h.HasArgs { + buf.WriteString(" (") + for j, a := range h.Args { + if j > 0 { + buf.WriteString(", ") + } + writeExpr(buf, a.Expr) + if a.Unknown { + buf.WriteString(" UNKNOWN") + } + } + buf.WriteByte(')') + } + } + buf.WriteByte(')') +} + +// writeFormat writes " FORMAT name" (ClickHouse). +func writeFormat(buf *bytes.Buffer, name string) { + if n := FormatNameText(name); n != "" { + buf.WriteString(" FORMAT ") + buf.WriteString(n) + } +} + +// writeLockWait writes " WAIT n" for an Oracle / MariaDB lock timeout. +func writeLockWait(buf *bytes.Buffer, f *ForClause) { + if f.Wait != nil { + buf.WriteString(" WAIT ") + writeExpr(buf, f.Wait) + } +} + +// JoinKeywordsText returns the keywords written between the left side and +// the joined table of a (non-comma) join, space-separated: "LEFT JOIN", +// "GLOBAL ANY LEFT JOIN", "ASOF JOIN", "INNER HASH JOIN", "CROSS APPLY", +// "STRAIGHT_JOIN". An unknown join type, strictness or hint is dropped. +func JoinKeywordsText(j *JoinClause) string { + buf := getBuf() + defer putBuf(buf) + writeJoinKeywords(buf, j) + return buf.String() +} + +// writeJoinKeywords writes the JoinKeywordsText of j without allocating. +func writeJoinKeywords(buf *bytes.Buffer, j *JoinClause) { + t := JoinTypeText(j.Type) + if strings.EqualFold(t, "CROSS APPLY") { + buf.WriteString("CROSS APPLY") + return + } + if strings.EqualFold(t, "OUTER APPLY") { + buf.WriteString("OUTER APPLY") + return + } + if strings.EqualFold(j.Hint, "STRAIGHT_JOIN") { + buf.WriteString("STRAIGHT_JOIN") + return + } + if j.Global { + buf.WriteString("GLOBAL ") + } + if strictness := JoinStrictnessText(j.Strictness); strictness != "" { + writeUpperASCII(buf, strictness) + buf.WriteByte(' ') + // INNER is the default kind under a strictness, and Snowflake only + // accepts a bare ASOF JOIN. + if strings.EqualFold(t, "INNER") { + t = "" + } + } + if t != "" { + buf.WriteString(t) + buf.WriteByte(' ') + } + if h := JoinHintText(j.Hint); h != "" { + writeUpperASCII(buf, h) + buf.WriteByte(' ') + } + buf.WriteString("JOIN") +} + +// writeUpperASCII writes s upper-cased (ASCII only) without allocating. +func writeUpperASCII(buf *bytes.Buffer, s string) { + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' { + c -= 'a' - 'A' + } + buf.WriteByte(c) + } +} + +// FetchRowsKeyword returns ROW for a FETCH clause without a count or with a +// count of 1 (FETCH FIRST ROW ONLY, FETCH NEXT 1 ROW ONLY) and ROWS +// otherwise; the two keywords mean the same. +func FetchRowsKeyword(f *FetchClause) string { + if f == nil || (f.FetchValue == nil && f.FetchExpr == nil) || (f.FetchValue != nil && *f.FetchValue == 1) { + return "ROW" + } + return "ROWS" +} + +// OffsetRowsKeyword returns ROW for OFFSET 1 and ROWS otherwise; the two +// keywords mean the same. +func OffsetRowsKeyword(s *SelectStatement) string { + if s != nil && s.Offset != nil && *s.Offset == 1 { + return "ROW" + } + return "ROWS" +} + +// OrderUsingOperatorText returns op when it is a PostgreSQL ORDER BY ... +// USING sort operator (<, >, <=, >=) and "" otherwise. +func OrderUsingOperatorText(op string) string { + switch op { + case "<", ">", "<=", ">=": + return op + } + return "" +} + +// writeInterpolate writes " INTERPOLATE [(col [AS expr], ...)]". +func writeInterpolate(buf *bytes.Buffer, c *InterpolateClause) { + if c == nil { + return + } + buf.WriteString(" INTERPOLATE") + if len(c.Items) == 0 { + return + } + buf.WriteString(" (") + for i, it := range c.Items { + if i > 0 { + buf.WriteString(", ") + } + writeName(buf, it.Column) + if it.Expr != nil { + buf.WriteString(" AS ") + writeExpr(buf, it.Expr) + } + } + buf.WriteByte(')') +} diff --git a/pkg/sql/ast/select_ext_test.go b/pkg/sql/ast/select_ext_test.go new file mode 100644 index 00000000..b6e55851 --- /dev/null +++ b/pkg/sql/ast/select_ext_test.go @@ -0,0 +1,225 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "reflect" + "strings" + "testing" +) + +// TestSelectExt_SQL renders hand-built SELECT-body clauses through SQL(). +func TestSelectExt_SQL(t *testing.T) { + id := func(n string) *Identifier { return &Identifier{Name: n} } + str := func(s string) *LiteralValue { return &LiteralValue{Value: s, Type: "string"} } + num := func(s string) *LiteralValue { return &LiteralValue{Value: s, Type: "int"} } + ph := func(s string) *LiteralValue { return &LiteralValue{Value: s, Type: "placeholder"} } + base := func() *SelectStatement { + return &SelectStatement{Columns: []Expression{id("a")}, From: []TableReference{{Name: "t"}}} + } + cases := []struct { + name string + mod func(s *SelectStatement) + want string + }{ + {"limit expr", func(s *SelectStatement) { s.LimitExpr = ph("$1") }, "SELECT a FROM t LIMIT $1"}, + {"limit int wins over expr", func(s *SelectStatement) { v := 3; s.Limit = &v; s.LimitExpr = ph("$1") }, "SELECT a FROM t LIMIT 3"}, + {"limit all", func(s *SelectStatement) { s.LimitAll = true }, "SELECT a FROM t LIMIT ALL"}, + {"offset expr rows", func(s *SelectStatement) { s.OffsetExpr = ph("@s"); s.OffsetRows = true }, "SELECT a FROM t OFFSET @s ROWS"}, + {"offset one row", func(s *SelectStatement) { v := 1; s.Offset = &v; s.OffsetRows = true }, "SELECT a FROM t OFFSET 1 ROW"}, + {"offset gets rows before fetch", func(s *SelectStatement) { + v := 4 + s.Offset = &v + s.Fetch = &FetchClause{FetchType: "NEXT", FetchExpr: ph("@n")} + }, "SELECT a FROM t OFFSET 4 ROWS FETCH NEXT @n ROWS ONLY"}, + {"limit by", func(s *SelectStatement) { + s.LimitBy = &LimitByClause{Count: num("2"), Offset: num("1"), Exprs: []Expression{id("u"), id("p")}} + v := 100 + s.Limit = &v + }, "SELECT a FROM t LIMIT 2 OFFSET 1 BY u, p LIMIT 100"}, + {"group by all with totals", func(s *SelectStatement) { s.GroupByModifiers = GroupByModifiers{All: true, WithTotals: true} }, "SELECT a FROM t GROUP BY ALL WITH TOTALS"}, + {"group by with cube", func(s *SelectStatement) { + s.GroupBy = []Expression{id("a")} + s.GroupByModifiers.WithCube = true + }, "SELECT a FROM t GROUP BY a WITH CUBE"}, + {"modifiers and top", func(s *SelectStatement) { + s.SelectModifiers = []string{"HIGH_PRIORITY", "SQL_NO_CACHE"} + s.Top = &TopClause{Count: ph("@n")} + }, "SELECT HIGH_PRIORITY SQL_NO_CACHE TOP (@n) a FROM t"}, + {"top keeps parentheses", func(s *SelectStatement) { s.Top = &TopClause{Count: num("10"), Parenthesized: true} }, "SELECT TOP (10) a FROM t"}, + {"top bare number", func(s *SelectStatement) { s.Top = &TopClause{Count: num("10"), IsPercent: true} }, "SELECT TOP 10 PERCENT a FROM t"}, + {"into table", func(s *SelectStatement) { + s.Into = &SelectInto{Kind: SelectIntoTable, Table: "db.n", Temporary: "UNLOGGED", TableKeyword: true} + }, "SELECT a INTO UNLOGGED TABLE db.n FROM t"}, + {"into variables trailing", func(s *SelectStatement) { + s.Into = &SelectInto{Kind: SelectIntoVariables, Targets: []Expression{ph("@x")}, Trailing: true} + s.For = &ForClause{LockType: "UPDATE"} + }, "SELECT a FROM t INTO @x FOR UPDATE"}, + {"into dumpfile", func(s *SelectStatement) { + s.Into = &SelectInto{Kind: SelectIntoDumpfile, File: str("/tmp/x")} + }, "SELECT a INTO DUMPFILE '/tmp/x' FROM t"}, + {"into outfile options", func(s *SelectStatement) { + s.Into = &SelectInto{Kind: SelectIntoOutfile, File: str("f"), CharacterSet: "utf8mb4", Trailing: true, + FieldsOptions: []IntoFileOption{{Option: "TERMINATED BY", Value: str(",")}, {Option: "ESCAPED BY", Value: str("x")}}, + LinesOptions: []IntoFileOption{{Option: "STARTING BY", Value: str(">")}}} + }, "SELECT a FROM t INTO OUTFILE 'f' CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' ESCAPED BY 'x' LINES STARTING BY '>'"}, + {"order siblings and interpolate", func(s *SelectStatement) { + s.OrderBy = []OrderByExpression{{Expression: id("a"), Ascending: true, UsingOperator: ">"}} + s.OrderSiblings = true + s.Interpolate = &InterpolateClause{} + }, "SELECT a FROM t ORDER SIBLINGS BY a USING > INTERPOLATE"}, + {"for xml raw", func(s *SelectStatement) { + s.ForOutput = &ForOutputClause{Kind: "XML", Mode: "RAW", ModeArg: str("r"), Options: []ForOutputOption{{Name: "BINARY BASE64"}, {Name: "ROOT"}}} + }, "SELECT a FROM t FOR XML RAW('r'), BINARY BASE64, ROOT"}, + {"for browse", func(s *SelectStatement) { s.ForOutput = &ForOutputClause{Kind: "BROWSE"} }, "SELECT a FROM t FOR BROWSE"}, + {"query hints", func(s *SelectStatement) { + s.QueryHints = []QueryHint{ + {Name: "USE HINT", HasArgs: true, Args: []QueryHintArg{{Expr: str("X")}}}, + {Name: "MAX_GRANT_PERCENT", Equals: true, Value: num("5")}, + } + }, "SELECT a FROM t OPTION (USE HINT ('X'), MAX_GRANT_PERCENT = 5)"}, + {"lock in share mode wait", func(s *SelectStatement) { + s.For = &ForClause{LockType: "SHARE", LockInShareMode: true, Wait: num("3")} + }, "SELECT a FROM t LOCK IN SHARE MODE WAIT 3"}, + {"format", func(s *SelectStatement) { s.OutputFormat = "TSV" }, "SELECT a FROM t FORMAT TSV"}, + {"qualify", func(s *SelectStatement) { s.Qualify = id("q") }, "SELECT a FROM t QUALIFY q"}, + {"joins", func(s *SelectStatement) { + s.Joins = []JoinClause{ + {Type: "INNER", Global: true, Strictness: "all", Right: TableReference{Name: "b"}, Using: []Expression{id("k")}}, + {Type: "LEFT", Hint: "merge", Right: TableReference{Name: "c"}, Condition: id("x")}, + {Type: "CROSS", Comma: true, Right: TableReference{Name: "d"}}, + {Type: "INNER", Hint: "STRAIGHT_JOIN", Right: TableReference{Name: "e"}}, + {Type: "OUTER APPLY", Right: TableReference{Name: "f"}}, + {Type: "INNER", Strictness: "ASOF", Right: TableReference{Name: "g"}, MatchCondition: id("m"), Condition: id("y")}, + } + }, "SELECT a FROM t GLOBAL ALL JOIN b USING (k) LEFT MERGE JOIN c ON x , d STRAIGHT_JOIN e OUTER APPLY f ASOF JOIN g MATCH_CONDITION (m) ON y"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := base() + tc.mod(s) + if got := s.SQL(); got != tc.want { + t.Errorf("SQL()\n got: %s\nwant: %s", got, tc.want) + } + }) + } +} + +// TestSelectExt_UnknownKeywordsDropped checks that a hand-built AST cannot +// inject SQL through the keyword-valued fields of the new clauses. +func TestSelectExt_UnknownKeywordsDropped(t *testing.T) { + const evil = "x; DROP TABLE users; --" + s := &SelectStatement{ + Columns: []Expression{&Identifier{Name: "a"}}, + From: []TableReference{{Name: "t"}}, + SelectModifiers: []string{evil}, + Into: &SelectInto{Kind: SelectIntoTable, Table: "n", Temporary: evil}, + ForOutput: &ForOutputClause{Kind: "JSON", Mode: evil, Options: []ForOutputOption{{Name: evil}}}, + QueryHints: []QueryHint{{Name: evil}}, + OutputFormat: evil, + Joins: []JoinClause{{Type: "INNER", Strictness: evil, Hint: evil, Right: TableReference{Name: "b"}}}, + OrderBy: []OrderByExpression{{Expression: &Identifier{Name: "a"}, UsingOperator: evil}}, + } + got := s.SQL() + if strings.Contains(got, "DROP") { + t.Fatalf("SQL() wrote an unknown keyword: %s", got) + } + if forced := (&SelectStatement{Columns: []Expression{&Identifier{Name: "a"}}, ForOutput: &ForOutputClause{Kind: evil}}).SQL(); strings.Contains(forced, "DROP") || strings.Contains(forced, "FOR") { + t.Fatalf("unknown FOR kind written: %s", forced) + } +} + +// TestSelectExt_Children checks that the new clause nodes expose their +// expression fields to the visitor. +func TestSelectExt_Children(t *testing.T) { + cases := []childrenCoverageCase{ + {"StarExpression", reflect.TypeOf(StarExpression{})}, + {"LimitByClause", reflect.TypeOf(LimitByClause{})}, + {"SelectInto", reflect.TypeOf(SelectInto{})}, + {"ForOutputClause", reflect.TypeOf(ForOutputClause{})}, + {"InterpolateClause", reflect.TypeOf(InterpolateClause{})}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ptr := reflect.New(c.typ) + injected := injectMockChildren(t, ptr.Elem()) + got := callChildren(t, ptr) + for _, want := range injected { + if !containsNode(got, want.node) { + t.Errorf("%s.Children() dropped %s", c.name, want.fieldPath) + } + } + // Zero values must be safe. + _ = callChildren(t, reflect.New(c.typ)) + }) + } + + // Nested expression fields reached through the statement. + lim, off, cnt := &Identifier{Name: "l"}, &Identifier{Name: "o"}, &Identifier{Name: "c"} + s := SelectStatement{ + LimitExpr: lim, + OffsetExpr: off, + LimitBy: &LimitByClause{Count: cnt}, + QueryHints: []QueryHint{{Name: "MAXDOP", Value: &Identifier{Name: "h"}}}, + } + got := s.Children() + for _, want := range []Node{lim, off, s.LimitBy, s.QueryHints[0].Value} { + if !containsNode(got, want) { + t.Errorf("SelectStatement.Children() lacks %v", want) + } + } +} + +// TestSelectExt_ReleaseClearsFields checks that releasing a statement clears +// every field added for the new clauses, so a pooled statement never leaks +// them into its next use. +func TestSelectExt_ReleaseClearsFields(t *testing.T) { + n := 3 + s := &SelectStatement{ + SelectModifiers: []string{"SQL_NO_CACHE"}, + Into: &SelectInto{Kind: SelectIntoVariables, Targets: []Expression{&Identifier{Name: "@x"}}}, + GroupByModifiers: GroupByModifiers{All: true}, + OrderSiblings: true, + Interpolate: &InterpolateClause{Items: []InterpolateItem{{Column: "c", Expr: &Identifier{Name: "c"}}}}, + LimitExpr: &Identifier{Name: "$1"}, + OffsetExpr: &Identifier{Name: "$2"}, + LimitAll: true, + OffsetRows: true, + LimitWithTies: true, + LimitBy: &LimitByClause{Count: &Identifier{Name: "n"}}, + ForOutput: &ForOutputClause{Kind: "JSON"}, + QueryHints: []QueryHint{{Name: "RECOMPILE"}}, + OutputFormat: "JSON", + Limit: &n, + Fetch: &FetchClause{FetchExpr: &Identifier{Name: "f"}}, + For: &ForClause{Wait: &Identifier{Name: "w"}}, + Joins: []JoinClause{{Global: true, Strictness: "ANY", Hint: "HASH", MatchCondition: &Identifier{Name: "m"}, Comma: true}}, + } + releaseSelectStatement(s, releaseDrawn) + rv := reflect.ValueOf(s).Elem() + rt := rv.Type() + for i := 0; i < rv.NumField(); i++ { + f := rv.Field(i) + if f.Kind() == reflect.Slice { + if f.Len() != 0 { + t.Errorf("field %s not reset: len=%d", rt.Field(i).Name, f.Len()) + } + continue + } + if !f.IsZero() { + t.Errorf("field %s not reset: %#v", rt.Field(i).Name, f.Interface()) + } + } +} diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 7372fb1b..84f6a8f4 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1178,9 +1178,15 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { } else if s.Distinct { buf.WriteString("DISTINCT ") } + writeSelectModifiers(buf, s.SelectModifiers) + writeTop(buf, s.Top) writeExprList(buf, s.Columns) + if s.Into != nil && !s.Into.Trailing { + writeSelectInto(buf, s.Into) + } + if len(s.From) > 0 { buf.WriteString(" FROM ") writeTableRefList(buf, s.From) @@ -1224,16 +1230,18 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { writeExpr(buf, s.Where) } - if len(s.GroupBy) > 0 { - buf.WriteString(" GROUP BY ") - writeExprList(buf, s.GroupBy) - } + writeGroupBy(buf, s) if s.Having != nil { buf.WriteString(" HAVING ") writeExpr(buf, s.Having) } + if s.Qualify != nil { + buf.WriteString(" QUALIFY ") + writeExpr(buf, s.Qualify) + } + // MariaDB hierarchical query clauses (10.2+): START WITH ... CONNECT BY ... // These must appear after HAVING and before ORDER BY per MariaDB grammar. if s.StartWith != nil { @@ -1259,32 +1267,38 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { } if len(s.OrderBy) > 0 { - buf.WriteString(" ORDER BY ") + if s.OrderSiblings { + buf.WriteString(" ORDER SIBLINGS BY ") + } else { + buf.WriteString(" ORDER BY ") + } writeOrderBy(buf, s.OrderBy) } + writeInterpolate(buf, s.Interpolate) - if s.Limit != nil { - buf.WriteString(" LIMIT ") - writeInt(buf, int64(*s.Limit)) - } - - if s.Offset != nil { - buf.WriteString(" OFFSET ") - writeInt(buf, int64(*s.Offset)) - } + writeLimitOffset(buf, s) if s.Fetch != nil { writeFetch(buf, s.Fetch) } + if s.Into != nil && s.Into.Trailing { + writeSelectInto(buf, s.Into) + } + if s.For != nil { writeFor(buf, s.For) } + writeForOutput(buf, s.ForOutput) + if len(s.Settings) > 0 { buf.WriteString(" SETTINGS ") writeQuerySettings(buf, s.Settings) } + + writeQueryHints(buf, s.QueryHints) + writeFormat(buf, s.OutputFormat) } // SQL returns the full SQL string for this INSERT statement, including the @@ -2116,7 +2130,10 @@ func writeOrderBy(buf *bytes.Buffer, orders []OrderByExpression) { buf.WriteString(", ") } writeExpr(buf, o.Expression) - if !o.Ascending { + if op := OrderUsingOperatorText(o.UsingOperator); op != "" { + buf.WriteString(" USING ") + buf.WriteString(op) + } else if !o.Ascending { buf.WriteString(" DESC") } if o.NullsFirst != nil { @@ -2224,13 +2241,19 @@ func writeTableRefList(buf *bytes.Buffer, refs []TableReference) { } func writeJoin(buf *bytes.Buffer, j *JoinClause) { - // An unknown join type is dropped, leaving a plain JOIN. - if t := JoinTypeText(j.Type); t != "" { - buf.WriteString(t) - buf.WriteByte(' ') + if j.Comma { + buf.WriteString(", ") + writeTableRef(buf, &j.Right) + return } - buf.WriteString("JOIN ") + writeJoinKeywords(buf, j) + buf.WriteByte(' ') writeTableRef(buf, &j.Right) + if j.MatchCondition != nil { + buf.WriteString(" MATCH_CONDITION (") + writeExpr(buf, j.MatchCondition) + buf.WriteByte(')') + } if len(j.Using) > 0 { buf.WriteString(" USING (") for i, u := range j.Using { @@ -2317,11 +2340,15 @@ func writeFetch(buf *bytes.Buffer, f *FetchClause) { if f.FetchValue != nil { buf.WriteByte(' ') writeInt(buf, *f.FetchValue) + } else if f.FetchExpr != nil { + buf.WriteByte(' ') + writeExpr(buf, f.FetchExpr) } if f.IsPercent { buf.WriteString(" PERCENT") } - buf.WriteString(" ROWS") + buf.WriteByte(' ') + buf.WriteString(FetchRowsKeyword(f)) if f.WithTies { buf.WriteString(" WITH TIES") } else { @@ -2330,6 +2357,14 @@ func writeFetch(buf *bytes.Buffer, f *FetchClause) { } func writeFor(buf *bytes.Buffer, f *ForClause) { + if f.LockInShareMode { + buf.WriteString(" LOCK IN SHARE MODE") + if f.NoWait { + buf.WriteString(" NOWAIT") + } + writeLockWait(buf, f) + return + } buf.WriteString(" FOR ") buf.WriteString(LockTypeText(f.LockType)) if len(f.Tables) > 0 { @@ -2342,6 +2377,7 @@ func writeFor(buf *bytes.Buffer, f *ForClause) { if f.SkipLocked { buf.WriteString(" SKIP LOCKED") } + writeLockWait(buf, f) } func writeCTE(buf *bytes.Buffer, cte *CommonTableExpr) { diff --git a/pkg/sql/ast/sql_golden_test.go b/pkg/sql/ast/sql_golden_test.go index 4f4bc3a2..5d7b6b37 100644 --- a/pkg/sql/ast/sql_golden_test.go +++ b/pkg/sql/ast/sql_golden_test.go @@ -132,7 +132,7 @@ var goldenParseCases = []goldenParseCase{ {"extra/intersect", "", "SELECT a FROM t1 INTERSECT SELECT a FROM t2", "SELECT a FROM t1 INTERSECT SELECT a FROM t2", true}, {"extra/insert select", "", "INSERT INTO t (a) SELECT a FROM u", "INSERT INTO t (a) SELECT a FROM u", true}, {"extra/with insert", "", "WITH c AS (SELECT 1 AS a) INSERT INTO t (a) SELECT a FROM c", "WITH c AS (SELECT 1 AS a) INSERT INTO t (a) SELECT a FROM c", true}, - {"extra/offset fetch", "", "SELECT a FROM t ORDER BY a OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY", "SELECT a FROM t ORDER BY a OFFSET 5 FETCH NEXT 10 ROWS ONLY", true}, + {"extra/offset fetch", "", "SELECT a FROM t ORDER BY a OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY", "SELECT a FROM t ORDER BY a OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY", true}, {"extra/tuple in", "", "SELECT * FROM t WHERE (a, b) IN ((1, 2), (3, 4))", "SELECT * FROM t WHERE (a, b) IN ((1, 2), (3, 4))", true}, {"extra/cube", "", "SELECT a, b FROM t GROUP BY CUBE(a, b)", "SELECT a, b FROM t GROUP BY CUBE(a, b)", true}, {"extra/all subquery", "", "SELECT * FROM t WHERE a > ALL (SELECT b FROM u)", "SELECT * FROM t WHERE a > ALL (SELECT b FROM u)", true}, @@ -458,9 +458,9 @@ var goldenNodeWant = map[string]string{ "rollup": "ROLLUP(a, b)", "cube": "CUBE(a)", "grouping sets": "GROUPING SETS((a, b), ())", - "select/everything": "WITH RECURSIVE c (x) AS NOT MATERIALIZED (SELECT 1 FROM t), 10 AS k SELECT DISTINCT ON (a) a, b AS bb FROM t x FINAL WITH (NOLOCK), LATERAL (SELECT z FROM t) s, h FOR SYSTEM_TIME ALL LEFT JOIN u y ON x.id = y.id CROSS JOIN v LEFT ARRAY JOIN arr AS e, arr2 SAMPLE 1/10 OFFSET 2/10 PREWHERE p = 1 WHERE w = 2 GROUP BY a, ROLLUP(b) HAVING h = 3 START WITH parent IS NULL CONNECT BY NOCYCLE PRIOR id = parent WINDOW w1 AS (PARTITION BY a RANGE UNBOUNDED PRECEDING), w2 AS (w1 ORDER BY b) ORDER BY a DESC NULLS LAST WITH FILL FROM 1 TO 10 STEP 2, b WITH FILL LIMIT 10 OFFSET -5 OFFSET 3 ROWS FETCH NEXT 7 PERCENT ROWS WITH TIES FOR SHARE OF t, u NOWAIT SKIP LOCKED", + "select/everything": "WITH RECURSIVE c (x) AS NOT MATERIALIZED (SELECT 1 FROM t), 10 AS k SELECT DISTINCT ON (a) a, b AS bb FROM t x FINAL WITH (NOLOCK), LATERAL (SELECT z FROM t) s, h FOR SYSTEM_TIME ALL LEFT JOIN u y ON x.id = y.id CROSS JOIN v LEFT ARRAY JOIN arr AS e, arr2 SAMPLE 1/10 OFFSET 2/10 PREWHERE p = 1 WHERE w = 2 GROUP BY a, ROLLUP(b) HAVING h = 3 START WITH parent IS NULL CONNECT BY NOCYCLE PRIOR id = parent WINDOW w1 AS (PARTITION BY a RANGE UNBOUNDED PRECEDING), w2 AS (w1 ORDER BY b) ORDER BY a DESC NULLS LAST WITH FILL FROM 1 TO 10 STEP 2, b WITH FILL LIMIT 10 OFFSET -5 ROWS OFFSET 3 ROWS FETCH NEXT 7 PERCENT ROWS WITH TIES FOR SHARE OF t, u NOWAIT SKIP LOCKED", "select/distinct no from": "SELECT DISTINCT 1", - "select/fetch minimal": "SELECT a FETCH FIRST ROWS ONLY", + "select/fetch minimal": "SELECT a FETCH FIRST ROW ONLY", "select/empty": "SELECT ", "insert/full": "WITH c AS MATERIALIZED (SELECT 1 FROM t) INSERT INTO t (a, b) VALUES (1, 'x'), (2, NULL) ON CONFLICT (a) ON CONSTRAINT pk DO UPDATE SET b = 'y', c = 1 WHERE d = 0 RETURNING a, b", "insert/query": "INSERT INTO t SELECT a FROM t UNION ALL SELECT b FROM t", diff --git a/pkg/sql/ast/star_expression.go b/pkg/sql/ast/star_expression.go new file mode 100644 index 00000000..eed0f7eb --- /dev/null +++ b/pkg/sql/ast/star_expression.go @@ -0,0 +1,135 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "bytes" + "strings" +) + +// StarExpression is a select-list wildcard with column modifiers: +// +// SELECT * EXCLUDE (a, b) FROM t -- Snowflake, DuckDB +// SELECT * EXCEPT (a) FROM t -- ClickHouse, BigQuery +// SELECT * REPLACE (UPPER(name) AS name) FROM t -- Snowflake, ClickHouse, BigQuery +// SELECT t.* RENAME (id AS t_id) FROM t -- Snowflake +// SELECT * ILIKE '%name%' FROM t -- Snowflake +// +// A plain "*" or "t.*" without modifiers stays an *Identifier with Name "*"; +// StarExpression is only produced when at least one modifier is present. +type StarExpression struct { + // Star is the wildcard itself: an *Identifier with Name "*" and an + // optional Table qualifier. + Star Expression + // Ilike is the Snowflake ILIKE pattern (a string literal), or nil. + Ilike Expression + // ExcludeKeyword is EXCLUDE or EXCEPT; Exclude lists the excluded columns. + ExcludeKeyword string + Exclude []string + // Replace holds REPLACE (expr AS col, ...) items. + Replace []*AliasedExpression + // Rename holds RENAME (col AS new_name, ...) items. + Rename []StarRename +} + +// StarRename is one RENAME (old AS new) item of a StarExpression. +type StarRename struct { + Column string + Alias string +} + +func (s *StarExpression) expressionNode() {} +func (s StarExpression) TokenLiteral() string { return "*" } + +// Children returns the wildcard, the ILIKE pattern and the REPLACE items. +func (s StarExpression) Children() []Node { + nodes := make([]Node, 0, 2+len(s.Replace)) + if s.Star != nil { + nodes = append(nodes, s.Star) + } + if s.Ilike != nil { + nodes = append(nodes, s.Ilike) + } + for _, r := range s.Replace { + if r != nil { + nodes = append(nodes, r) + } + } + return nodes +} + +// SQL renders the wildcard and its modifiers. +func (s *StarExpression) SQL() string { + if s == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + s.writeSQL(buf) + return buf.String() +} + +func (s *StarExpression) writeSQL(buf *bytes.Buffer) { + if s == nil { + return + } + if s.Star != nil { + writeExpr(buf, s.Star) + } else { + buf.WriteByte('*') + } + if s.Ilike != nil { + buf.WriteString(" ILIKE ") + writeExpr(buf, s.Ilike) + } + if len(s.Exclude) > 0 { + kw := "EXCLUDE" + if strings.EqualFold(s.ExcludeKeyword, "EXCEPT") { + kw = "EXCEPT" + } + buf.WriteByte(' ') + buf.WriteString(kw) + buf.WriteString(" (") + writeNameList(buf, s.Exclude) + buf.WriteByte(')') + } + if len(s.Replace) > 0 { + buf.WriteString(" REPLACE (") + for i, r := range s.Replace { + if i > 0 { + buf.WriteString(", ") + } + if r == nil { + continue + } + writeExpr(buf, r.Expr) + buf.WriteString(" AS ") + writeName(buf, r.Alias) + } + buf.WriteByte(')') + } + if len(s.Rename) > 0 { + buf.WriteString(" RENAME (") + for i, r := range s.Rename { + if i > 0 { + buf.WriteString(", ") + } + writeName(buf, r.Column) + buf.WriteString(" AS ") + writeName(buf, r.Alias) + } + buf.WriteByte(')') + } +} diff --git a/pkg/sql/parser/clause_alloc_free_test.go b/pkg/sql/parser/clause_alloc_free_test.go index 92aa80f2..9e3ca902 100644 --- a/pkg/sql/parser/clause_alloc_free_test.go +++ b/pkg/sql/parser/clause_alloc_free_test.go @@ -15,8 +15,6 @@ package parser import ( - "fmt" - "strconv" "strings" "testing" @@ -46,60 +44,43 @@ func parseSelectWithDialect(t *testing.T, sql string, dialect keywords.SQLDialec return stmt, tree } -// TestParseLeadingInt_MatchesSscanf pins parseLeadingInt against -// fmt.Sscanf("%d"), the implementation it replaces. Leading whitespace is -// excluded on purpose: Number tokens never carry it. -func TestParseLeadingInt_MatchesSscanf(t *testing.T) { - inputs := []string{ - "10", "-5", "+5", "10.5", "", "abc", - "99999999999999999999", "9223372036854775807", "9223372036854775808", - "-9223372036854775808", "1e5", "0x10", "007", "1_000", "5abc", "-", "+-5", - } - for _, in := range inputs { - t.Run(fmt.Sprintf("%q", in), func(t *testing.T) { - var wantInt int - _, _ = fmt.Sscanf(in, "%d", &wantInt) - if got := int(parseLeadingInt(in, strconv.IntSize)); got != wantInt { - t.Errorf("int: parseLeadingInt = %d, Sscanf = %d", got, wantInt) - } - var want64 int64 - _, _ = fmt.Sscanf(in, "%d", &want64) - if got := parseLeadingInt(in, 64); got != want64 { - t.Errorf("int64: parseLeadingInt = %d, Sscanf = %d", got, want64) - } - }) - } - - if raceEnabled { - return - } - if n := testing.AllocsPerRun(1000, func() { _ = parseLeadingInt("20", strconv.IntSize) }); n != 0 { - t.Errorf("parseLeadingInt allocs = %v, want 0", n) - } -} - -// TestParseLimitOffsetFetch_LenientNumeric pins the lenient numeric handling -// of LIMIT, OFFSET and FETCH end to end. +// TestParseLimitOffsetFetch_LenientNumeric pins the numeric handling of +// LIMIT, OFFSET and FETCH end to end: a plain integer fills the *int field, +// and anything else (decimals, exponents, values that overflow int) is kept +// verbatim as an expression rather than truncated, so formatting never +// changes the row count. func TestParseLimitOffsetFetch_LenientNumeric(t *testing.T) { intp := func(v int) *int { return &v } i64p := func(v int64) *int64 { return &v } tests := []struct { - name string - sql string - dialect keywords.SQLDialect - wantLimit *int - wantOff *int - wantFetch *int64 + name string + sql string + dialect keywords.SQLDialect + wantLimit *int + wantOff *int + wantLimitExpr string + wantOffExpr string + wantFetch *int64 + wantFetchExpr string }{ - {"limit offset", "SELECT id FROM t LIMIT 10 OFFSET 20", keywords.DialectPostgreSQL, intp(10), intp(20), nil}, - {"decimal literals", "SELECT id FROM t LIMIT 10.5 OFFSET 2.75", keywords.DialectPostgreSQL, intp(10), intp(2), nil}, - {"exponent literal", "SELECT id FROM t LIMIT 1e2", keywords.DialectPostgreSQL, intp(1), nil, nil}, - {"overflow limit", "SELECT id FROM t LIMIT 99999999999999999999", keywords.DialectPostgreSQL, intp(0), nil, nil}, - {"mysql offset,count", "SELECT id FROM t LIMIT 2.5, 7.9", keywords.DialectMySQL, intp(7), intp(2), nil}, - {"fetch first", "SELECT id FROM t FETCH FIRST 5 ROWS ONLY", keywords.DialectPostgreSQL, nil, nil, i64p(5)}, - {"fetch decimal", "SELECT id FROM t OFFSET 20 ROWS FETCH NEXT 5.5 ROWS ONLY", keywords.DialectPostgreSQL, nil, nil, i64p(5)}, - {"fetch overflow", "SELECT id FROM t FETCH FIRST 99999999999999999999 ROWS ONLY", keywords.DialectPostgreSQL, nil, nil, i64p(0)}, + {name: "limit offset", sql: "SELECT id FROM t LIMIT 10 OFFSET 20", dialect: keywords.DialectPostgreSQL, wantLimit: intp(10), wantOff: intp(20)}, + {name: "decimal literals", sql: "SELECT id FROM t LIMIT 10.5 OFFSET 2.75", dialect: keywords.DialectPostgreSQL, wantLimitExpr: "10.5", wantOffExpr: "2.75"}, + {name: "exponent literal", sql: "SELECT id FROM t LIMIT 1e2", dialect: keywords.DialectPostgreSQL, wantLimitExpr: "1e2"}, + {name: "overflow limit", sql: "SELECT id FROM t LIMIT 99999999999999999999", dialect: keywords.DialectPostgreSQL, wantLimitExpr: "99999999999999999999"}, + {name: "mysql offset,count", sql: "SELECT id FROM t LIMIT 2.5, 7.9", dialect: keywords.DialectMySQL, wantLimitExpr: "7.9", wantOffExpr: "2.5"}, + {name: "fetch first", sql: "SELECT id FROM t FETCH FIRST 5 ROWS ONLY", dialect: keywords.DialectPostgreSQL, wantFetch: i64p(5)}, + {name: "fetch decimal", sql: "SELECT id FROM t OFFSET 20 ROWS FETCH NEXT 5.5 ROWS ONLY", dialect: keywords.DialectPostgreSQL, wantOff: intp(20), wantFetchExpr: "5.5"}, + {name: "fetch overflow", sql: "SELECT id FROM t FETCH FIRST 99999999999999999999 ROWS ONLY", dialect: keywords.DialectPostgreSQL, wantFetchExpr: "99999999999999999999"}, + } + exprText := func(e ast.Expression) string { + if e == nil { + return "" + } + if s, ok := e.(interface{ SQL() string }); ok { + return s.SQL() + } + return e.TokenLiteral() } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -116,15 +97,26 @@ func TestParseLimitOffsetFetch_LenientNumeric(t *testing.T) { } } cmpInt("Limit", stmt.Limit, tt.wantLimit) - if tt.wantFetch == nil { - cmpInt("Offset", stmt.Offset, tt.wantOff) + cmpInt("Offset", stmt.Offset, tt.wantOff) + if got := exprText(stmt.LimitExpr); got != tt.wantLimitExpr { + t.Errorf("LimitExpr = %q, want %q", got, tt.wantLimitExpr) + } + if got := exprText(stmt.OffsetExpr); got != tt.wantOffExpr { + t.Errorf("OffsetExpr = %q, want %q", got, tt.wantOffExpr) + } + if tt.wantFetch == nil && tt.wantFetchExpr == "" { return } - if stmt.Fetch == nil || stmt.Fetch.FetchValue == nil { - t.Fatalf("expected FetchValue %d, got Fetch=%+v", *tt.wantFetch, stmt.Fetch) + if stmt.Fetch == nil { + t.Fatalf("expected a FETCH clause") + } + if tt.wantFetch != nil { + if stmt.Fetch.FetchValue == nil || *stmt.Fetch.FetchValue != *tt.wantFetch { + t.Errorf("FetchValue = %v, want %d", stmt.Fetch.FetchValue, *tt.wantFetch) + } } - if *stmt.Fetch.FetchValue != *tt.wantFetch { - t.Errorf("FetchValue = %d, want %d", *stmt.Fetch.FetchValue, *tt.wantFetch) + if got := exprText(stmt.Fetch.FetchExpr); got != tt.wantFetchExpr { + t.Errorf("FetchExpr = %q, want %q", got, tt.wantFetchExpr) } }) } diff --git a/pkg/sql/parser/clickhouse_select_settings.go b/pkg/sql/parser/clickhouse_select_settings.go index d343197e..4af3aa60 100644 --- a/pkg/sql/parser/clickhouse_select_settings.go +++ b/pkg/sql/parser/clickhouse_select_settings.go @@ -43,6 +43,17 @@ func (p *Parser) parseTrailingSelectSettings(stmt ast.Statement) error { return goerrors.InvalidSyntaxError("SETTINGS must follow a SELECT", loc, "") } target.Settings = settings + // Trailing output-format hint: SETTINGS ... FORMAT . Format names + // may tokenize as keywords or literals (Values, Null), so any word token + // is accepted, using the same rule as setting names. A name is required. + if !p.isStringLiteral() && strings.EqualFold(p.currentToken.Token.Value, "FORMAT") { + p.advance() // FORMAT + if !p.isQuerySettingName() || ast.FormatNameText(p.currentToken.Token.Value) == "" { + return p.expectedError("format name after FORMAT") + } + target.OutputFormat = p.currentToken.Token.Value + p.advance() // format name + } return nil } @@ -83,18 +94,8 @@ func (p *Parser) parseQuerySettingsList() ([]ast.QuerySetting, error) { p.advance() // , } - // Trailing ClickHouse output-format hint: SETTINGS ... FORMAT . - // Parse-only; FORMAT is not modeled on the AST (pre-existing, see the - // INSERT ... FORMAT handling in dml_insert.go). Format names may tokenize - // as keywords or literals (Values, Null), so any word token is accepted, - // using the same rule as setting names. A name is required. - if !p.isStringLiteral() && strings.EqualFold(p.currentToken.Token.Value, "FORMAT") { - p.advance() // FORMAT - if !p.isQuerySettingName() { - return nil, p.expectedError("format name after FORMAT") - } - p.advance() // format name - } + // A trailing FORMAT is parsed by the caller + // (parseTrailingSelectSettings stores it on the SELECT). return settings, nil } diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index d3c6a4af..233f1e03 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -1405,7 +1405,7 @@ func (p *Parser) nextStartsTableClause() bool { // but NOT contextual clause keywords that would be consumed as aliases by mistake // (e.g. MINUS in Snowflake/Oracle, QUALIFY in Snowflake/BigQuery, WINDOW). func (p *Parser) canBeAlias() bool { - if p.isMinusSetOp() || p.isQualifyKeyword() || p.isWindowClauseKeyword() || p.isSettingsKeyword() || p.isDMLReturningKeyword() { + if p.isMinusSetOp() || p.isQualifyKeyword() || p.isWindowClauseKeyword() || p.isSettingsKeyword() || p.isDMLReturningKeyword() || p.isSelectExtClauseStart() { return false } return p.isIdentifier() || p.isNonReservedKeyword() @@ -1448,7 +1448,7 @@ func (p *Parser) isJoinKeyword() bool { (p.isType(models.TokenTypeAny) || p.isType(models.TokenTypeAll)) { return true } - return false + return p.isExtJoinStart() } // parseWithStatement parses a WITH statement (Common Table Expression). diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index e3fd52bb..3fee82f6 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -42,6 +42,15 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { return nil, err } + // MySQL / MariaDB select modifiers (SQL_CALC_FOUND_ROWS, STRAIGHT_JOIN, ...), + // which may also precede DISTINCT. + selectModifiers := p.parseSelectModifiers() + if len(selectModifiers) > 0 && !isDistinct && p.isType(models.TokenTypeDistinct) { + if isDistinct, distinctOnColumns, err = p.parseDistinctModifier(); err != nil { + return nil, err + } + } + // Reject TOP in dialects that use LIMIT/OFFSET or ROWNUM/FETCH FIRST instead. // String comparison is used here because the lexer has no TokenTypeTOP constant; // TOP is emitted as an identifier literal (see parseTopClause comment). @@ -72,6 +81,14 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { return nil, err } + // SELECT ... INTO target (SQL Server, PostgreSQL, MySQL, Oracle PL/SQL) + var into *ast.SelectInto + if p.isType(models.TokenTypeInto) && p.supportsSelectInto() { + if into, err = p.parseSelectInto(false); err != nil { + return nil, err + } + } + // FROM … JOIN clauses tableName, tables, joins, err := p.parseFromClause() if err != nil { @@ -82,8 +99,10 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { selectStmt := &ast.SelectStatement{ Distinct: isDistinct, DistinctOnColumns: distinctOnColumns, + SelectModifiers: selectModifiers, Top: topClause, Columns: columns, + Into: into, From: tables, Joins: joins, TableName: tableName, @@ -122,7 +141,7 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { } // GROUP BY - if selectStmt.GroupBy, err = p.parseGroupByClause(); err != nil { + if selectStmt.GroupBy, err = p.parseGroupByClause(selectStmt); err != nil { return nil, err } @@ -197,6 +216,18 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { } cb.Condition = cond selectStmt.ConnectBy = cb + + // Oracle also accepts START WITH after CONNECT BY. + if selectStmt.StartWith == nil && p.isMariaDBClauseStart() && + equalsUpper(p.currentToken.Token.Value, "START") { + p.advance() // START + p.advance() // WITH + startExpr, startErr := p.parseExpression() + if startErr != nil { + return nil, startErr + } + selectStmt.StartWith = startExpr + } } } @@ -233,30 +264,28 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { } } - // ORDER BY - if selectStmt.OrderBy, err = p.parseOrderByClause(); err != nil { + // ORDER [SIBLINGS] BY + if p.isOrderSiblingsBy() { + p.advance() // ORDER + p.advance() // SIBLINGS + selectStmt.OrderSiblings = true + if selectStmt.OrderBy, err = p.parseOrderByList(); err != nil { + return nil, err + } + } else if selectStmt.OrderBy, err = p.parseOrderByClause(); err != nil { return nil, err } - - // LIMIT / OFFSET - if selectStmt.Limit, selectStmt.Offset, err = p.parseLimitOffsetClause(); err != nil { - return nil, err + // ClickHouse ORDER BY ... WITH FILL INTERPOLATE [(col AS expr, ...)] + if len(selectStmt.OrderBy) > 0 && p.dialect == string(keywords.DialectClickHouse) && + p.isUnquotedWord("INTERPOLATE") { + if selectStmt.Interpolate, err = p.parseInterpolate(); err != nil { + return nil, err + } } - // ClickHouse LIMIT BY: `LIMIT N [OFFSET M] BY expr [, expr]...` - // The LIMIT and OFFSET values were already consumed above; if the next - // token is BY, consume the BY-expression list (permissive, not modeled). - if p.dialect == string(keywords.DialectClickHouse) && p.isType(models.TokenTypeBy) { - p.advance() // Consume BY - for { - if _, err := p.parseExpression(); err != nil { - return nil, err - } - if !p.isType(models.TokenTypeComma) { - break - } - p.advance() - } + // LIMIT / OFFSET (either order), ClickHouse LIMIT ... BY + if err = p.parseLimitOffsetInto(selectStmt); err != nil { + return nil, err } // FETCH FIRST / NEXT @@ -266,16 +295,46 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { } } - // FOR UPDATE / SHARE / … - if p.isType(models.TokenTypeFor) { + // MySQL: INTO after the FROM ... clauses + if selectStmt.Into == nil && p.isMySQLFamily() && p.isType(models.TokenTypeInto) { + if selectStmt.Into, err = p.parseSelectInto(true); err != nil { + return nil, err + } + } + + // FOR UPDATE / SHARE / …, MySQL LOCK IN SHARE MODE + if p.isType(models.TokenTypeFor) && !p.isForOutputStart() { if selectStmt.For, err = p.parseForClause(); err != nil { return nil, err } + } else if p.isLockInShareMode() { + if selectStmt.For, err = p.parseLockInShareMode(); err != nil { + return nil, err + } + } + + // MySQL 8: INTO after the locking clause + if selectStmt.Into == nil && selectStmt.For != nil && p.isMySQLFamily() && p.isType(models.TokenTypeInto) { + if selectStmt.Into, err = p.parseSelectInto(true); err != nil { + return nil, err + } + } + + // SQL Server FOR JSON / XML and OPTION (...), ClickHouse FORMAT + if err = p.parseSelectTail(selectStmt); err != nil { + return nil, err } return selectStmt, nil } +// isOrderSiblingsBy reports whether the current tokens are Oracle +// ORDER SIBLINGS BY. +func (p *Parser) isOrderSiblingsBy() bool { + return p.dialect == string(keywords.DialectOracle) && p.isType(models.TokenTypeOrder) && + equalsUpper(p.peekToken().Token.Value, "SIBLINGS") +} + // parseDistinctModifier parses the optional DISTINCT [ON (...)] or ALL keyword // immediately after SELECT. func (p *Parser) parseDistinctModifier() (isDistinct bool, distinctOnColumns []ast.Expression, err error) { @@ -334,7 +393,17 @@ func (p *Parser) isNonTopDialect() bool { // plain identifier/keyword literal. A future lexer enhancement could introduce // models.TokenTypeTop and replace the equalsUpper check below. func (p *Parser) parseTopClause() (*ast.TopClause, error) { - if p.dialect != string(keywords.DialectSQLServer) || !equalsUpper(p.currentToken.Token.Value, "TOP") { + if !equalsUpper(p.currentToken.Token.Value, "TOP") { + return nil, nil + } + switch p.dialect { + case string(keywords.DialectSQLServer): + case string(keywords.DialectSnowflake): + // Snowflake TOP ; a column named top is left alone. + if p.peekToken().Token.Type != models.TokenTypeNumber { + return nil, nil + } + default: return nil, nil } p.advance() // Consume TOP @@ -344,7 +413,13 @@ func (p *Parser) parseTopClause() (*ast.TopClause, error) { p.advance() // Consume ( } - countExpr, err := p.parsePrimaryExpression() + var countExpr ast.Expression + var err error + if hasParen { + countExpr, err = p.parseExpression() + } else { + countExpr, err = p.parsePrimaryExpression() + } if err != nil { return nil, goerrors.InvalidSyntaxError( fmt.Sprintf("expected expression after TOP: %v", err), @@ -360,7 +435,7 @@ func (p *Parser) parseTopClause() (*ast.TopClause, error) { p.advance() // Consume ) } - topClause := &ast.TopClause{Count: countExpr} + topClause := &ast.TopClause{Count: countExpr, Parenthesized: hasParen} // Optional PERCENT if p.isType(models.TokenTypePercent) || @@ -401,12 +476,22 @@ func (p *Parser) parseSelectColumnList() ([]ast.Expression, error) { // The tokenizer may produce either depending on context. expr = &ast.Identifier{Name: "*"} p.advance() + var err error + if expr, err = p.parseStarModifiers(expr); err != nil { + return nil, err + } } else { var err error expr, err = p.parseExpression() if err != nil { return nil, err } + // t.* EXCLUDE (...) and other wildcard modifiers + if id, ok := expr.(*ast.Identifier); ok && id.Name == "*" { + if expr, err = p.parseStarModifiers(expr); err != nil { + return nil, err + } + } // Optional alias: AS name or implicit name (non-identifier expressions only) if p.isType(models.TokenTypeAs) { diff --git a/pkg/sql/parser/select_clauses.go b/pkg/sql/parser/select_clauses.go index 288b28c4..a0f12c7e 100644 --- a/pkg/sql/parser/select_clauses.go +++ b/pkg/sql/parser/select_clauses.go @@ -65,7 +65,8 @@ func (p *Parser) parseFromClause() (tableName string, tables []ast.TableReferenc !p.isAnyType(models.TokenTypeUnion, models.TokenTypeExcept, models.TokenTypeIntersect) && !p.isMinusSetOp() && !p.isDMLReturningKeyword() && - !p.isAnyType(models.TokenTypeOrder, models.TokenTypeLimit, models.TokenTypeOffset, models.TokenTypeFetch) { + !p.isAnyType(models.TokenTypeOrder, models.TokenTypeLimit, models.TokenTypeOffset, models.TokenTypeFetch) && + !p.isSelectTailStart() { return "", nil, nil, p.expectedError("FROM, semicolon, or end of statement") } return "", nil, nil, nil @@ -122,7 +123,33 @@ func (p *Parser) parseFromClause() (tableName string, tables []ast.TableReferenc // JOIN clauses joins, err = p.parseJoinClauses(firstRef, joins) - return tableName, tables, joins, err + if err != nil { + return "", nil, nil, err + } + + // "a JOIN b ON ..., c [JOIN d ...]": a comma after an explicit join + // adds a cross-joined table; it is kept as a comma when rendered. + for len(joins) > 0 && p.isType(models.TokenTypeComma) { + joinPos := p.currentLocation() + p.advance() // Consume , + ref, e2 := p.parseFromTableReference() + if e2 != nil { + return "", nil, nil, e2 + } + joins = append(joins, ast.JoinClause{ + Type: "CROSS", + Comma: true, + Left: ast.TableReference{ + Name: "(" + firstRef.Name + "_with_" + strconv.Itoa(len(joins)) + "_joins)", + }, + Right: ref, + Pos: joinPos, + }) + if joins, err = p.parseJoinClauses(firstRef, joins); err != nil { + return "", nil, nil, err + } + } + return tableName, tables, joins, nil } // parseFromItem parses the first item of a FROM clause: either a plain table @@ -299,14 +326,15 @@ func (p *Parser) parseJoinClauses(firstRef ast.TableReference, joins []ast.JoinC for p.isJoinKeyword() { joinPos := p.currentLocation() - joinType, isNatural, err := p.parseJoinType() + var jc ast.JoinClause + joinType, isNatural, err := p.parseJoinType(&jc) if err != nil { return nil, err } - // Expect JOIN keyword (APPLY variants skip it) + // Expect JOIN keyword (APPLY variants and STRAIGHT_JOIN skip it) isApply := joinType == "CROSS APPLY" || joinType == "OUTER APPLY" - if !isApply { + if !isApply && jc.Hint != "STRAIGHT_JOIN" { if !p.isType(models.TokenTypeJoin) { return nil, goerrors.ExpectedTokenError( "JOIN after "+joinType, @@ -323,9 +351,32 @@ func (p *Parser) parseJoinClauses(firstRef ast.TableReference, joins []ast.JoinC return nil, err } - joinCondition, joinUsing, err := p.parseJoinCondition(joinType, isNatural, isApply) - if err != nil { - return nil, err + // Snowflake ASOF JOIN t MATCH_CONDITION (expr) ON ... + if jc.Strictness == "ASOF" && p.dialect == string(keywords.DialectSnowflake) { + if !p.isUnquotedWord("MATCH_CONDITION") { + return nil, p.expectedError("MATCH_CONDITION after ASOF JOIN table") + } + p.advance() // MATCH_CONDITION + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("( after MATCH_CONDITION") + } + p.advance() // ( + if jc.MatchCondition, err = p.parseExpression(); err != nil { + return nil, err + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") after MATCH_CONDITION") + } + p.advance() // ) + } + + var joinCondition ast.Expression + var joinUsing []ast.Expression + // MySQL / MariaDB / SQLite: [INNER] JOIN without ON is a cross join. + if !p.joinConditionOptional(joinType, &jc) || p.isType(models.TokenTypeOn) || p.isType(models.TokenTypeUsing) { + if joinCondition, joinUsing, err = p.parseJoinCondition(joinType, isNatural, isApply); err != nil { + return nil, err + } } // Build left-side reference (synthetic for chained joins) @@ -341,37 +392,99 @@ func (p *Parser) parseJoinClauses(firstRef ast.TableReference, joins []ast.JoinC if cap(joins) == 0 { joins = make([]ast.JoinClause, 0, 2) } - joins = append(joins, ast.JoinClause{ - Type: joinType, - Left: leftTable, - Right: joinedTableRef, - Condition: joinCondition, - Using: joinUsing, - Pos: joinPos, - }) + jc.Type = joinType + jc.Left = leftTable + jc.Right = joinedTableRef + jc.Condition = joinCondition + jc.Using = joinUsing + jc.Pos = joinPos + joins = append(joins, jc) } return joins, nil } +// joinConditionOptional reports whether a join of this type may omit ON / +// USING: MySQL, MariaDB and SQLite treat [INNER] JOIN and STRAIGHT_JOIN +// without a condition as a cross join. +func (p *Parser) joinConditionOptional(joinType string, jc *ast.JoinClause) bool { + if jc.Strictness != "" || joinType != "INNER" { + return false + } + return p.isMySQLFamily() || p.dialect == string(keywords.DialectSQLite) +} + +// isApplyDialect reports whether CROSS APPLY / OUTER APPLY are accepted +// (SQL Server, Oracle 12c+). +func (p *Parser) isApplyDialect() bool { + return p.dialect == string(keywords.DialectSQLServer) || p.dialect == string(keywords.DialectOracle) +} + +// isExtJoinStart reports whether the current token starts a join written +// with a dialect word that isJoinKeyword does not know: ClickHouse ASOF / +// SEMI / ANTI, Snowflake ASOF, MySQL STRAIGHT_JOIN, Oracle OUTER APPLY. +func (p *Parser) isExtJoinStart() bool { + switch p.dialect { + case string(keywords.DialectClickHouse), string(keywords.DialectSnowflake): + return p.isJoinStrictnessStart() + case string(keywords.DialectMySQL), string(keywords.DialectMariaDB): + return p.isUnquotedWord("STRAIGHT_JOIN") + case string(keywords.DialectOracle): + return p.isType(models.TokenTypeOuter) && equalsUpper(p.peekToken().Token.Value, "APPLY") + } + return false +} + +// parseJoinStrictness consumes a ClickHouse join strictness (ANY, ALL, +// ASOF, SEMI, ANTI) or Snowflake ASOF into jc when one is present. +func (p *Parser) parseJoinStrictness(jc *ast.JoinClause) { + if jc.Strictness != "" { + return + } + switch p.dialect { + case string(keywords.DialectClickHouse): + if p.isType(models.TokenTypeAny) || p.isType(models.TokenTypeAll) { + jc.Strictness = strings.ToUpper(p.currentToken.Token.Value) + p.advance() + return + } + if p.currentToken.Token.Type == models.TokenTypeIdentifier && !p.isQuotedIdentifierToken() { + v := p.currentToken.Token.Value + if equalsUpper(v, "ASOF") || equalsUpper(v, "SEMI") || equalsUpper(v, "ANTI") { + jc.Strictness = strings.ToUpper(v) + p.advance() + } + } + case string(keywords.DialectSnowflake): + if p.isJoinStrictnessStart() { + jc.Strictness = "ASOF" + p.advance() + } + } +} + // parseJoinType parses the optional NATURAL keyword and the join-type keywords // (LEFT, RIGHT, FULL, INNER, CROSS, OUTER APPLY, …) that precede the JOIN keyword. // Returns (joinType string, isNatural bool, err). -func (p *Parser) parseJoinType() (string, bool, error) { +func (p *Parser) parseJoinType(jc *ast.JoinClause) (string, bool, error) { joinType := "INNER" isNatural := false explicitType := false // tracks whether a join-type keyword was explicitly given - // ClickHouse GLOBAL JOIN — consume the GLOBAL modifier before the join type. - // GLOBAL semantics (distributed join) are not preserved in the AST. + // ClickHouse GLOBAL JOIN (distributed join). if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("GLOBAL") { - p.advance() // consume GLOBAL; fall through to standard join parsing + jc.Global = true + p.advance() } - // ClickHouse ANY/ALL join strictness prefix — e.g. ANY LEFT JOIN, ALL INNER JOIN. - // The strictness modifier is consumed but not modeled on the AST. - if p.dialect == string(keywords.DialectClickHouse) && - (p.isType(models.TokenTypeAny) || p.isType(models.TokenTypeAll)) { + // ClickHouse ANY / ALL / ASOF / SEMI / ANTI and Snowflake ASOF written + // before the join kind: ANY LEFT JOIN, ASOF JOIN. + p.parseJoinStrictness(jc) + + // MySQL / MariaDB STRAIGHT_JOIN: an inner join that reads the left table first. + if p.isMySQLFamily() && p.isUnquotedWord("STRAIGHT_JOIN") { + jc.Hint = "STRAIGHT_JOIN" p.advance() + return joinType, false, nil } if p.isType(models.TokenTypeNatural) { @@ -409,13 +522,13 @@ func (p *Parser) parseJoinType() (string, bool, error) { joinType = "CROSS" explicitType = true p.advance() - if p.dialect == string(keywords.DialectSQLServer) && + if p.isApplyDialect() && p.currentToken.Token.Type == models.TokenTypeIdentifier && strings.ToUpper(p.currentToken.Token.Value) == "APPLY" { joinType = "CROSS APPLY" p.advance() } - case p.isType(models.TokenTypeOuter) && p.dialect == string(keywords.DialectSQLServer): + case p.isType(models.TokenTypeOuter) && p.isApplyDialect(): p.advance() if p.currentToken.Token.Type == models.TokenTypeIdentifier && strings.ToUpper(p.currentToken.Token.Value) == "APPLY" { @@ -426,6 +539,21 @@ func (p *Parser) parseJoinType() (string, bool, error) { } } + // ClickHouse strictness after the kind: LEFT SEMI JOIN, LEFT ANY JOIN. + if explicitType { + p.parseJoinStrictness(jc) + } + + // SQL Server join hints after an explicit kind: INNER HASH JOIN, + // LEFT OUTER LOOP JOIN, FULL MERGE JOIN, INNER REMOTE JOIN. + if explicitType && p.dialect == string(keywords.DialectSQLServer) && + p.peekToken().Token.Type == models.TokenTypeJoin { + if h := ast.JoinHintText(p.currentToken.Token.Value); h != "" { + jc.Hint = strings.ToUpper(h) + p.advance() + } + } + if isNatural { // NATURAL JOIN without an explicit type keyword → just "NATURAL" (SQL standard). // NATURAL with an explicit keyword → "NATURAL LEFT", "NATURAL INNER", etc. @@ -629,9 +757,10 @@ func (p *Parser) parseWhereClause() (ast.Expression, error) { } // parseGroupByClause parses "GROUP BY [, ...]" including ROLLUP, CUBE, -// GROUPING SETS, and MySQL's trailing WITH ROLLUP / WITH CUBE syntax. +// GROUPING SETS, GROUP BY ALL, and the trailing WITH ROLLUP / WITH CUBE / +// WITH TOTALS modifiers, which are stored on stmt.GroupByModifiers. // Returns nil slice (no error) when GROUP BY is absent. -func (p *Parser) parseGroupByClause() ([]ast.Expression, error) { +func (p *Parser) parseGroupByClause(stmt *ast.SelectStatement) ([]ast.Expression, error) { if !p.isType(models.TokenTypeGroup) { return nil, nil } @@ -642,6 +771,15 @@ func (p *Parser) parseGroupByClause() ([]ast.Expression, error) { } p.advance() // Consume BY + // Snowflake / ClickHouse / BigQuery GROUP BY ALL: group by every + // non-aggregate select item. + if p.isType(models.TokenTypeAll) && p.supportsGroupByAll() { + p.advance() // Consume ALL + stmt.GroupByModifiers.All = true + p.parseGroupByWithModifiers(stmt) + return nil, nil + } + groupByExprs := make([]ast.Expression, 0, 4) for { var ( @@ -672,28 +810,43 @@ func (p *Parser) parseGroupByClause() ([]ast.Expression, error) { p.advance() } - // MySQL / ClickHouse: GROUP BY col1 WITH ROLLUP / WITH CUBE / WITH TOTALS - if p.isType(models.TokenTypeWith) { - switch strings.ToUpper(p.peekToken().Token.Value) { - case "ROLLUP": - p.advance() // Consume WITH - p.advance() // Consume ROLLUP - groupByExprs = []ast.Expression{&ast.RollupExpression{Expressions: groupByExprs}} - case "CUBE": - p.advance() // Consume WITH - p.advance() // Consume CUBE - groupByExprs = []ast.Expression{&ast.CubeExpression{Expressions: groupByExprs}} - case "TOTALS": - // ClickHouse WITH TOTALS: adds a summary row with aggregate totals. - // Consumed but not modeled on the AST (follow-up). - p.advance() // Consume WITH - p.advance() // Consume TOTALS - } - } + // MySQL / SQL Server / ClickHouse: GROUP BY col1 WITH ROLLUP / WITH CUBE / WITH TOTALS + p.parseGroupByWithModifiers(stmt) return groupByExprs, nil } +// supportsGroupByAll reports whether GROUP BY ALL is accepted. +func (p *Parser) supportsGroupByAll() bool { + switch p.dialect { + case string(keywords.DialectSnowflake), string(keywords.DialectClickHouse), + string(keywords.DialectBigQuery): + return true + } + return false +} + +// parseGroupByWithModifiers parses the trailing GROUP BY modifiers +// WITH ROLLUP, WITH CUBE and (ClickHouse) WITH TOTALS. They are kept as +// written: MySQL and MariaDB reject the equivalent ROLLUP(...) form. +func (p *Parser) parseGroupByWithModifiers(stmt *ast.SelectStatement) { + for p.isType(models.TokenTypeWith) { + next := p.peekToken().Token.Value + switch { + case equalsUpper(next, "ROLLUP") && !stmt.GroupByModifiers.WithCube: + stmt.GroupByModifiers.WithRollup = true + case equalsUpper(next, "CUBE") && !stmt.GroupByModifiers.WithRollup: + stmt.GroupByModifiers.WithCube = true + case equalsUpper(next, "TOTALS"): + stmt.GroupByModifiers.WithTotals = true + default: + return + } + p.advance() // Consume WITH + p.advance() // Consume ROLLUP / CUBE / TOTALS + } +} + // parseHavingClause parses "HAVING " if present. // Returns nil (no error) when HAVING is absent. func (p *Parser) parseHavingClause() (ast.Expression, error) { @@ -711,7 +864,12 @@ func (p *Parser) parseOrderByClause() ([]ast.OrderByExpression, error) { return nil, nil } p.advance() // Consume ORDER + return p.parseOrderByList() +} +// parseOrderByList parses "BY [ASC|DESC] ... [, ...]"; the current +// token is BY. +func (p *Parser) parseOrderByList() ([]ast.OrderByExpression, error) { if !p.isType(models.TokenTypeBy) { return nil, p.expectedError("BY") } @@ -736,6 +894,16 @@ func (p *Parser) parseOrderByClause() ([]ast.OrderByExpression, error) { } else if p.isType(models.TokenTypeDesc) { entry.Ascending = false p.advance() + } else if p.isType(models.TokenTypeUsing) && p.dialect == string(keywords.DialectPostgreSQL) { + // PostgreSQL ORDER BY expr USING + op := ast.OrderUsingOperatorText(p.peekToken().Token.Value) + if op == "" { + p.advance() // USING + return nil, p.expectedError("sort operator after USING") + } + p.advance() // USING + p.advance() // operator + entry.UsingOperator = op } nullsFirst, err := p.parseNullsClause() @@ -803,64 +971,6 @@ func (p *Parser) parseClickHouseWithFill() (*ast.WithFillClause, error) { } } -// parseLimitOffsetClause parses optional LIMIT and/or OFFSET clauses. -// Supports standard "LIMIT n OFFSET m", MySQL "LIMIT offset, count", and -// SQL-99 "OFFSET n ROWS" (ROW/ROWS consumed but value stored). -// Returns (limit, offset, error); either or both pointers may be nil. -func (p *Parser) parseLimitOffsetClause() (limit *int, offset *int, err error) { - // LIMIT clause - if p.isType(models.TokenTypeLimit) { - // Reject LIMIT in SQL Server and Oracle - these dialects use TOP/OFFSET-FETCH or ROWNUM/FETCH FIRST. - if p.dialect == string(keywords.DialectSQLServer) || p.dialect == string(keywords.DialectOracle) { - msg := "LIMIT clause is not supported in SQL Server; use TOP or OFFSET/FETCH NEXT instead" - if p.dialect == string(keywords.DialectOracle) { - msg = "LIMIT clause is not supported in Oracle; use ROWNUM or FETCH FIRST … ROWS ONLY instead" - } - return nil, nil, goerrors.UnsupportedFeatureError(msg, p.currentLocation(), "") - } - p.advance() // Consume LIMIT - - if !p.isNumericLiteral() { - return nil, nil, p.expectedError("integer for LIMIT") - } - firstVal := int(parseLeadingInt(p.currentToken.Token.Value, strconv.IntSize)) - p.advance() - - // MySQL / ClickHouse: LIMIT offset, count - if (p.dialect == "mysql" || p.dialect == string(keywords.DialectMariaDB) || p.dialect == string(keywords.DialectClickHouse)) && p.isType(models.TokenTypeComma) { - p.advance() - if !p.isNumericLiteral() { - return nil, nil, p.expectedError("integer for LIMIT count") - } - secondVal := int(parseLeadingInt(p.currentToken.Token.Value, strconv.IntSize)) - p.advance() - offset = &firstVal - limit = &secondVal - } else { - limit = &firstVal - } - } - - // OFFSET clause - if p.isType(models.TokenTypeOffset) { - p.advance() - - if !p.isNumericLiteral() { - return nil, nil, p.expectedError("integer for OFFSET") - } - offsetVal := int(parseLeadingInt(p.currentToken.Token.Value, strconv.IntSize)) - offset = &offsetVal - p.advance() - - // SQL-99: OFFSET n ROWS - if p.isAnyType(models.TokenTypeRow, models.TokenTypeRows) { - p.advance() - } - } - - return limit, offset, nil -} - // parseFetchClause parses the SQL-99 FETCH FIRST/NEXT clause (F861, F862). // Syntax: FETCH {FIRST | NEXT} n [{ROW | ROWS}] [{PERCENT}] {ONLY | WITH TIES} // @@ -887,16 +997,20 @@ func (p *Parser) parseFetchClause() (*ast.FetchClause, error) { return nil, p.expectedError("FIRST or NEXT after FETCH") } - // Parse the count value - if !p.isNumericLiteral() { - return nil, p.expectedError("integer for FETCH count") + // Parse the count value: optional (FETCH FIRST ROW ONLY), a plain + // integer, or an expression (FETCH NEXT @n ROWS ONLY). + if !p.isAnyType(models.TokenTypeRow, models.TokenTypeRows) { + n, e, err := p.parseRowCountValue() + if err != nil { + return nil, err + } + if n != nil { + fetchVal := int64(*n) + fetchClause.FetchValue = &fetchVal + } + fetchClause.FetchExpr = e } - // Convert string to int64 - fetchVal := parseLeadingInt(p.currentToken.Token.Value, 64) - fetchClause.FetchValue = &fetchVal - p.advance() - // Check for PERCENT (optional) if p.isType(models.TokenTypePercent) { fetchClause.IsPercent = true @@ -997,10 +1111,11 @@ func (p *Parser) parseForClause() (*ast.ForClause, error) { } } - // Parse NOWAIT or SKIP LOCKED - if p.isTokenMatch("NOWAIT") { - forClause.NoWait = true - p.advance() + // Parse NOWAIT, WAIT n (Oracle / MariaDB) or SKIP LOCKED + if p.isTokenMatch("NOWAIT") || p.isUnquotedWord("WAIT") { + if err := p.parseLockWaitOption(forClause); err != nil { + return nil, err + } } else if p.isTokenMatch("SKIP") { p.advance() // Consume SKIP if !p.isTokenMatch("LOCKED") { @@ -1071,29 +1186,3 @@ func (p *Parser) parseArrayJoinClause() (*ast.ArrayJoinClause, error) { } return clause, nil } - -// parseLeadingInt parses an optional sign followed by leading ASCII digits of -// s and ignores any trailing characters. It reproduces fmt.Sscanf(s, "%d", &v) -// for tokenizer Number values without the reflection and allocation cost: -// "10.5" yields 10, "1e2" yields 1, and empty, non-numeric or out-of-range -// input yields 0. Unlike Sscanf it does not skip leading whitespace, which a -// Number token never carries. bitSize is the target integer width (0, 8, 16, -// 32 or 64 as accepted by strconv.ParseInt). -func parseLeadingInt(s string, bitSize int) int64 { - i := 0 - if i < len(s) && (s[i] == '+' || s[i] == '-') { - i++ - } - start := i - for i < len(s) && s[i] >= '0' && s[i] <= '9' { - i++ - } - if i == start { - return 0 - } - v, err := strconv.ParseInt(s[:i], 10, bitSize) - if err != nil { - return 0 - } - return v -} diff --git a/pkg/sql/parser/select_ext.go b/pkg/sql/parser/select_ext.go new file mode 100644 index 00000000..121efe85 --- /dev/null +++ b/pkg/sql/parser/select_ext.go @@ -0,0 +1,976 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package parser - select_ext.go +// Dialect-specific SELECT-body clauses: MySQL select modifiers, star +// modifiers (EXCLUDE / EXCEPT / REPLACE / RENAME / ILIKE), SELECT ... INTO, +// LIMIT/OFFSET values as expressions, ClickHouse LIMIT BY / FORMAT, SQL Server +// FOR JSON / FOR XML and OPTION (...), MySQL LOCK IN SHARE MODE. + +package parser + +import ( + "strconv" + "strings" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// isMySQLFamily reports whether the dialect is MySQL or MariaDB. +func (p *Parser) isMySQLFamily() bool { + return p.dialect == string(keywords.DialectMySQL) || p.dialect == string(keywords.DialectMariaDB) +} + +// isUnquotedWord reports whether the current token is an unquoted +// identifier or keyword whose text equals upper (case-insensitively). +func (p *Parser) isUnquotedWord(upper string) bool { + t := p.currentToken.Token + switch t.Type { + case models.TokenTypeDoubleQuotedString, models.TokenTypeString, + models.TokenTypeSingleQuotedString, models.TokenTypeDollarQuotedString: + return false + } + return equalsUpper(t.Value, upper) && !p.isQuotedIdentifierToken() +} + +// isSelectExtClauseStart reports whether the current identifier starts one +// of the clauses parsed in this file (or a join written with a dialect word) +// rather than naming an alias: ClickHouse FORMAT / GLOBAL / ASOF / SEMI / +// ANTI, Snowflake ASOF / MATCH_CONDITION, MySQL STRAIGHT_JOIN / LOCK IN +// SHARE MODE, SQL Server OPTION (...). +func (p *Parser) isSelectExtClauseStart() bool { + if p.currentToken.Token.Type != models.TokenTypeIdentifier { + return false + } + switch p.dialect { + case string(keywords.DialectClickHouse): + return p.isFormatClauseStart() || p.isJoinStrictnessStart() || + (p.isUnquotedWord("GLOBAL") && p.peekStartsJoin()) + case string(keywords.DialectSnowflake): + return p.isJoinStrictnessStart() || + (p.isUnquotedWord("MATCH_CONDITION") && p.peekToken().Token.Type == models.TokenTypeLParen) + case string(keywords.DialectMySQL), string(keywords.DialectMariaDB): + return p.isUnquotedWord("STRAIGHT_JOIN") || p.isLockInShareMode() + case string(keywords.DialectSQLServer): + return p.isQueryHintStart() + } + return false +} + +// peekStartsJoin reports whether the token after the current one starts a +// join: JOIN, a join kind (INNER, LEFT, ...), ANY / ALL, or a ClickHouse / +// Snowflake strictness word followed by more join keywords. +func (p *Parser) peekStartsJoin() bool { + next := p.peekToken().Token + switch next.Type { + case models.TokenTypeJoin, models.TokenTypeInner, models.TokenTypeLeft, + models.TokenTypeRight, models.TokenTypeFull, models.TokenTypeCross, + models.TokenTypeAny, models.TokenTypeAll, models.TokenTypeOuter: + return true + case models.TokenTypeIdentifier: + v := next.Value + return equalsUpper(v, "ASOF") || equalsUpper(v, "SEMI") || equalsUpper(v, "ANTI") || + equalsUpper(v, "PASTE") + } + return false +} + +// isJoinStrictnessStart reports whether the current token is a join +// strictness word written before the join kind: ClickHouse ASOF / SEMI / +// ANTI (ANY and ALL are keywords) and Snowflake ASOF, followed by JOIN or a +// join kind. +func (p *Parser) isJoinStrictnessStart() bool { + if p.currentToken.Token.Type != models.TokenTypeIdentifier || p.isQuotedIdentifierToken() { + return false + } + v := p.currentToken.Token.Value + switch p.dialect { + case string(keywords.DialectClickHouse): + if !equalsUpper(v, "ASOF") && !equalsUpper(v, "SEMI") && !equalsUpper(v, "ANTI") { + return false + } + return p.peekStartsJoin() + case string(keywords.DialectSnowflake): + return equalsUpper(v, "ASOF") && p.peekToken().Token.Type == models.TokenTypeJoin + } + return false +} + +// parseSelectModifiers parses MySQL / MariaDB select modifiers after SELECT +// [DISTINCT]: HIGH_PRIORITY, STRAIGHT_JOIN, SQL_SMALL_RESULT, +// SQL_BIG_RESULT, SQL_BUFFER_RESULT, SQL_NO_CACHE, SQL_CACHE, +// SQL_CALC_FOUND_ROWS. A word is a modifier only when a select-list item +// follows it, so a column with such a name still parses. +func (p *Parser) parseSelectModifiers() []string { + if !p.isMySQLFamily() { + return nil + } + var mods []string + for p.currentToken.Token.Type == models.TokenTypeIdentifier && !p.isQuotedIdentifierToken() { + m := ast.SelectModifierText(p.currentToken.Token.Value) + if m == "" { + break + } + switch p.peekToken().Token.Type { + case models.TokenTypeComma, models.TokenTypeFrom, models.TokenTypeEOF, + models.TokenTypeSemicolon, models.TokenTypePeriod, models.TokenTypeAs, + models.TokenTypeLParen, models.TokenTypeRParen: + return mods + } + mods = append(mods, strings.ToUpper(m)) + p.advance() + } + return mods +} + +// supportsStarModifiers reports whether the dialect accepts modifiers after +// a select-list wildcard. +func (p *Parser) supportsStarModifiers() bool { + switch p.dialect { + case string(keywords.DialectSnowflake), string(keywords.DialectClickHouse), + string(keywords.DialectBigQuery): + return true + } + return false +} + +// isStarModifierStart reports whether the current token starts a wildcard +// modifier. ClickHouse / BigQuery "* EXCEPT (" is a modifier, not the set +// operator, because a set operator never directly follows a wildcard. +func (p *Parser) isStarModifierStart() bool { + if !p.supportsStarModifiers() { + return false + } + next := p.peekToken().Token.Type + switch { + case p.isType(models.TokenTypeExclude) || p.isUnquotedWord("EXCLUDE"): + return p.dialect == string(keywords.DialectSnowflake) && + (next == models.TokenTypeLParen || next == models.TokenTypeIdentifier) + case p.isType(models.TokenTypeExcept): + return p.dialect != string(keywords.DialectSnowflake) && + (next == models.TokenTypeLParen || next == models.TokenTypeIdentifier) + case p.isType(models.TokenTypeReplace) || p.isUnquotedWord("REPLACE"): + return next == models.TokenTypeLParen + case p.isUnquotedWord("RENAME"): + return p.dialect == string(keywords.DialectSnowflake) && + (next == models.TokenTypeLParen || next == models.TokenTypeIdentifier) + case p.isUnquotedWord("ILIKE"): + return p.dialect == string(keywords.DialectSnowflake) + } + return false +} + +// parseStarModifiers parses the modifiers after a select-list wildcard and +// returns the wildcard unchanged when there are none. +func (p *Parser) parseStarModifiers(star ast.Expression) (ast.Expression, error) { + if !p.isStarModifierStart() { + return star, nil + } + se := &ast.StarExpression{Star: star} + for p.isStarModifierStart() { + switch { + case p.isUnquotedWord("ILIKE"): + p.advance() // ILIKE + if !p.isStringLiteral() { + return nil, p.expectedError("pattern string after ILIKE") + } + pat, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + se.Ilike = pat + case p.isType(models.TokenTypeExclude) || p.isUnquotedWord("EXCLUDE") || p.isType(models.TokenTypeExcept): + se.ExcludeKeyword = strings.ToUpper(p.currentToken.Token.Value) + p.advance() // EXCLUDE / EXCEPT + cols, err := p.parseStarColumnList() + if err != nil { + return nil, err + } + se.Exclude = append(se.Exclude, cols...) + case p.isType(models.TokenTypeReplace) || p.isUnquotedWord("REPLACE"): + p.advance() // REPLACE + p.advance() // ( + for { + e, err := p.parseExpression() + if err != nil { + return nil, err + } + if !p.isType(models.TokenTypeAs) { + return nil, p.expectedError("AS in REPLACE item") + } + p.advance() // AS + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("column name after AS") + } + se.Replace = append(se.Replace, &ast.AliasedExpression{Expr: e, Alias: p.currentToken.Token.Value}) + p.advance() + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // , + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") after REPLACE list") + } + p.advance() // ) + default: // RENAME + p.advance() // RENAME + paren := p.isType(models.TokenTypeLParen) + if paren { + p.advance() + } + for { + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("column name in RENAME") + } + col := p.currentToken.Token.Value + p.advance() + if !p.isType(models.TokenTypeAs) { + return nil, p.expectedError("AS in RENAME item") + } + p.advance() + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("new column name in RENAME") + } + se.Rename = append(se.Rename, ast.StarRename{Column: col, Alias: p.currentToken.Token.Value}) + p.advance() + if !paren || !p.isType(models.TokenTypeComma) { + break + } + p.advance() + } + if paren { + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") after RENAME list") + } + p.advance() + } + } + } + return se, nil +} + +// parseStarColumnList parses "col" or "(col, ...)" after EXCLUDE / EXCEPT. +func (p *Parser) parseStarColumnList() ([]string, error) { + if !p.isType(models.TokenTypeLParen) { + if !p.isIdentifier() { + return nil, p.expectedError("column name") + } + name := p.currentToken.Token.Value + p.advance() + return []string{name}, nil + } + p.advance() // ( + var cols []string + for { + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("column name") + } + name := p.currentToken.Token.Value + p.advance() + // Qualified names (t.col) are kept whole. + for p.isType(models.TokenTypePeriod) { + p.advance() + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("column name after .") + } + name += "." + p.currentToken.Token.Value + p.advance() + } + cols = append(cols, name) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() + return cols, nil +} + +// supportsSelectInto reports whether SELECT ... INTO is accepted. +func (p *Parser) supportsSelectInto() bool { + switch p.dialect { + case string(keywords.DialectSQLite), string(keywords.DialectClickHouse): + return false + } + return true +} + +// parseSelectInto parses an INTO clause; the current token is INTO. +// trailing records that it follows the FROM ... clauses (MySQL). +func (p *Parser) parseSelectInto(trailing bool) (*ast.SelectInto, error) { + p.advance() // INTO + in := &ast.SelectInto{Trailing: trailing} + switch { + case p.isUnquotedWord("OUTFILE") || p.isUnquotedWord("DUMPFILE"): + if !p.isMySQLFamily() { + return nil, p.expectedError("INTO target") + } + in.Kind = strings.ToUpper(p.currentToken.Token.Value) + p.advance() + if !p.isStringLiteral() { + return nil, p.expectedError("file name string after INTO " + in.Kind) + } + file, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + in.File = file + if in.Kind == ast.SelectIntoOutfile { + if err := p.parseOutfileOptions(in); err != nil { + return nil, err + } + } + return in, nil + case p.isMySQLFamily() || p.dialect == string(keywords.DialectOracle) || + p.isType(models.TokenTypePlaceholder): + // Variables: @a, @b (MySQL), local variables, :bind (Oracle PL/SQL). + in.Kind = ast.SelectIntoVariables + for { + e, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + in.Targets = append(in.Targets, e) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() + } + return in, nil + } + + // New table (SQL Server, PostgreSQL): [TEMP|TEMPORARY|UNLOGGED] [TABLE] name + in.Kind = ast.SelectIntoTable + if p.dialect != string(keywords.DialectSQLServer) { + if t := ast.SelectIntoTemporaryText(p.currentToken.Token.Value); t != "" && !p.isQuotedIdentifierToken() && + p.peekToken().Token.Type != models.TokenTypeFrom && p.peekToken().Token.Type != models.TokenTypeEOF { + in.Temporary = strings.ToUpper(t) + p.advance() + } + if p.isType(models.TokenTypeTable) { + in.TableKeyword = true + p.advance() + } + } + name, err := p.parseQualifiedName() + if err != nil { + return nil, p.qualifiedNameError(err, "table name after INTO") + } + in.Table = name + return in, nil +} + +// parseOutfileOptions parses the MySQL INTO OUTFILE options: +// +// [CHARACTER SET cs] [{FIELDS|COLUMNS} [TERMINATED BY s] [[OPTIONALLY] +// ENCLOSED BY c] [ESCAPED BY c]] [LINES [STARTING BY s] [TERMINATED BY s]] +func (p *Parser) parseOutfileOptions(in *ast.SelectInto) error { + if p.isUnquotedWord("CHARACTER") && strings.EqualFold(p.peekToken().Token.Value, "SET") { + p.advance() // CHARACTER + p.advance() // SET + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return p.expectedError("character set name") + } + in.CharacterSet = p.currentToken.Token.Value + p.advance() + } + if p.isUnquotedWord("FIELDS") || p.isUnquotedWord("COLUMNS") { + p.advance() + opts, err := p.parseIntoFileOptionList() + if err != nil { + return err + } + in.FieldsOptions = opts + } + if p.isUnquotedWord("LINES") { + p.advance() + opts, err := p.parseIntoFileOptionList() + if err != nil { + return err + } + in.LinesOptions = opts + } + return nil +} + +// parseIntoFileOptionList parses "
before the column list / ENGINE / AS + if clickhouse && p.isType(models.TokenTypeTo) { + p.advance() // Consume TO + toName, toErr := p.parseQualifiedName() + if toErr != nil { + return nil, p.qualifiedNameError(toErr, "target table after TO") + } + stmt.To = toName + } + + // Parse optional column list: names, or ClickHouse typed columns if p.isType(models.TokenTypeLParen) { - p.advance() // Consume ( - for { - if !p.isIdentifier() { - return nil, p.expectedError("column name") + if clickhouse && p.wordAt(1) != "" && p.tokenAt(2).Token.Type != models.TokenTypeComma && + p.tokenAt(2).Token.Type != models.TokenTypeRParen { + p.advance() // Consume ( + for { + col, err := p.parseColumnDef() + if err != nil { + return nil, err + } + stmt.ColumnDefs = append(stmt.ColumnDefs, *col) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() } - stmt.Columns = append(stmt.Columns, p.currentToken.Token.Value) - p.advance() + } else { + p.advance() // Consume ( + for { + if !p.isIdentifier() { + return nil, p.expectedError("column name") + } + stmt.Columns = append(stmt.Columns, p.currentToken.Token.Value) + p.advance() - if p.isType(models.TokenTypeComma) { - p.advance() // Consume comma - continue + if p.isType(models.TokenTypeComma) { + p.advance() // Consume comma + continue + } + break } - break } if !p.isType(models.TokenTypeRParen) { return nil, p.expectedError(")") @@ -188,24 +229,36 @@ func (p *Parser) parseCreateMaterializedView() (*ast.CreateMaterializedViewState p.advance() } - // ClickHouse: optional TO
before ENGINE/AS - if p.dialect == string(keywords.DialectClickHouse) && p.isType(models.TokenTypeTo) { - p.advance() // Consume TO - toName, toErr := p.parseQualifiedName() - if toErr != nil { - return nil, p.qualifiedNameError(toErr, "target table after TO") + // ClickHouse: ENGINE = ... with its trailing clauses, and POPULATE + if clickhouse { + if stmt.EngineOptions, err = p.parseTableOptions(mvEngineOptionSpecs, stmt.EngineOptions); err != nil { + return nil, err + } + if p.isEngineClauseStart() { + stmt.Engine = &ast.TableEngineClauses{} + if err := p.parseEngineClauses(stmt.Engine); err != nil { + return nil, err + } + } + if p.wordAt(0) == "POPULATE" { + p.advance() + stmt.Populate = true } - stmt.Tablespace = toName // reuse Tablespace for ClickHouse TO } - // ClickHouse: optional ENGINE = ... ORDER BY ... before AS SELECT - if p.dialect == string(keywords.DialectClickHouse) { - for p.isTokenMatch("ENGINE") || p.isType(models.TokenTypeOrder) || p.isTokenMatch("PRIMARY") || p.isTokenMatch("PARTITION") || p.isTokenMatch("SETTINGS") { - // Consume all engine clauses token-by-token until AS - for !p.isType(models.TokenTypeAs) && !p.isType(models.TokenTypeEOF) && !p.isType(models.TokenTypeSemicolon) { - p.advance() + // Oracle: BUILD IMMEDIATE | DEFERRED, REFRESH ..., ENABLE QUERY REWRITE + if !clickhouse && (p.wordAt(0) == "BUILD" || p.wordAt(0) == "REFRESH" || + p.wordAt(0) == "NEVER" || p.wordAt(0) == "ENABLE" || p.wordAt(0) == "DISABLE") { + var words []string + for !p.isType(models.TokenTypeAs) { + w := p.wordAt(0) + if w == "" { + return nil, p.expectedError("AS") } + words = append(words, w) + p.advance() } + stmt.Refresh = strings.Join(words, " ") } // Expect AS @@ -214,13 +267,11 @@ func (p *Parser) parseCreateMaterializedView() (*ast.CreateMaterializedViewState } p.advance() // Consume AS - // Parse the SELECT statement - if !p.isType(models.TokenTypeSelect) { + // Parse the query (SELECT, WITH ... SELECT or a parenthesized query) + if !p.isQueryStart() { return nil, p.expectedError("SELECT") } - p.advance() // Consume SELECT - - query, err := p.parseSelectWithSetOperations() + query, err := p.parseQueryExpression("SELECT") if err != nil { return nil, goerrors.WrapError( goerrors.ErrCodeInvalidSyntax, @@ -254,6 +305,12 @@ func (p *Parser) parseCreateMaterializedView() (*ast.CreateMaterializedViewState return stmt, nil } +// mvEngineOptionSpecs are the storage options of a ClickHouse +// materialized view. +var mvEngineOptionSpecs = []optionSpec{ + {[]string{"ENGINE"}, optEqValue}, +} + // parseRefreshStatement parses REFRESH MATERIALIZED VIEW statement func (p *Parser) parseRefreshStatement() (*ast.RefreshMaterializedViewStatement, error) { // Expect MATERIALIZED diff --git a/pkg/sql/parser/qualified_name_issue613_test.go b/pkg/sql/parser/qualified_name_issue613_test.go index 1ae6d5cf..32e02454 100644 --- a/pkg/sql/parser/qualified_name_issue613_test.go +++ b/pkg/sql/parser/qualified_name_issue613_test.go @@ -169,18 +169,19 @@ func TestIssue613_PartLimitErrorDialectPaths(t *testing.T) { } // TestIssue613_SnowflakeCreateStubKeepsName guards the success path of the -// Snowflake CREATE stub: an in-limit name is kept whole in RawSQL. +// Snowflake CREATE statements, kept verbatim: an in-limit name is kept +// whole in the statement text. func TestIssue613_SnowflakeCreateStubKeepsName(t *testing.T) { tree, err := gosqlx.ParseWithDialect("CREATE STAGE a.b.c URL='s3://bucket/x'", keywords.DialectSnowflake) if err != nil { t.Fatalf("ParseWithDialect failed: %v", err) } - stub, ok := tree.Statements[0].(*ast.UnsupportedStatement) + stmt, ok := tree.Statements[0].(*ast.UtilityStatement) if !ok { - t.Fatalf("expected UnsupportedStatement, got %T", tree.Statements[0]) + t.Fatalf("expected UtilityStatement, got %T", tree.Statements[0]) } - if !strings.HasPrefix(stub.RawSQL, "CREATE STAGE a.b.c ") { - t.Fatalf("RawSQL = %q, want prefix %q", stub.RawSQL, "CREATE STAGE a.b.c ") + if stmt.Kind != "CREATE STAGE" || !strings.HasPrefix(stmt.Text, "CREATE STAGE a.b.c ") { + t.Fatalf("Kind = %q, Text = %q, want CREATE STAGE with prefix %q", stmt.Kind, stmt.Text, "CREATE STAGE a.b.c ") } } diff --git a/pkg/sql/parser/select_clauses.go b/pkg/sql/parser/select_clauses.go index a888b703..d866b830 100644 --- a/pkg/sql/parser/select_clauses.go +++ b/pkg/sql/parser/select_clauses.go @@ -66,7 +66,10 @@ func (p *Parser) parseFromClause() (tableName string, tables []ast.TableReferenc !p.isMinusSetOp() && !p.isDMLReturningKeyword() && !p.isAnyType(models.TokenTypeOrder, models.TokenTypeLimit, models.TokenTypeOffset, models.TokenTypeFetch) && - !p.isSelectTailStart() { + !p.isSelectTailStart() && + // A DDL tail after a FROM-less query: CREATE ... AS SELECT 1 + // WITH [NO] DATA | WITH CHECK OPTION | WITH READ ONLY. + !p.isType(models.TokenTypeWith) { return "", nil, nil, p.expectedError("FROM, semicolon, or end of statement") } return "", nil, nil, nil diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index 6340de90..2d436a97 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -6,24 +6,11 @@ clickhouse/01_app_crud.sql#10 parse clickhouse/01_app_crud.sql#11 parse clickhouse/01_app_crud.sql#12 parse clickhouse/01_app_crud.sql#13 parse -clickhouse/03_ddl_migrations.sql#0 drop -clickhouse/03_ddl_migrations.sql#1 drop -clickhouse/03_ddl_migrations.sql#10 drop -clickhouse/03_ddl_migrations.sql#11 drop clickhouse/03_ddl_migrations.sql#12 parse clickhouse/03_ddl_migrations.sql#13 parse -clickhouse/03_ddl_migrations.sql#14 drop -clickhouse/03_ddl_migrations.sql#17 drop -clickhouse/03_ddl_migrations.sql#18 reparse -clickhouse/03_ddl_migrations.sql#19 parse -clickhouse/03_ddl_migrations.sql#2 drop -clickhouse/03_ddl_migrations.sql#20 parse -clickhouse/03_ddl_migrations.sql#21 parse -clickhouse/03_ddl_migrations.sql#23 parse clickhouse/03_ddl_migrations.sql#26 parse clickhouse/03_ddl_migrations.sql#27 parse clickhouse/03_ddl_migrations.sql#28 parse -clickhouse/03_ddl_migrations.sql#3 drop clickhouse/03_ddl_migrations.sql#30 parse clickhouse/03_ddl_migrations.sql#31 parse clickhouse/03_ddl_migrations.sql#32 parse @@ -34,16 +21,9 @@ clickhouse/03_ddl_migrations.sql#36 parse clickhouse/03_ddl_migrations.sql#37 parse clickhouse/03_ddl_migrations.sql#38 parse clickhouse/03_ddl_migrations.sql#39 parse -clickhouse/03_ddl_migrations.sql#4 drop clickhouse/03_ddl_migrations.sql#40 parse clickhouse/03_ddl_migrations.sql#41 parse clickhouse/03_ddl_migrations.sql#42 parse -clickhouse/03_ddl_migrations.sql#43 parse -clickhouse/03_ddl_migrations.sql#5 drop -clickhouse/03_ddl_migrations.sql#6 drop -clickhouse/03_ddl_migrations.sql#7 drop -clickhouse/03_ddl_migrations.sql#8 drop -clickhouse/03_ddl_migrations.sql#9 reparse clickhouse/04_window_analytics.sql#10 reparse clickhouse/04_window_analytics.sql#28 parse clickhouse/04_window_analytics.sql#9 reparse @@ -51,27 +31,6 @@ clickhouse/05_dialect_specific.sql#0 drop clickhouse/05_dialect_specific.sql#1 drop clickhouse/05_dialect_specific.sql#6 drop clickhouse/05_dialect_specific.sql#66 parse -clickhouse/06_materialized_views.sql#0 drop -clickhouse/06_materialized_views.sql#1 drop -clickhouse/06_materialized_views.sql#10 drop -clickhouse/06_materialized_views.sql#14 parse -clickhouse/06_materialized_views.sql#16 parse -clickhouse/06_materialized_views.sql#17 parse -clickhouse/06_materialized_views.sql#18 parse -clickhouse/06_materialized_views.sql#2 drop -clickhouse/06_materialized_views.sql#23 parse -clickhouse/06_materialized_views.sql#24 parse -clickhouse/06_materialized_views.sql#25 parse -clickhouse/06_materialized_views.sql#27 parse -clickhouse/06_materialized_views.sql#28 parse -clickhouse/06_materialized_views.sql#29 parse -clickhouse/06_materialized_views.sql#3 drop -clickhouse/06_materialized_views.sql#30 parse -clickhouse/06_materialized_views.sql#34 parse -clickhouse/06_materialized_views.sql#35 parse -clickhouse/06_materialized_views.sql#4 parse -clickhouse/06_materialized_views.sql#5 parse -clickhouse/06_materialized_views.sql#6 drop clickhouse/08_etl_pipelines.sql#34 parse clickhouse/08_etl_pipelines.sql#35 parse clickhouse/08_etl_pipelines.sql#36 parse @@ -79,35 +38,15 @@ clickhouse/08_etl_pipelines.sql#37 parse clickhouse/08_etl_pipelines.sql#38 parse clickhouse/09_deep_nesting.sql#17 parse clickhouse/09_deep_nesting.sql#28 parse -clickhouse/10_misc_real.sql#10 drop -clickhouse/10_misc_real.sql#11 drop clickhouse/10_misc_real.sql#12 parse -clickhouse/10_misc_real.sql#13 drop -clickhouse/10_misc_real.sql#14 drop -clickhouse/10_misc_real.sql#15 drop -clickhouse/10_misc_real.sql#16 drop -clickhouse/10_misc_real.sql#18 parse -clickhouse/10_misc_real.sql#19 drop -clickhouse/10_misc_real.sql#20 drop -clickhouse/10_misc_real.sql#21 drop -clickhouse/10_misc_real.sql#22 drop clickhouse/10_misc_real.sql#23 parse clickhouse/10_misc_real.sql#24 parse -clickhouse/10_misc_real.sql#25 drop clickhouse/10_misc_real.sql#26 parse -clickhouse/10_misc_real.sql#27 drop -clickhouse/10_misc_real.sql#7 drop -clickhouse/10_misc_real.sql#8 drop -clickhouse/10_misc_real.sql#9 drop mariadb/02_reporting.sql#19 parse mariadb/02_reporting.sql#20 parse mariadb/02_reporting.sql#21 parse mariadb/02_reporting.sql#29 parse -mariadb/03_ddl_migrations.sql#0 parse mariadb/03_ddl_migrations.sql#1 parse -mariadb/03_ddl_migrations.sql#10 drop -mariadb/03_ddl_migrations.sql#13 parse -mariadb/03_ddl_migrations.sql#14 parse mariadb/03_ddl_migrations.sql#17 parse mariadb/03_ddl_migrations.sql#18 parse mariadb/03_ddl_migrations.sql#19 parse @@ -122,20 +61,7 @@ mariadb/03_ddl_migrations.sql#27 parse mariadb/03_ddl_migrations.sql#29 parse mariadb/03_ddl_migrations.sql#3 parse mariadb/03_ddl_migrations.sql#31 parse -mariadb/03_ddl_migrations.sql#36 parse -mariadb/03_ddl_migrations.sql#37 parse -mariadb/03_ddl_migrations.sql#38 parse -mariadb/03_ddl_migrations.sql#39 parse -mariadb/03_ddl_migrations.sql#4 parse -mariadb/03_ddl_migrations.sql#40 parse -mariadb/03_ddl_migrations.sql#41 parse -mariadb/03_ddl_migrations.sql#5 parse mariadb/03_ddl_migrations.sql#6 parse -mariadb/03_ddl_migrations.sql#7 parse -mariadb/03_ddl_migrations.sql#8 reparse -mariadb/05_mariadb_specific.sql#26 drop -mariadb/05_mariadb_specific.sql#27 drop -mariadb/05_mariadb_specific.sql#28 parse mariadb/05_mariadb_specific.sql#29 parse mariadb/05_mariadb_specific.sql#30 parse mariadb/05_mariadb_specific.sql#31 parse @@ -150,16 +76,7 @@ mariadb/05_mariadb_specific.sql#39 parse mariadb/05_mariadb_specific.sql#40 parse mariadb/05_mariadb_specific.sql#41 parse mariadb/05_mariadb_specific.sql#58 parse -mariadb/05_mariadb_specific.sql#65 parse mariadb/06_etl_admin.sql#36 parse -mariadb/07_stored_programs.sql#0 parse -mariadb/07_stored_programs.sql#1 parse -mariadb/07_stored_programs.sql#18 parse -mariadb/07_stored_programs.sql#2 parse -mariadb/07_stored_programs.sql#3 parse -mariadb/07_stored_programs.sql#4 parse -mariadb/07_stored_programs.sql#6 parse -mariadb/07_stored_programs.sql#7 parse mariadb/08_complex_queries.sql#12 parse mariadb/08_complex_queries.sql#21 parse mariadb/09_mysql_compat_misc.sql#11 parse @@ -170,7 +87,6 @@ mariadb/09_mysql_compat_misc.sql#5 parse mysql/02_reporting.sql#28 parse mysql/03_ddl_migrations.sql#0 parse mysql/03_ddl_migrations.sql#1 parse -mysql/03_ddl_migrations.sql#10 parse mysql/03_ddl_migrations.sql#11 parse mysql/03_ddl_migrations.sql#12 parse mysql/03_ddl_migrations.sql#14 parse @@ -186,14 +102,7 @@ mysql/03_ddl_migrations.sql#25 parse mysql/03_ddl_migrations.sql#26 parse mysql/03_ddl_migrations.sql#27 parse mysql/03_ddl_migrations.sql#3 parse -mysql/03_ddl_migrations.sql#30 parse -mysql/03_ddl_migrations.sql#32 parse -mysql/03_ddl_migrations.sql#33 parse mysql/03_ddl_migrations.sql#4 parse -mysql/03_ddl_migrations.sql#42 parse -mysql/03_ddl_migrations.sql#43 parse -mysql/03_ddl_migrations.sql#6 parse -mysql/03_ddl_migrations.sql#7 reparse mysql/03_ddl_migrations.sql#9 parse mysql/04_window_analytics.sql#20 parse mysql/05_dialect_specific.sql#12 parse @@ -203,16 +112,8 @@ mysql/05_dialect_specific.sql#15 parse mysql/05_dialect_specific.sql#68 parse mysql/05_dialect_specific.sql#8 parse mysql/06_wordpress.sql#20 parse -mysql/07_laravel_magento.sql#7 parse mysql/07_laravel_magento.sql#8 parse -mysql/08_etl_admin.sql#22 parse -mysql/08_etl_admin.sql#23 parse -mysql/08_etl_admin.sql#24 parse mysql/08_etl_admin.sql#25 parse -mysql/08_etl_admin.sql#26 parse -mysql/08_etl_admin.sql#34 parse -mysql/08_etl_admin.sql#35 parse -mysql/08_etl_admin.sql#36 parse mysql/09_deep_nesting.sql#26 parse mysql/09_deep_nesting.sql#27 parse mysql/09_deep_nesting.sql#30 parse @@ -233,35 +134,12 @@ mysql/11_stored_programs.sql#24 parse mysql/11_stored_programs.sql#25 parse mysql/11_stored_programs.sql#26 parse mysql/11_stored_programs.sql#27 parse -mysql/11_stored_programs.sql#28 parse -mysql/11_stored_programs.sql#29 parse mysql/11_stored_programs.sql#3 parse -mysql/11_stored_programs.sql#30 parse -mysql/11_stored_programs.sql#31 parse -mysql/11_stored_programs.sql#32 parse -mysql/11_stored_programs.sql#33 parse -mysql/11_stored_programs.sql#34 parse mysql/11_stored_programs.sql#5 parse mysql/11_stored_programs.sql#6 parse -mysql/11_stored_programs.sql#7 parse -mysql/11_stored_programs.sql#8 parse mysql/12_orm_misc.sql#6 parse oracle/02_reporting.sql#17 drop -oracle/03_ddl_migrations.sql#10 parse -oracle/03_ddl_migrations.sql#11 reparse -oracle/03_ddl_migrations.sql#12 reparse oracle/03_ddl_migrations.sql#13 parse -oracle/03_ddl_migrations.sql#17 parse -oracle/03_ddl_migrations.sql#18 parse -oracle/03_ddl_migrations.sql#19 parse -oracle/03_ddl_migrations.sql#20 parse -oracle/03_ddl_migrations.sql#21 parse -oracle/03_ddl_migrations.sql#22 parse -oracle/03_ddl_migrations.sql#24 parse -oracle/03_ddl_migrations.sql#25 parse -oracle/03_ddl_migrations.sql#26 parse -oracle/03_ddl_migrations.sql#27 parse -oracle/03_ddl_migrations.sql#28 parse oracle/03_ddl_migrations.sql#29 parse oracle/03_ddl_migrations.sql#3 parse oracle/03_ddl_migrations.sql#30 parse @@ -276,15 +154,7 @@ oracle/03_ddl_migrations.sql#42 parse oracle/03_ddl_migrations.sql#43 parse oracle/03_ddl_migrations.sql#44 parse oracle/03_ddl_migrations.sql#45 parse -oracle/03_ddl_migrations.sql#47 parse oracle/03_ddl_migrations.sql#5 parse -oracle/03_ddl_migrations.sql#50 parse -oracle/03_ddl_migrations.sql#58 parse -oracle/03_ddl_migrations.sql#59 parse -oracle/03_ddl_migrations.sql#6 parse -oracle/03_ddl_migrations.sql#7 drop -oracle/03_ddl_migrations.sql#8 parse -oracle/03_ddl_migrations.sql#9 parse oracle/04_window_analytics.sql#19 parse oracle/04_window_analytics.sql#22 parse oracle/04_window_analytics.sql#23 parse @@ -295,19 +165,10 @@ oracle/05_dialect_specific.sql#18 parse oracle/05_dialect_specific.sql#19 parse oracle/05_dialect_specific.sql#32 parse oracle/06_etl_merge.sql#10 parse -oracle/06_etl_merge.sql#24 reparse -oracle/06_etl_merge.sql#25 parse oracle/06_etl_merge.sql#36 parse oracle/07_admin_ops.sql#0 parse oracle/07_admin_ops.sql#1 parse oracle/07_admin_ops.sql#10 parse -oracle/07_admin_ops.sql#11 parse -oracle/07_admin_ops.sql#12 parse -oracle/07_admin_ops.sql#13 parse -oracle/07_admin_ops.sql#14 parse -oracle/07_admin_ops.sql#15 parse -oracle/07_admin_ops.sql#16 parse -oracle/07_admin_ops.sql#17 parse oracle/07_admin_ops.sql#2 parse oracle/07_admin_ops.sql#3 parse oracle/07_admin_ops.sql#34 parse @@ -324,16 +185,6 @@ oracle/08_complex_nested.sql#19 parse postgresql/02_reporting.sql#27 drop postgresql/02_reporting.sql#8 parse postgresql/03_ddl_migrations.sql#1 parse -postgresql/03_ddl_migrations.sql#10 parse -postgresql/03_ddl_migrations.sql#12 parse -postgresql/03_ddl_migrations.sql#14 parse -postgresql/03_ddl_migrations.sql#16 parse -postgresql/03_ddl_migrations.sql#17 parse -postgresql/03_ddl_migrations.sql#19 parse -postgresql/03_ddl_migrations.sql#21 parse -postgresql/03_ddl_migrations.sql#24 parse -postgresql/03_ddl_migrations.sql#25 drop -postgresql/03_ddl_migrations.sql#26 parse postgresql/03_ddl_migrations.sql#28 parse postgresql/03_ddl_migrations.sql#29 parse postgresql/03_ddl_migrations.sql#3 parse @@ -342,7 +193,6 @@ postgresql/03_ddl_migrations.sql#34 parse postgresql/03_ddl_migrations.sql#37 parse postgresql/03_ddl_migrations.sql#38 parse postgresql/03_ddl_migrations.sql#39 parse -postgresql/03_ddl_migrations.sql#4 parse postgresql/03_ddl_migrations.sql#42 parse postgresql/03_ddl_migrations.sql#43 parse postgresql/03_ddl_migrations.sql#44 parse @@ -351,16 +201,7 @@ postgresql/03_ddl_migrations.sql#46 parse postgresql/03_ddl_migrations.sql#47 parse postgresql/03_ddl_migrations.sql#48 parse postgresql/03_ddl_migrations.sql#49 parse -postgresql/03_ddl_migrations.sql#5 parse -postgresql/03_ddl_migrations.sql#50 parse -postgresql/03_ddl_migrations.sql#59 parse postgresql/03_ddl_migrations.sql#6 parse -postgresql/03_ddl_migrations.sql#60 parse -postgresql/03_ddl_migrations.sql#61 parse -postgresql/03_ddl_migrations.sql#62 parse -postgresql/03_ddl_migrations.sql#63 parse -postgresql/03_ddl_migrations.sql#7 reparse -postgresql/03_ddl_migrations.sql#9 parse postgresql/05_dialect_specific.sql#14 parse postgresql/05_dialect_specific.sql#35 drop postgresql/05_dialect_specific.sql#40 parse @@ -370,32 +211,13 @@ postgresql/05_dialect_specific.sql#53 parse postgresql/06_cte_dml_etl.sql#3 drop postgresql/06_cte_dml_etl.sql#5 parse postgresql/06_cte_dml_etl.sql#6 parse -postgresql/07_admin_ops.sql#20 parse -postgresql/07_admin_ops.sql#41 parse -postgresql/07_admin_ops.sql#42 parse -postgresql/07_admin_ops.sql#43 parse -postgresql/07_admin_ops.sql#44 parse snowflake/02_reporting.sql#21 drop snowflake/03_ddl_migrations.sql#1 parse -snowflake/03_ddl_migrations.sql#10 parse -snowflake/03_ddl_migrations.sql#12 parse snowflake/03_ddl_migrations.sql#16 parse -snowflake/03_ddl_migrations.sql#2 drop snowflake/03_ddl_migrations.sql#20 parse snowflake/03_ddl_migrations.sql#24 parse snowflake/03_ddl_migrations.sql#25 parse snowflake/03_ddl_migrations.sql#26 parse -snowflake/03_ddl_migrations.sql#3 parse -snowflake/03_ddl_migrations.sql#31 reparse -snowflake/03_ddl_migrations.sql#32 reparse -snowflake/03_ddl_migrations.sql#33 reparse -snowflake/03_ddl_migrations.sql#34 reparse -snowflake/03_ddl_migrations.sql#35 reparse -snowflake/03_ddl_migrations.sql#5 parse -snowflake/03_ddl_migrations.sql#6 parse -snowflake/03_ddl_migrations.sql#7 parse -snowflake/03_ddl_migrations.sql#8 reparse -snowflake/03_ddl_migrations.sql#9 reparse snowflake/05_dialect_specific.sql#16 drop snowflake/05_dialect_specific.sql#17 drop snowflake/05_dialect_specific.sql#18 drop @@ -410,29 +232,17 @@ snowflake/05_dialect_specific.sql#42 drop snowflake/05_dialect_specific.sql#49 parse snowflake/05_dialect_specific.sql#5 parse snowflake/05_dialect_specific.sql#8 parse -snowflake/06_etl_merge.sql#13 reparse -snowflake/06_etl_merge.sql#14 reparse -snowflake/06_etl_merge.sql#16 reparse -snowflake/06_etl_merge.sql#17 reparse snowflake/06_etl_merge.sql#18 parse snowflake/06_etl_merge.sql#19 parse -snowflake/06_etl_merge.sql#20 reparse -snowflake/06_etl_merge.sql#21 parse -snowflake/06_etl_merge.sql#22 reparse -snowflake/07_admin.sql#21 reparse snowflake/07_admin.sql#22 parse snowflake/07_admin.sql#23 parse -snowflake/07_admin.sql#24 reparse -snowflake/07_admin.sql#32 parse snowflake/07_admin.sql#33 parse snowflake/07_admin.sql#34 parse snowflake/07_admin.sql#35 parse snowflake/07_admin.sql#36 parse snowflake/07_admin.sql#46 parse snowflake/07_admin.sql#47 parse -snowflake/07_admin.sql#48 parse snowflake/07_admin.sql#49 parse -snowflake/07_admin.sql#50 parse snowflake/07_admin.sql#51 parse snowflake/08_pivot_match.sql#1 parse snowflake/08_pivot_match.sql#13 parse @@ -443,26 +253,13 @@ snowflake/08_pivot_match.sql#6 drop snowflake/08_pivot_match.sql#7 drop snowflake/09_dbt_models.sql#0 parse snowflake/09_dbt_models.sql#1 parse -snowflake/09_dbt_models.sql#12 parse snowflake/09_dbt_models.sql#15 parse -snowflake/09_dbt_models.sql#2 parse snowflake/10_complex_nested.sql#26 parse sqlite/01_app_crud.sql#0 drop -sqlite/02_upsert_replace.sql#0 drop sqlite/02_upsert_replace.sql#2 parse sqlite/03_ddl_migrations.sql#1 drop -sqlite/03_ddl_migrations.sql#13 parse -sqlite/03_ddl_migrations.sql#15 drop -sqlite/03_ddl_migrations.sql#23 parse -sqlite/03_ddl_migrations.sql#25 parse -sqlite/03_ddl_migrations.sql#27 parse -sqlite/03_ddl_migrations.sql#28 parse -sqlite/03_ddl_migrations.sql#3 parse -sqlite/03_ddl_migrations.sql#4 parse sqlite/03_ddl_migrations.sql#5 parse sqlite/03_ddl_migrations.sql#6 parse -sqlite/03_ddl_migrations.sql#7 drop -sqlite/03_ddl_migrations.sql#9 reparse sqlite/05_dialect_specific.sql#14 drop sqlite/05_dialect_specific.sql#15 drop sqlite/05_dialect_specific.sql#29 drop @@ -474,19 +271,12 @@ sqlite/05_dialect_specific.sql#44 parse sqlite/06_json.sql#16 parse sqlite/06_json.sql#17 parse sqlite/06_json.sql#18 parse -sqlite/06_json.sql#21 parse -sqlite/06_json.sql#22 parse sqlite/07_ctes_recursive.sql#10 parse sqlite/07_ctes_recursive.sql#2 parse sqlite/07_ctes_recursive.sql#7 drop sqlite/07_ctes_recursive.sql#8 drop sqlite/09_etl_admin.sql#1 drop sqlite/10_mobile_offline_sync.sql#0 drop -sqlite/10_mobile_offline_sync.sql#20 drop -sqlite/10_mobile_offline_sync.sql#21 parse -sqlite/10_mobile_offline_sync.sql#4 parse -sqlite/10_mobile_offline_sync.sql#5 parse -sqlite/10_mobile_offline_sync.sql#7 parse sqlite/11_deep_nesting.sql#14 parse sqlite/12_orm_generated.sql#10 parse sqlite/12_orm_generated.sql#11 drop @@ -500,14 +290,7 @@ sqlserver/03_ddl_migrations.sql#10 parse sqlserver/03_ddl_migrations.sql#11 parse sqlserver/03_ddl_migrations.sql#12 parse sqlserver/03_ddl_migrations.sql#13 parse -sqlserver/03_ddl_migrations.sql#15 parse -sqlserver/03_ddl_migrations.sql#16 parse -sqlserver/03_ddl_migrations.sql#18 parse sqlserver/03_ddl_migrations.sql#2 parse -sqlserver/03_ddl_migrations.sql#23 parse -sqlserver/03_ddl_migrations.sql#25 parse -sqlserver/03_ddl_migrations.sql#26 parse -sqlserver/03_ddl_migrations.sql#29 parse sqlserver/03_ddl_migrations.sql#4 parse sqlserver/03_ddl_migrations.sql#5 parse sqlserver/03_ddl_migrations.sql#6 parse @@ -526,11 +309,6 @@ sqlserver/06_merge_etl.sql#16 parse sqlserver/06_merge_etl.sql#4 parse sqlserver/07_ctes_deep.sql#11 parse sqlserver/07_ctes_deep.sql#15 parse -sqlserver/08_procs_batches.sql#28 parse -sqlserver/08_procs_batches.sql#31 parse -sqlserver/08_procs_batches.sql#34 parse -sqlserver/08_procs_batches.sql#36 parse -sqlserver/08_procs_batches.sql#37 parse sqlserver/09_admin_dmv.sql#16 parse sqlserver/09_admin_dmv.sql#17 parse sqlserver/09_admin_dmv.sql#18 parse From 975d359637cb333383ce4e96a2feb4302fa193d0 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:29:26 +0530 Subject: [PATCH 28/95] fix(gosqlx,linter): report syntax errors instead of "no violations" gosqlx.Lint and LintWithDialect ignored tokenizer and parser failures, so invalid SQL came back as a clean result with a nil error. linter.FileResult now records TokenizeError and ParseError (SyntaxError() returns whichever is set) and FormatResult prints them. Lint and LintWithDialect return the violations of the text and token rules, which still run on invalid input, together with an error wrapping ErrTokenize or ErrSyntax. Empty, whitespace-only and comment-only content has no statements and is not a syntax error. The playground bridge keeps reporting invalid SQL through ParseError and no longer drops the violations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/gosqlx/lint.go | 34 +++++++++---- pkg/gosqlx/lint_dialect_test.go | 6 ++- pkg/gosqlx/lint_syntax_test.go | 87 +++++++++++++++++++++++++++++++++ pkg/gosqlx/lint_test.go | 6 +-- pkg/linter/linter.go | 59 +++++++++++++++++++--- wasm/internal/bridge/bridge.go | 11 ++++- 6 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 pkg/gosqlx/lint_syntax_test.go diff --git a/pkg/gosqlx/lint.go b/pkg/gosqlx/lint.go index 2cb228d9..8f793447 100644 --- a/pkg/gosqlx/lint.go +++ b/pkg/gosqlx/lint.go @@ -46,6 +46,12 @@ var defaultLinter = sync.OnceValue(func() *linter.Linter { // Lint checks SQL for style, safety, and performance issues using all 30 built-in // lint rules (L001-L030). Returns violations found and any error encountered. // +// SQL that does not tokenize or parse is reported as an error wrapping +// ErrTokenize or ErrSyntax, so a caller that only checks err cannot mistake +// invalid SQL for clean SQL. The returned *LintResult is still non-nil in that +// case and holds the violations of the text and token rules, which run on +// invalid input; callers that want only those can ignore the error. +// // This is a convenience wrapper around the pkg/linter package that uses a // shared, lazily-built linter with all rules enabled using sensible defaults. // For fine-grained control over which rules to enable or their configuration, @@ -64,11 +70,24 @@ var defaultLinter = sync.OnceValue(func() *linter.Linter { // } func Lint(sql string) (*LintResult, error) { l := defaultLinter() - fileResult := l.LintString(sql, "") + return lintResult(l.LintString(sql, "")) +} + +// lintResult converts a linter.FileResult into the Lint return values: a rule +// failure returns no result; invalid SQL returns the result together with an +// error wrapping ErrTokenize or ErrSyntax. +func lintResult(fileResult linter.FileResult) (*LintResult, error) { if fileResult.Error != nil { return nil, fileResult.Error } - return &LintResult{Violations: fileResult.Violations}, nil + res := &LintResult{Violations: fileResult.Violations} + switch { + case fileResult.TokenizeError != nil: + return res, fmt.Errorf("%w: %w", ErrTokenize, fileResult.TokenizeError) + case fileResult.ParseError != nil: + return res, fmt.Errorf("%w: %w", ErrSyntax, fileResult.ParseError) + } + return res, nil } // dialectLinters caches one linter per dialect for LintWithDialect. Only @@ -96,8 +115,9 @@ func linterForDialect(dialect sqlkeywords.SQLDialect) *linter.Linter { // LintWithDialect is like Lint but tokenizes and parses the SQL with the given // dialect, so the AST-based rules (L011-L030) also run on dialect-specific // syntax such as SQL Server's TOP or [bracketed] identifiers. An empty dialect -// behaves exactly like Lint. An unknown dialect returns an error wrapping -// ErrUnsupportedDialect, matching ParseWithDialect. +// behaves exactly like Lint, including its error for invalid SQL. An unknown +// dialect returns an error wrapping ErrUnsupportedDialect, matching +// ParseWithDialect. // // Thread Safety: safe for concurrent use. func LintWithDialect(sql string, dialect sqlkeywords.SQLDialect) (*LintResult, error) { @@ -107,11 +127,7 @@ func LintWithDialect(sql string, dialect sqlkeywords.SQLDialect) (*LintResult, e if !sqlkeywords.IsValidDialect(string(dialect)) { return nil, fmt.Errorf("%w: %q", ErrUnsupportedDialect, dialect) } - fileResult := linterForDialect(dialect).LintString(sql, "") - if fileResult.Error != nil { - return nil, fileResult.Error - } - return &LintResult{Violations: fileResult.Violations}, nil + return lintResult(linterForDialect(dialect).LintString(sql, "")) } // allRules returns all 30 built-in lint rules with sensible defaults: the diff --git a/pkg/gosqlx/lint_dialect_test.go b/pkg/gosqlx/lint_dialect_test.go index 17a24df8..4007c9fa 100644 --- a/pkg/gosqlx/lint_dialect_test.go +++ b/pkg/gosqlx/lint_dialect_test.go @@ -46,6 +46,10 @@ func TestLintWithDialect(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { res, err := LintWithDialect(sql, tt.dialect) + if err != nil && res != nil && errors.Is(err, ErrSyntax) && tt.dialect == "" { + // The default dialect does not parse TOP; that is the point. + err = nil + } if err != nil { t.Fatalf("LintWithDialect: %v", err) } @@ -56,7 +60,7 @@ func TestLintWithDialect(t *testing.T) { } // The shared default linter must stay on the default dialect. - res, err := Lint(sql) + res, err := lintTolerant(sql) if err != nil { t.Fatalf("Lint: %v", err) } diff --git a/pkg/gosqlx/lint_syntax_test.go b/pkg/gosqlx/lint_syntax_test.go new file mode 100644 index 00000000..18f4d10a --- /dev/null +++ b/pkg/gosqlx/lint_syntax_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx + +import ( + "errors" + "testing" + + sqlkeywords "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// lintTolerant runs Lint for tests that exercise the rules on SQL the parser +// does not accept: the syntax error is expected, the violations are what is +// being checked. +func lintTolerant(sql string) (*LintResult, error) { + res, err := Lint(sql) + if err != nil && res != nil && (errors.Is(err, ErrSyntax) || errors.Is(err, ErrTokenize)) { + return res, nil + } + return res, err +} + +func TestLint_SyntaxErrorsAreReported(t *testing.T) { + tests := []struct { + name string + sql string + dialect sqlkeywords.SQLDialect + wantErr error + }{ + {"misspelled keyword", "SELEC FROM", "", ErrSyntax}, + {"incomplete statement", "SELECT FROM WHERE (", "", ErrSyntax}, + {"unterminated string", "SELECT 'abc", "", ErrTokenize}, + {"dialect: syntax error", "SELEC FROM", sqlkeywords.DialectPostgreSQL, ErrSyntax}, + {"dialect: tokenize error", "SELECT 'abc", sqlkeywords.DialectMySQL, ErrTokenize}, + {"valid SQL", "SELECT id FROM users WHERE id = 1", "", nil}, + {"valid SQL with dialect", "SELECT TOP 5 id FROM users", sqlkeywords.DialectSQLServer, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + res *LintResult + err error + ) + if tt.dialect == "" { + res, err = Lint(tt.sql) + } else { + res, err = LintWithDialect(tt.sql, tt.dialect) + } + if tt.wantErr == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want it to wrap %v", err, tt.wantErr) + } + if res == nil { + t.Fatal("result is nil; the violations of the text and token rules must still be returned") + } + }) + } +} + +func TestLint_SyntaxErrorKeepsTextRuleViolations(t *testing.T) { + // L001 (trailing whitespace) is a text rule and must still fire on SQL + // that does not parse. + res, err := Lint("SELEC FROM \n") + if !errors.Is(err, ErrSyntax) { + t.Fatalf("err = %v, want ErrSyntax", err) + } + if res == nil || !hasRule(res, "L001") { + t.Fatalf("expected L001 alongside the syntax error, got %+v", res) + } +} diff --git a/pkg/gosqlx/lint_test.go b/pkg/gosqlx/lint_test.go index 67a9ee1e..626f0e6a 100644 --- a/pkg/gosqlx/lint_test.go +++ b/pkg/gosqlx/lint_test.go @@ -105,7 +105,7 @@ func TestLint_ConcurrentMatchesSerial(t *testing.T) { } want := make([][]linter.Violation, len(inputs)) for i, in := range inputs { - res, err := Lint(in) + res, err := lintTolerant(in) if err != nil { t.Fatalf("serial Lint(%q): %v", in, err) } @@ -124,7 +124,7 @@ func TestLint_ConcurrentMatchesSerial(t *testing.T) { defer wg.Done() for i := 0; i < iterations; i++ { idx := (g + i) % len(inputs) - res, err := Lint(inputs[idx]) + res, err := lintTolerant(inputs[idx]) if err != nil { mu.Lock() errs = append(errs, fmt.Sprintf("Lint(%q): %v", inputs[idx], err)) @@ -191,7 +191,7 @@ func TestLint_DeterministicL006L009(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { for i := 0; i < 100; i++ { - res, err := Lint(tt.sql) + res, err := lintTolerant(tt.sql) if err != nil { t.Fatalf("iteration %d: Lint: %v", i, err) } diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index 59f1eb55..0b6b9e39 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -20,6 +20,7 @@ import ( "path/filepath" "strings" + "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" @@ -47,13 +48,31 @@ type Result struct { // - Filename: Path to the file that was linted // - Violations: All rule violations found in this file // - Error: Any error encountered during linting (file read, rule execution) +// - TokenizeError: The tokenizer error, when the content could not be tokenized +// - ParseError: The parser error, when the content tokenized but did not parse // // A FileResult with non-nil Error may still contain partial violations // from rules that executed successfully before the error occurred. +// +// TokenizeError and ParseError describe invalid SQL, not a failed lint run: +// the text and token rules still run and their violations are reported, so +// callers that must not accept invalid SQL (CI gates, the CLI) check +// SyntaxError. type FileResult struct { - Filename string - Violations []Violation - Error error + Filename string + Violations []Violation + Error error + TokenizeError error + ParseError error +} + +// SyntaxError returns the tokenizer or parser error for content that is not +// valid SQL, or nil when the content tokenized and parsed. +func (r FileResult) SyntaxError() error { + if r.TokenizeError != nil { + return r.TokenizeError + } + return r.ParseError } // Linter performs SQL linting with configurable rules. @@ -210,7 +229,8 @@ func (l *Linter) LintFile(filename string) FileResult { // This allows partial linting of syntactically invalid SQL. // // Returns a FileResult containing violations. The Error field is only set if -// a rule execution fails, not for tokenization/parsing failures. +// a rule execution fails. Tokenization and parsing failures are recorded in +// TokenizeError and ParseError (see FileResult.SyntaxError). // // Example: // @@ -238,11 +258,19 @@ func (l *Linter) LintString(sql string, filename string) FileResult { if tokenErr == nil { ctx.WithTokens(tokens) - // Attempt parsing with position tracking (best effort - some rules are token-only) - defer p.Release() - astObj, parseErr := p.ParseFromModelTokens(tokens) - ctx.WithAST(astObj, parseErr) + if hasStatementTokens(tokens) { + // Attempt parsing with position tracking (best effort - some rules are token-only) + defer p.Release() + astObj, parseErr := p.ParseFromModelTokens(tokens) + ctx.WithAST(astObj, parseErr) + result.ParseError = parseErr + } else { + // Empty, whitespace-only and comment-only content has no + // statements: nothing to parse, and not a syntax error. + p.Release() + } } else { + result.TokenizeError = tokenErr p.Release() } @@ -259,6 +287,17 @@ func (l *Linter) LintString(sql string, filename string) FileResult { return result } +// hasStatementTokens reports whether tokens holds anything besides the +// end-of-input marker. +func hasStatementTokens(tokens []models.TokenWithSpan) bool { + for i := range tokens { + if tokens[i].Token.Type != models.TokenTypeEOF { + return true + } + } + return false +} + // LintFiles lints multiple files in batch. // // Each file is linted independently. Errors reading or linting one file don't @@ -426,6 +465,10 @@ func FormatResult(result Result) string { continue } + if synErr := fileResult.SyntaxError(); synErr != nil { + sb.WriteString(fmt.Sprintf("\n%s: SYNTAX ERROR: %v\n", fileResult.Filename, synErr)) + } + if len(fileResult.Violations) == 0 { continue } diff --git a/wasm/internal/bridge/bridge.go b/wasm/internal/bridge/bridge.go index be4e1020..915a1400 100644 --- a/wasm/internal/bridge/bridge.go +++ b/wasm/internal/bridge/bridge.go @@ -27,6 +27,8 @@ package bridge import ( + "errors" + "github.com/ajitpratap0/GoSQLX/pkg/advisor" "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" @@ -90,8 +92,13 @@ func Lint(sql, dialect string) LintResponse { result, err := gosqlx.LintWithDialect(sql, Dialect(dialect)) if err != nil { - resp.Error = err.Error() - return resp + // Invalid SQL is reported through ParseError above; the violations of + // the rules that still ran are returned with the error. + invalidSQL := errors.Is(err, gosqlx.ErrSyntax) || errors.Is(err, gosqlx.ErrTokenize) + if result == nil || !invalidSQL { + resp.Error = err.Error() + return resp + } } for _, v := range result.Violations { resp.Violations = append(resp.Violations, LintViolation{ From a77414752e05c2f6e4f807551eba2d77cc44ff9b Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:29:48 +0530 Subject: [PATCH 29/95] fix(formatter,gosqlx): keep comments and optimizer hints when formatting gosqlx.Format parsed without the tokenizer's comments, so "format" deleted every comment and optimizer hint (changing query plans for /*+ FULL(t) */). formatter.JoinWithComments re-attaches comments around already-rendered statements using the token spans: leading and trailing comments stay with their statement, comments inside a statement move to the line above it, and a hint directly after the statement's first keyword stays there. A hint or MySQL executable comment that cannot be put back faithfully fails with ErrUnplaceableComment instead of being dropped or moved. gosqlx.Format uses it whenever the SQL has comments, and the new WithComments parse option attaches comments to ast.AST.Comments for FormatTree and FormatAST. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/comments.go | 383 +++++++++++++++++++++++++++++ pkg/formatter/comments_test.go | 204 +++++++++++++++ pkg/gosqlx/format_comments_test.go | 102 ++++++++ pkg/gosqlx/gosqlx.go | 48 +++- pkg/gosqlx/options.go | 26 ++ pkg/gosqlx/tree.go | 19 +- 6 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 pkg/formatter/comments.go create mode 100644 pkg/formatter/comments_test.go create mode 100644 pkg/gosqlx/format_comments_test.go diff --git a/pkg/formatter/comments.go b/pkg/formatter/comments.go new file mode 100644 index 00000000..e7c383f4 --- /dev/null +++ b/pkg/formatter/comments.go @@ -0,0 +1,383 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter + +import ( + "errors" + "fmt" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// ErrUnplaceableComment is returned by JoinWithComments for a comment that +// carries meaning to the database (an optimizer hint such as /*+ FULL(t) */ or +// a MySQL executable comment such as /*!40101 ... */) and cannot be put back +// where it was. Writing such a comment elsewhere, or dropping it, would change +// what the statement does, so the caller must leave the input unformatted. +var ErrUnplaceableComment = errors.New("formatter: cannot preserve comment") + +// CommentLayout describes how JoinWithComments separates statements. +type CommentLayout struct { + // Terminator is written after every statement except the last. Use ";" + // when the rendered statements carry no terminator, "" when they already + // end with one. + Terminator string + // Gap separates one statement (and its trailing comments) from the next: + // "\n\n", "\n" or " ". It is widened to a line break whenever a line + // comment would otherwise swallow the following text. + Gap string +} + +// JoinWithComments joins already-rendered statements and puts the comments of +// the original text back. +// +// stmts holds the rendered text of every statement in source order, tokens the +// token stream of the source (EOF is ignored) and comments the comments the +// tokenizer captured from it. Statements are matched to the source by +// splitting tokens at ';'. +// +// Where a comment goes: +// +// - A comment between two statements goes in front of the next statement, +// except one that follows the previous statement on the same line, which +// stays after it (SELECT 1; -- done). +// - A comment inside a statement is moved in front of that statement, in +// source order. The renderer lays statements out afresh, so there is no +// position inside them to return to. +// - A comment after the last statement stays after it. +// - An optimizer hint directly after the statement's first keyword +// (SELECT /*+ ... */) stays there. A hint anywhere else, and a MySQL +// executable comment (/*! ... */) inside a statement, make the call fail +// with ErrUnplaceableComment. An executable comment followed by ';' on its +// own is a statement of its own and is kept verbatim with its ';'. +// +// When the statements cannot be matched to the source (stmts and the +// semicolon-separated runs of tokens differ in number) every comment is put +// in front of the first statement, under the same restriction on hints. +// +// No comment is ever dropped: the comments of the result are those of the +// input. +func JoinWithComments(stmts []string, tokens []models.TokenWithSpan, comments []models.Comment, layout CommentLayout) (string, error) { + if len(comments) == 0 { + return joinPlain(stmts, layout), nil + } + items := splitItems(tokens) + var nStmt int + for _, it := range items { + if !it.empty { + nStmt++ + } + } + if nStmt != len(stmts) || len(stmts) == 0 { + return joinHoisted(stmts, comments, layout) + } + + // Attach every comment to an item. + type attach struct { + leading []string + trailing []string + hints []string + } + att := make([]attach, len(items)) + var tail []string + for ci := range comments { + c := comments[ci] + sig := isSignificantComment(c.Text) + idx, role := locateComment(items, tokens, c) + switch role { + case roleLeading: + if sig && !items[idx].empty && isExecutable(c.Text) { + // An executable comment in front of a statement, with no ';' + // of its own, runs together with it. + return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) + } + att[idx].leading = append(att[idx].leading, c.Text) + case roleTrailing: + if sig && isExecutable(c.Text) { + return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) + } + att[idx].trailing = append(att[idx].trailing, c.Text) + case roleInner: + it := items[idx] + if sig { + if isHint(c.Text) && it.hintSlot(tokens, c) { + att[idx].hints = append(att[idx].hints, c.Text) + continue + } + return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) + } + att[idx].leading = append(att[idx].leading, c.Text) + case roleTail: + if sig && isExecutable(c.Text) { + return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) + } + tail = append(tail, c.Text) + } + } + + var sb strings.Builder + prevEndsInLine := false // the text written so far ends in a line comment + si := 0 // index into stmts + for i, it := range items { + a := att[i] + var chunk strings.Builder + endsInLine := false + if it.empty { + // A bare ';' with comments before it. If one of them is an + // executable comment (/*!...*/;) the comment is the statement and + // keeps its ';'; otherwise they are plain comments and the stray + // ';' is dropped, as the formatter drops every empty statement. + text := strings.Join(a.leading, "\n") + chunk.WriteString(text) + if hasExecutable(a.leading) { + if strings.HasPrefix(lastComment(a.leading), "--") { + chunk.WriteString("\n") + } + chunk.WriteString(";") + } else { + endsInLine = strings.HasPrefix(lastComment(a.leading), "--") + } + } else { + for _, l := range a.leading { + chunk.WriteString(l) + chunk.WriteString("\n") + } + stmt := stmts[si] + si++ + if len(a.hints) > 0 { + var err error + if stmt, err = insertHints(stmt, tokens[it.first].Token.Value, a.hints); err != nil { + return "", err + } + } + chunk.WriteString(stmt) + if i < len(items)-1 { + chunk.WriteString(layout.Terminator) + } + } + for _, t := range a.trailing { + chunk.WriteString(" ") + chunk.WriteString(t) + } + if chunk.Len() == 0 { + continue + } + if sb.Len() > 0 { + gap := layout.Gap + if (prevEndsInLine || len(a.leading) > 0) && !strings.Contains(gap, "\n") { + gap = "\n" + } + sb.WriteString(gap) + } + sb.WriteString(chunk.String()) + if len(a.trailing) > 0 { + endsInLine = strings.HasPrefix(lastComment(a.trailing), "--") + } + prevEndsInLine = endsInLine + } + for _, t := range tail { + sb.WriteString("\n") + sb.WriteString(t) + } + return sb.String(), nil +} + +func hasExecutable(comments []string) bool { + for _, c := range comments { + if isExecutable(c) { + return true + } + } + return false +} + +func lastComment(c []string) string { + if len(c) == 0 { + return "" + } + return c[len(c)-1] +} + +// joinPlain joins statements without comments. +func joinPlain(stmts []string, layout CommentLayout) string { + var sb strings.Builder + for i, s := range stmts { + if i > 0 { + sb.WriteString(layout.Terminator) + sb.WriteString(layout.Gap) + } + sb.WriteString(s) + } + return sb.String() +} + +// joinHoisted is the fallback used when statements cannot be matched to the +// source: all comments go in front of the first statement. +func joinHoisted(stmts []string, comments []models.Comment, layout CommentLayout) (string, error) { + var sb strings.Builder + for _, c := range comments { + if isSignificantComment(c.Text) { + return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) + } + sb.WriteString(c.Text) + sb.WriteString("\n") + } + sb.WriteString(joinPlain(stmts, layout)) + return sb.String(), nil +} + +// item is one ';'-terminated run of tokens: a statement, or (empty) a bare +// ';' standing where a statement would be. +type item struct { + first, last int // token indexes of the statement (first > last for empty) + term int // index of the terminating ';' token, -1 when absent + empty bool + // start and end bound the item in the source: start is the first token + // (or, for an empty item, its ';'), end is just past the terminator (or + // the last token). + start, end models.Location +} + +// splitItems splits tokens at ';'. +func splitItems(tokens []models.TokenWithSpan) []item { + var items []item + cur := item{first: -1, term: -1} + flush := func() { + if cur.first >= 0 { + items = append(items, cur) + } + cur = item{first: -1, term: -1} + } + for i := range tokens { + t := tokens[i] + switch t.Token.Type { + case models.TokenTypeEOF: + continue + case models.TokenTypeSemicolon: + if cur.first < 0 { + items = append(items, item{first: i, last: i - 1, term: i, empty: true, start: t.Start, end: t.End}) + continue + } + cur.term = i + cur.end = t.End + flush() + default: + if cur.first < 0 { + cur.first = i + cur.start = t.Start + } + cur.last = i + cur.end = t.End + } + } + flush() + return items +} + +// hintSlot reports whether c lies between the statement's first token and the +// token after it, and that first token is a keyword that can carry a hint. +func (it item) hintSlot(tokens []models.TokenWithSpan, c models.Comment) bool { + if it.empty || it.first < 0 || it.first+1 >= len(tokens) { + return false + } + switch strings.ToUpper(tokens[it.first].Token.Value) { + case "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE": + default: + return false + } + if it.first == it.last { + return false + } + return !before(c.Start, tokens[it.first].End) && before(c.Start, tokens[it.first+1].Start) +} + +type commentRole int + +const ( + roleLeading commentRole = iota + roleTrailing + roleInner + roleTail +) + +// locateComment finds the item a comment belongs to and its role there. +func locateComment(items []item, tokens []models.TokenWithSpan, c models.Comment) (int, commentRole) { + if len(items) == 0 { + return 0, roleTail + } + for i, it := range items { + if before(c.Start, it.start) { + // Between the previous item and this one (or before the first). + if i > 0 && !it.empty && c.Start.Line == items[i-1].end.Line && items[i-1].term >= 0 { + return i - 1, roleTrailing + } + return i, roleLeading + } + if before(c.Start, it.end) { + return i, roleInner + } + } + last := items[len(items)-1] + if c.Start.Line == last.end.Line { + return len(items) - 1, roleTrailing + } + return len(items) - 1, roleTail +} + +// before reports whether a is strictly earlier than b. +func before(a, b models.Location) bool { + if a.Line != b.Line { + return a.Line < b.Line + } + return a.Column < b.Column +} + +// isHint reports whether text is an optimizer hint comment, /*+ ... */. +func isHint(text string) bool { return strings.HasPrefix(text, "/*+") } + +// isExecutable reports whether text is a MySQL/MariaDB executable comment, +// /*! ... */ or /*M! ... */, whose content the server runs. +func isExecutable(text string) bool { + return strings.HasPrefix(text, "/*!") || strings.HasPrefix(text, "/*M!") +} + +// isSignificantComment reports whether a comment affects how the database +// runs the statement. +func isSignificantComment(text string) bool { return isHint(text) || isExecutable(text) } + +// insertHints writes hints after the first word of the rendered statement: +// "SELECT a FROM t" becomes "SELECT /*+ H */ a FROM t". +func insertHints(stmt, keyword string, hints []string) (string, error) { + i := 0 + for i < len(stmt) && (stmt[i] == ' ' || stmt[i] == '\t' || stmt[i] == '\n' || stmt[i] == '\r') { + i++ + } + j := i + for j < len(stmt) && (stmt[j] >= 'A' && stmt[j] <= 'Z' || stmt[j] >= 'a' && stmt[j] <= 'z') { + j++ + } + if !strings.EqualFold(stmt[i:j], keyword) { + return "", fmt.Errorf("%w: %s (rendered statement does not start with %s)", ErrUnplaceableComment, abbreviate(hints[0]), keyword) + } + return stmt[:j] + " " + strings.Join(hints, " ") + stmt[j:], nil +} + +func abbreviate(s string) string { + if len(s) > 40 { + return s[:37] + "..." + } + return s +} diff --git a/pkg/formatter/comments_test.go b/pkg/formatter/comments_test.go new file mode 100644 index 00000000..d6ba768a --- /dev/null +++ b/pkg/formatter/comments_test.go @@ -0,0 +1,204 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter + +import ( + "errors" + "sort" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" +) + +// renderJoin formats every statement of sql compactly and re-attaches its +// comments with JoinWithComments. +func renderJoin(t *testing.T, sql string, layout CommentLayout) (string, error) { + t.Helper() + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + tokens, err := tkz.TokenizeString(sql) + if err != nil { + t.Fatalf("tokenize %q: %v", sql, err) + } + p := parser.GetParser() + defer parser.PutParser(p) + tree, err := p.ParseFromModelTokens(tokens) + if err != nil { + t.Fatalf("parse %q: %v", sql, err) + } + defer ast.ReleaseAST(tree) + + style := ast.CompactStyle() + style.AddSemicolon = false + stmts := make([]string, len(tree.Statements)) + for i, s := range tree.Statements { + stmts[i] = FormatStatement(s, style) + } + return JoinWithComments(stmts, tokens, tkz.Comments, layout) +} + +func TestJoinWithComments(t *testing.T) { + layout := CommentLayout{Terminator: ";", Gap: "\n"} + tests := []struct { + name string + sql string + want string + }{ + {"no comments", "select a from t; select b from u", "SELECT a FROM t;\nSELECT b FROM u"}, + {"leading line comment", "-- who\nselect a from t", "-- who\nSELECT a FROM t"}, + {"trailing line comment", "select a from t -- why", "SELECT a FROM t -- why"}, + {"trailing comment after semicolon stays with its statement", + "select a from t; -- one\nselect b from u", "SELECT a FROM t; -- one\nSELECT b FROM u"}, + {"comment between statements goes before the next", + "select a from t;\n-- two\nselect b from u", "SELECT a FROM t;\n-- two\nSELECT b FROM u"}, + {"comment after the last statement on its own line", + "select a from t;\n-- end", "SELECT a FROM t\n-- end"}, + {"inner comment moves above its statement", + "select a, /* b is new */ b from t", "/* b is new */\nSELECT a, b FROM t"}, + {"inner line comment moves above its statement", + "select a, -- first\n b from t", "-- first\nSELECT a, b FROM t"}, + {"hint after first keyword stays", "select /*+ FULL(t) */ a from t", "SELECT /*+ FULL(t) */ a FROM t"}, + {"hint plus comment", "select /*+ FULL(t) */ a from t -- note", "SELECT /*+ FULL(t) */ a FROM t -- note"}, + {"hint on insert", "insert /*+ APPEND */ into t (a) values (1)", "INSERT /*+ APPEND */ INTO t (a) VALUES (1)"}, + {"hint on second statement", "select 1; select /*+ x */ 2", "SELECT 1;\nSELECT /*+ x */ 2"}, + {"executable comment statement kept with its semicolon", + "/*!40101 SET x = 1 */;\nselect 1", "/*!40101 SET x = 1 */;\nSELECT 1"}, + {"comment-only statement between statements", + "select 1;\n/*!40101 SET x = 1 */;\nselect 2", "SELECT 1;\n/*!40101 SET x = 1 */;\nSELECT 2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := renderJoin(t, tt.sql, layout) + if err != nil { + t.Fatalf("JoinWithComments: %v", err) + } + if got != tt.want { + t.Fatalf("got\n%s\nwant\n%s", got, tt.want) + } + }) + } +} + +func TestJoinWithComments_RefusesWhatItCannotPlace(t *testing.T) { + layout := CommentLayout{Terminator: ";", Gap: "\n"} + for name, sql := range map[string]string{ + "hint in subquery": "select * from (select /*+ x */ 1) s", + "hint after from": "select a from /*+ x */ t", + "hint in union branch": "select 1 union select /*+ x */ 2", + "executable in stmt": "select 1 /*!50000 + 1 */", + "executable before stmt": "/*!40101 SET x = 1 */ select 1", + } { + t.Run(name, func(t *testing.T) { + got, err := renderJoin(t, sql, layout) + if !errors.Is(err, ErrUnplaceableComment) { + t.Fatalf("err = %v (output %q), want ErrUnplaceableComment", err, got) + } + }) + } +} + +func TestJoinWithComments_NeverLosesAComment(t *testing.T) { + inputs := []string{ + "-- a\nselect 1 -- b\n; /* c */ select 2 /* d */ -- e\n-- f", + "select /* x */ 1, /* y */ 2 from /* z */ t where a = 1 -- w", + ";;-- only\n;select 1", + "select 1;\n\n\n/* t1 */\n/* t2 */", + } + for _, sql := range inputs { + for _, layout := range []CommentLayout{ + {Terminator: ";", Gap: "\n"}, + {Terminator: ";", Gap: "\n\n"}, + {Terminator: ";", Gap: " "}, + } { + got, err := renderJoin(t, sql, layout) + if err != nil { + t.Fatalf("%q: %v", sql, err) + } + if a, b := commentTexts(t, sql), commentTexts(t, got); strings.Join(a, "|") != strings.Join(b, "|") { + t.Errorf("comments differ for %q (layout %+v)\n in: %q\n out: %q\n got: %q", sql, layout, a, b, got) + } + // A line comment must never swallow SQL that follows it. + if _, err := reparseCount(t, got); err != nil { + t.Errorf("output %q does not parse: %v", got, err) + } + } + } +} + +func commentTexts(t *testing.T, sql string) []string { + t.Helper() + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + if _, err := tkz.TokenizeString(sql); err != nil { + t.Fatalf("tokenize %q: %v", sql, err) + } + out := make([]string, 0, len(tkz.Comments)) + for _, c := range tkz.Comments { + out = append(out, c.Text) + } + sort.Strings(out) + return out +} + +func reparseCount(t *testing.T, sql string) (int, error) { + t.Helper() + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + tokens, err := tkz.TokenizeString(sql) + if err != nil { + return 0, err + } + p := parser.GetParser() + defer parser.PutParser(p) + tree, err := p.ParseFromModelTokens(tokens) + if err != nil { + return 0, err + } + defer ast.ReleaseAST(tree) + return len(tree.Statements), nil +} + +func TestJoinWithComments_Fallback(t *testing.T) { + // A statement count that does not match the semicolon-separated runs of + // tokens (here: the caller passes one fewer rendered statement) puts the + // comments in front of the first statement instead of losing them. + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + tokens, err := tkz.TokenizeString("select 1; -- two\nselect 2") + if err != nil { + t.Fatal(err) + } + got, err := JoinWithComments([]string{"SELECT 1"}, tokens, tkz.Comments, CommentLayout{Terminator: ";", Gap: "\n"}) + if err != nil { + t.Fatal(err) + } + if want := "-- two\nSELECT 1"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestJoinWithComments_NoComments(t *testing.T) { + got, err := JoinWithComments([]string{"A", "B", "C"}, nil, nil, CommentLayout{Terminator: ";", Gap: " "}) + if err != nil || got != "A; B; C" { + t.Fatalf("got %q, %v", got, err) + } + got, _ = JoinWithComments([]string{"A;", "B;"}, nil, nil, CommentLayout{Gap: "\n"}) + if got != "A;\nB;" { + t.Fatalf("got %q", got) + } +} diff --git a/pkg/gosqlx/format_comments_test.go b/pkg/gosqlx/format_comments_test.go new file mode 100644 index 00000000..8d930bc3 --- /dev/null +++ b/pkg/gosqlx/format_comments_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" +) + +func TestFormat_PreservesCommentsAndHints(t *testing.T) { + opts := DefaultFormatOptions() + opts.UppercaseKeywords = true + compact := opts + compact.IndentSize = 0 + + tests := []struct { + name string + sql string + opts FormatOptions + want string + }{ + {"hint and trailing comment", "select /*+ hint */ a from t -- note", compact, "SELECT /*+ hint */ a FROM t -- note"}, + {"oracle full hint", "select /*+ FULL(t) */ a from t", compact, "SELECT /*+ FULL(t) */ a FROM t"}, + {"leading comment", "-- report\nselect a from t", compact, "-- report\nSELECT a FROM t"}, + {"comment between statements", "select 1; -- one\n-- two\nselect 2", compact, "SELECT 1; -- one\n-- two\nSELECT 2"}, + {"inner comment moves up", "select a, /* new */ b from t", compact, "/* new */\nSELECT a, b FROM t"}, + {"no comments unchanged path", "select a from t", compact, "SELECT a FROM t"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Format(tt.sql, tt.opts) + if err != nil { + t.Fatalf("Format: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormat_RefusesToDropAnOptimizerHint(t *testing.T) { + _, err := Format("select * from (select /*+ FULL(t) */ a from t) s", DefaultFormatOptions()) + if !errors.Is(err, formatter.ErrUnplaceableComment) { + t.Fatalf("err = %v, want it to wrap formatter.ErrUnplaceableComment", err) + } +} + +func TestFormat_AddSemicolonWithComments(t *testing.T) { + opts := DefaultFormatOptions() + opts.AddSemicolon = true + opts.IndentSize = 0 + got, err := Format("select 1 -- a\n; select 2 -- b", opts) + if err != nil { + t.Fatalf("Format: %v", err) + } + if strings.Count(got, ";") != 2 { + t.Fatalf("got %q, want exactly two semicolons", got) + } + for _, c := range []string{"-- a", "-- b"} { + if !strings.Contains(got, c) { + t.Fatalf("got %q, lost %q", got, c) + } + } +} + +func TestParseTree_WithCommentsKeepsThemForFormatTree(t *testing.T) { + const sql = "-- header\nSELECT a FROM t -- trailing" + plain, err := ParseTree(context.Background(), sql) + if err != nil { + t.Fatal(err) + } + if out := FormatTree(plain); strings.Contains(out, "--") { + t.Fatalf("comments kept without WithComments: %q", out) + } + withC, err := ParseTree(context.Background(), sql, WithComments()) + if err != nil { + t.Fatal(err) + } + out := FormatTree(withC) + for _, c := range []string{"-- header", "-- trailing"} { + if !strings.Contains(out, c) { + t.Fatalf("FormatTree output %q lost %q", out, c) + } + } +} diff --git a/pkg/gosqlx/gosqlx.go b/pkg/gosqlx/gosqlx.go index 73b1e268..6e95318a 100644 --- a/pkg/gosqlx/gosqlx.go +++ b/pkg/gosqlx/gosqlx.go @@ -608,13 +608,32 @@ func DefaultFormatOptions() FormatOptions { // opts.UppercaseKeywords = true // formatted, err := gosqlx.Format(sql, opts) // +// Comments are preserved: a comment between statements stays between them, a +// trailing comment stays after its statement, and a comment inside a statement +// moves to the line above that statement. An optimizer hint (/*+ ... */) +// directly after the first keyword of a statement is kept there. SQL whose +// comments cannot be kept faithfully, such as a hint inside a subquery, is +// rejected with an error wrapping formatter.ErrUnplaceableComment instead of +// being rewritten without them. +// // Returns the formatted SQL string or an error if SQL is invalid. func Format(sql string, options FormatOptions) (string, error) { - // Parse SQL into AST - parsedAST, err := Parse(sql) + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + + tokens, err := tkz.TokenizeString(sql) + if err != nil { + return "", fmt.Errorf("cannot format invalid SQL: %w: %w", ErrTokenize, err) + } + comments := tkz.Comments + + p := parser.GetParser() + defer parser.PutParser(p) + parsedAST, err := p.ParseFromModelTokens(tokens) if err != nil { - return "", fmt.Errorf("cannot format invalid SQL: %w", err) + return "", fmt.Errorf("cannot format invalid SQL: %w: %w", ErrSyntax, err) } + defer ast.ReleaseAST(parsedAST) // Convert gosqlx FormatOptions to ast.FormatOptions kwCase := ast.KeywordPreserve @@ -630,8 +649,27 @@ func Format(sql string, options FormatOptions) (string, error) { AddSemicolon: options.AddSemicolon, } - result := formatter.FormatAST(parsedAST, astOpts) - return result, nil + if len(comments) == 0 { + return formatter.FormatAST(parsedAST, astOpts), nil + } + + // Comments go back around the statements they belonged to. A comment that + // cannot be put back faithfully (an optimizer hint that is not directly + // after its statement's first keyword) is an error rather than silently + // dropped or moved: either would change what the statement does. + stmts := make([]string, len(parsedAST.Statements)) + for i, stmt := range parsedAST.Statements { + stmts[i] = formatter.FormatStatement(stmt, astOpts) + } + layout := formatter.CommentLayout{Terminator: ";", Gap: "\n"} + if options.AddSemicolon { + layout.Terminator = "" // each rendered statement already ends with ';' + } + out, err := formatter.JoinWithComments(stmts, tokens, comments, layout) + if err != nil { + return "", fmt.Errorf("cannot format SQL: %w", err) + } + return out, nil } // ParseWithRecovery tokenizes and parses SQL with error recovery, returning diff --git a/pkg/gosqlx/options.go b/pkg/gosqlx/options.go index 68eae484..8ef64c33 100644 --- a/pkg/gosqlx/options.go +++ b/pkg/gosqlx/options.go @@ -65,6 +65,10 @@ type parseOptions struct { // Inputs that exceed the cap are rejected with ErrTooLarge before any // parsing work begins. maxBytes int64 + + // comments attaches the comments the tokenizer captured to the parsed + // AST (ast.AST.Comments), so FormatTree and FormatAST write them back. + comments bool } // defaultParseOptions returns the baseline configuration used when no options @@ -195,3 +199,25 @@ func WithRecovery() Option { o.recover = true } } + +// WithComments keeps the comments of the SQL text: the tokenizer's comments +// are attached to the parsed ast.AST (AST.Comments), and FormatTree and +// FormatAST write them back out. Without it comments are discarded, which is +// the default because they cost an allocation and most callers only inspect +// the tree. +// +// FormatTree and FormatAST place comments without knowing where in the text +// they stood: comments on their own line go in front of the query, comments +// that shared a line with SQL after it. To keep each comment next to its +// statement, and to keep optimizer hints in place, format the text with +// Format instead. +// +// Example: +// +// tree, err := gosqlx.ParseTree(ctx, "SELECT 1 -- one", gosqlx.WithComments()) +// out := gosqlx.FormatTree(tree) // "SELECT 1 -- one" +func WithComments() Option { + return func(o *parseOptions) { + o.comments = true + } +} diff --git a/pkg/gosqlx/tree.go b/pkg/gosqlx/tree.go index dfe9597b..532d3fc4 100644 --- a/pkg/gosqlx/tree.go +++ b/pkg/gosqlx/tree.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" @@ -346,7 +347,11 @@ func parseWithConfig(ctx context.Context, sql string, cfg parseOptions) (*ast.AS joined := errors.Join(errs...) return &ast.AST{Statements: stmts}, fmt.Errorf("%w: %w", ErrSyntax, joined) } - return &ast.AST{Statements: stmts}, nil + recovered := &ast.AST{Statements: stmts} + if cfg.comments { + attachComments(recovered, tkz.Comments) + } + return recovered, nil } astNode, err := p.ParseContextFromModelTokens(ctx, tokens) @@ -356,9 +361,21 @@ func parseWithConfig(ctx context.Context, sql string, cfg parseOptions) (*ast.AS } return nil, fmt.Errorf("%w: %w", ErrSyntax, err) } + if cfg.comments { + attachComments(astNode, tkz.Comments) + } return astNode, nil } +// attachComments copies the tokenizer's comments onto a. The tokenizer reuses +// its slice, so the AST gets its own. +func attachComments(a *ast.AST, comments []models.Comment) { + if len(comments) == 0 { + return + } + a.Comments = append(a.Comments[:0], comments...) +} + // wrapContextErr returns the context error wrapped in ErrTimeout so callers // can test errors.Is(err, ErrTimeout). context.DeadlineExceeded and // context.Canceled both flow through this helper. From 956503a418f3096480a7ae90b7b0f725a51a5d49 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:29:49 +0530 Subject: [PATCH 30/95] fix(gosqlx): ParseReaderMultiple skips comment-only segments A script ending in "SELECT 1; -- done" produced a final comment-only segment that failed to parse. Segments holding only whitespace and comments are skipped like empty ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/gosqlx/reader.go | 9 +++++---- pkg/gosqlx/reader_test.go | 26 ++++++++++++++++++++++++++ pkg/gosqlx/splitter_proc.go | 6 ++++++ pkg/gosqlx/splitter_proc_test.go | 19 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/pkg/gosqlx/reader.go b/pkg/gosqlx/reader.go index ebf9208e..6e87f789 100644 --- a/pkg/gosqlx/reader.go +++ b/pkg/gosqlx/reader.go @@ -98,9 +98,10 @@ func ParseReader(ctx context.Context, r io.Reader, opts ...Option) (*Tree, error // splitting or parsing work begins. // // Empty segments (whitespace between consecutive semicolons, trailing -// whitespace after the last ';', etc.) are skipped. The first segment that -// fails to parse short-circuits the call and returns its error wrapped in -// the usual ParseTree sentinels, prefixed with the 1-based statement index. +// whitespace after the last ';', a trailing comment, etc.) are skipped. The +// first segment that fails to parse short-circuits the call and returns its +// error wrapped in the usual ParseTree sentinels, prefixed with the 1-based +// statement index. // // Example: // @@ -136,7 +137,7 @@ func ParseReaderMultiple(ctx context.Context, r io.Reader, opts ...Option) ([]*T trees := make([]*Tree, 0, len(segments)) for i, seg := range segments { seg = strings.TrimSpace(seg) - if seg == "" { + if seg == "" || commentsOnly(seg) { continue } tree, err := ParseTree(ctx, seg, opts...) diff --git a/pkg/gosqlx/reader_test.go b/pkg/gosqlx/reader_test.go index 0c4d7dc1..03a7c89e 100644 --- a/pkg/gosqlx/reader_test.go +++ b/pkg/gosqlx/reader_test.go @@ -305,3 +305,29 @@ func TestSplitSQLStatements(t *testing.T) { }) } } + +// TestParseReaderMultiple_SkipsTrailingComment guards the common script shape +// "stmt; -- note": the comment after the last ';' is not a statement. +func TestParseReaderMultiple_SkipsTrailingComment(t *testing.T) { + trees, err := ParseReaderMultiple(context.Background(), + strings.NewReader("SELECT 1; -- one\nSELECT 2;\n/* done */\n-- bye\n")) + if err != nil { + t.Fatalf("ParseReaderMultiple: %v", err) + } + if len(trees) != 2 { + t.Fatalf("got %d trees, want 2", len(trees)) + } +} + +// TestParseReaderMultiple_BatchScript checks the splitter's GO handling end +// to end through the reader. +func TestParseReaderMultiple_BatchScript(t *testing.T) { + src := "SELECT 1\nGO\nSELECT 2\nGO\n" + trees, err := ParseReaderMultiple(context.Background(), strings.NewReader(src), WithDialect("sqlserver")) + if err != nil { + t.Fatalf("ParseReaderMultiple: %v", err) + } + if len(trees) != 2 { + t.Fatalf("got %d trees, want 2", len(trees)) + } +} diff --git a/pkg/gosqlx/splitter_proc.go b/pkg/gosqlx/splitter_proc.go index 4cb800dc..7111335c 100644 --- a/pkg/gosqlx/splitter_proc.go +++ b/pkg/gosqlx/splitter_proc.go @@ -384,6 +384,12 @@ func peekWord(src string, j int) (w string, c byte, end int) { return "", 0, j } +// commentsOnly reports whether seg holds nothing but whitespace and comments. +func commentsOnly(seg string) bool { + _, c, end := peekWord(seg, 0) + return c == 0 && end >= len(seg) +} + // isWordByte reports whether c can be part of an identifier-like word. func isWordByte(c byte) bool { return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || diff --git a/pkg/gosqlx/splitter_proc_test.go b/pkg/gosqlx/splitter_proc_test.go index 28c9d1c7..aa0186e9 100644 --- a/pkg/gosqlx/splitter_proc_test.go +++ b/pkg/gosqlx/splitter_proc_test.go @@ -172,3 +172,22 @@ func TestSplitStatements_ProceduralNoAlloc(t *testing.T) { } } } + +func TestCommentsOnly(t *testing.T) { + for in, want := range map[string]bool{ + "": true, + " \n\t": true, + "-- c": true, + "-- c\n/* d */ -- e\n": true, + "/* unterminated": true, + "SELECT 1": false, + "-- c\nSELECT 1": false, + "/* c */ SELECT 1": false, + "/* c */ ; -- d": false, + "(": false, + } { + if got := commentsOnly(in); got != want { + t.Errorf("commentsOnly(%q) = %v, want %v", in, got, want) + } + } +} From 145483e06a2cd2dea2d637198988cba9f426cd19 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:30:21 +0530 Subject: [PATCH 31/95] fix(cli): exit non-zero on syntax errors and fix flag, dialect and I/O handling - lint reports tokenizer and parser errors and exits 1 for them; --auto-fix judges the fixed text instead of the pre-fix report. - format --no-uppercase shared the --uppercase variable, so it set uppercase to true; it now lower-cases keywords, and giving both flags is an error. - format keeps comments and optimizer hints (JoinWithComments), refuses input whose hints cannot be placed, verifies that the output holds exactly the input's comments, and preserves a leading UTF-8 byte order mark. - One alias table (postgres, pg, mssql, tsql, sqlite3, sql-server) is applied by validate, format, lint, parse, analyze, highlight and transpile. - Errors are printed once, by main; usage is printed for flag and argument errors but not for failures of the work. validate --quiet exits 1 silently. - validate reads stdin in memory and names it instead of a temp path. A file that fails as a whole is re-checked statement by statement, so GO and DELIMITER scripts validate and files past the token limit work. - Empty, whitespace-only and comment-only input is valid for validate, lint and format; parse and analyze say there is no statement. - The global -f/-o flags are honoured (parse and transpile -o, validate -f json / -o) or rejected where unsupported; format -o with several inputs is rejected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/analyze.go | 2 +- cmd/gosqlx/cmd/analyzer.go | 5 +- cmd/gosqlx/cmd/cli_e2e_test.go | 405 ++++++++++++++++++++++++++++++++ cmd/gosqlx/cmd/cmd_utils.go | 20 +- cmd/gosqlx/cmd/dialect_alias.go | 62 +++++ cmd/gosqlx/cmd/format.go | 33 ++- cmd/gosqlx/cmd/formatter.go | 73 +++++- cmd/gosqlx/cmd/global_flags.go | 69 ++++++ cmd/gosqlx/cmd/highlight.go | 2 +- cmd/gosqlx/cmd/lint.go | 107 ++++++--- cmd/gosqlx/cmd/parse.go | 18 +- cmd/gosqlx/cmd/parser_cmd.go | 5 + cmd/gosqlx/cmd/root.go | 24 ++ cmd/gosqlx/cmd/sql_formatter.go | 42 +++- cmd/gosqlx/cmd/transpile.go | 17 +- cmd/gosqlx/cmd/validate.go | 101 +++++--- cmd/gosqlx/cmd/validator.go | 133 +++++++++-- cmd/gosqlx/main.go | 4 +- 18 files changed, 994 insertions(+), 128 deletions(-) create mode 100644 cmd/gosqlx/cmd/cli_e2e_test.go create mode 100644 cmd/gosqlx/cmd/dialect_alias.go create mode 100644 cmd/gosqlx/cmd/global_flags.go diff --git a/cmd/gosqlx/cmd/analyze.go b/cmd/gosqlx/cmd/analyze.go index 14452628..d0e455a7 100644 --- a/cmd/gosqlx/cmd/analyze.go +++ b/cmd/gosqlx/cmd/analyze.go @@ -67,7 +67,7 @@ This is a basic implementation for CLI foundation.`, func analyzeRun(cmd *cobra.Command, args []string) error { // Reject unknown dialect names early before any parsing. - if err := validateDialectName(analyzeDialect); err != nil { + if err := normalizeDialectFlag(&analyzeDialect); err != nil { return err } diff --git a/cmd/gosqlx/cmd/analyzer.go b/cmd/gosqlx/cmd/analyzer.go index a9a729e7..c9ea47bf 100644 --- a/cmd/gosqlx/cmd/analyzer.go +++ b/cmd/gosqlx/cmd/analyzer.go @@ -85,7 +85,10 @@ func (a *Analyzer) Analyze(input string) (*AnalyzerResult, error) { return result, result.Error } - // Convert TokenWithSpan to Token using centralized converter + if !hasStatementTokens(tokens) { + result.Error = errNoStatements + return result, result.Error + } // Parse with proper error handling for memory management p := parserForDialect(a.Opts.Dialect) diff --git a/cmd/gosqlx/cmd/cli_e2e_test.go b/cmd/gosqlx/cmd/cli_e2e_test.go new file mode 100644 index 00000000..ecafa3bd --- /dev/null +++ b/cmd/gosqlx/cmd/cli_e2e_test.go @@ -0,0 +1,405 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// resetCLIFlags returns every flag of every command to its default. Flag +// values live in package variables that survive from one rootCmd.Execute to +// the next, so each run starts from a clean slate. +func resetCLIFlags() { + var walk func(c *cobra.Command) + reset := func(f *pflag.Flag) { + if sv, ok := f.Value.(pflag.SliceValue); ok { + _ = sv.Replace(nil) + } else { + _ = f.Value.Set(f.DefValue) + } + f.Changed = false + } + walk = func(c *cobra.Command) { + c.SilenceUsage = false + c.Flags().VisitAll(reset) + c.PersistentFlags().VisitAll(reset) + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(rootCmd) +} + +// cliResult is what one in-process run of the gosqlx command line produced. +type cliResult struct { + stdout, stderr string + err error +} + +// runCLI runs the command line in process with stdin as standard input. +// stdin is only wired up when non-nil. +func runCLI(t *testing.T, stdin *string, args ...string) cliResult { + t.Helper() + resetCLIFlags() + t.Cleanup(resetCLIFlags) + + if stdin != nil { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + old := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = old; _ = r.Close() }) + go func() { + _, _ = w.WriteString(*stdin) + _ = w.Close() + }() + } + + var out, errOut bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&errOut) + rootCmd.SetArgs(args) + err := rootCmd.Execute() + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + return cliResult{stdout: out.String(), stderr: errOut.String(), err: err} +} + +func str(s string) *string { return &s } + +func writeTemp(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestCLI_LintExitsNonZeroOnSyntaxErrors(t *testing.T) { + bad := writeTemp(t, "bad.sql", "SELEC FROM\n") + good := writeTemp(t, "good.sql", "SELECT id FROM users WHERE id = 1;\n") + + tests := []struct { + name string + stdin *string + args []string + wantErr bool + }{ + {"stdin syntax error", str("SELEC FROM"), []string{"lint"}, true}, + {"stdin valid", str("SELECT id FROM users"), []string{"lint"}, false}, + {"file syntax error", nil, []string{"lint", bad}, true}, + {"file valid", nil, []string{"lint", good}, false}, + {"inline syntax error", nil, []string{"lint", "SELECT FROM WHERE ("}, true}, + {"inline valid", nil, []string{"lint", "SELECT id FROM users"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := runCLI(t, tt.stdin, tt.args...) + if (res.err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v\nstdout: %s", res.err, tt.wantErr, res.stdout) + } + if tt.wantErr && strings.Contains(res.stdout, "No violations found") { + t.Fatalf("invalid SQL reported as clean:\n%s", res.stdout) + } + }) + } +} + +func TestCLI_LintAutoFixJudgesTheFixedText(t *testing.T) { + path := writeTemp(t, "ws.sql", "SELECT id FROM users \n") + res := runCLI(t, nil, "lint", "--auto-fix", path) + if res.err != nil { + t.Fatalf("lint --auto-fix left %v after fixing everything\n%s", res.err, res.stdout) + } + if !strings.Contains(res.stdout, "0 violation(s) remain after auto-fix") { + t.Fatalf("missing remaining-violation count:\n%s", res.stdout) + } + got, _ := os.ReadFile(path) + if strings.Contains(string(got), " \n") { + t.Fatalf("file not fixed: %q", got) + } + + res = runCLI(t, str("SELECT id FROM users \n"), "lint", "--auto-fix") + if res.err != nil { + t.Fatalf("stdin lint --auto-fix: %v\n%s", res.err, res.stdout) + } +} + +func TestCLI_NoStatementsIsNotAnError(t *testing.T) { + for _, content := range []string{"", " \n", "-- just a comment\n", "/* a */ -- b\n"} { + path := writeTemp(t, "empty.sql", content) + for _, cmd := range []string{"validate", "lint", "format"} { + if cmd == "lint" && strings.TrimSpace(content) == "" && content != "" { + continue // whitespace-only text legitimately breaks the whitespace rules + } + res := runCLI(t, nil, cmd, path) + if res.err != nil { + t.Errorf("%s %q: %v\n%s%s", cmd, content, res.err, res.stdout, res.stderr) + } + } + } + // Comment-only stdin is the same: valid, and format echoes it. + res := runCLI(t, str("-- keep me\n"), "format") + if res.err != nil || strings.TrimSpace(res.stdout) != "-- keep me" { + t.Fatalf("format of a comment-only stdin = %q, %v", res.stdout, res.err) + } + if res = runCLI(t, str("-- keep me\n"), "validate"); res.err != nil { + t.Fatalf("validate of a comment-only stdin: %v", res.err) + } + // parse and analyze need a statement to show; they say so. + path := writeTemp(t, "c.sql", "-- only a comment\n") + for _, cmd := range []string{"parse", "analyze"} { + res := runCLI(t, nil, cmd, path) + if res.err == nil || !strings.Contains(res.err.Error(), "no SQL statements") { + t.Errorf("%s of a comment-only file: err = %v, want a 'no SQL statements' error", cmd, res.err) + } + } +} + +func TestCLI_DialectAliases(t *testing.T) { + for _, alias := range []string{"postgres", "pg", "mssql", "tsql", "sqlite3", "postgresql", "sqlserver"} { + for _, cmd := range []string{"validate", "format", "lint", "parse", "analyze"} { + t.Run(cmd+"/"+alias, func(t *testing.T) { + res := runCLI(t, nil, cmd, "--dialect", alias, "SELECT id FROM users") + if res.err != nil && strings.Contains(res.err.Error(), "unknown SQL dialect") { + t.Fatalf("--dialect %s rejected: %v", alias, res.err) + } + }) + } + } + res := runCLI(t, nil, "validate", "--dialect", "nosuchsql", "SELECT 1") + if res.err == nil || !strings.Contains(res.err.Error(), "unknown SQL dialect") { + t.Fatalf("unknown dialect: err = %v", res.err) + } + // Case handling for canonical names is unchanged. + if res = runCLI(t, nil, "validate", "--dialect", "MySQL", "SELECT 1"); res.err == nil { + t.Fatal(`--dialect MySQL accepted; names are case-sensitive`) + } +} + +func TestCLI_FormatNoUppercase(t *testing.T) { + res := runCLI(t, str("SELECT a FROM t"), "format", "--no-uppercase") + if res.err != nil { + t.Fatal(res.err) + } + if got := strings.ToLower(res.stdout); got != res.stdout { + t.Fatalf("--no-uppercase output has upper-case keywords: %q", res.stdout) + } + res = runCLI(t, str("select a from t"), "format") + if !strings.Contains(res.stdout, "SELECT") { + t.Fatalf("default output is not upper case: %q", res.stdout) + } + res = runCLI(t, str("select a from t"), "format", "--uppercase", "--no-uppercase") + if res.err == nil { + t.Fatal("--uppercase together with --no-uppercase was accepted") + } +} + +func TestCLI_FormatKeepsCommentsAndHints(t *testing.T) { + res := runCLI(t, str("SELECT /*+ hint */ a FROM t -- note\n"), "format") + if res.err != nil { + t.Fatal(res.err) + } + for _, want := range []string{"SELECT /*+ hint */ a", "-- note"} { + if !strings.Contains(res.stdout, want) { + t.Fatalf("output %q lost %q", res.stdout, want) + } + } + + // A hint that cannot be put back refuses the file and leaves it alone. + original := "SELECT * FROM (SELECT /*+ FULL(t) */ a FROM t) s -- keep\n" + path := writeTemp(t, "hint.sql", original) + res = runCLI(t, nil, "format", "-i", path) + if res.err == nil { + t.Fatalf("format -i accepted a hint it cannot place\n%s%s", res.stdout, res.stderr) + } + if got, _ := os.ReadFile(path); string(got) != original { + t.Fatalf("file was rewritten despite the refusal: %q", got) + } +} + +func TestCLI_FormatInPlaceNeverDropsComments(t *testing.T) { + original := "-- header\nselect a, /* inline */ b from t; -- after\n-- next\nselect 2\n-- tail\n" + path := writeTemp(t, "c.sql", original) + res := runCLI(t, nil, "format", "-i", path) + if res.err != nil { + t.Fatalf("%v\n%s%s", res.err, res.stdout, res.stderr) + } + got, _ := os.ReadFile(path) + for _, c := range []string{"-- header", "/* inline */", "-- after", "-- next", "-- tail"} { + if !strings.Contains(string(got), c) { + t.Errorf("formatted file lost %q:\n%s", c, got) + } + } +} + +func TestCLI_FormatKeepsByteOrderMark(t *testing.T) { + path := writeTemp(t, "bom.sql", "\uFEFFselect a from t\n") + if res := runCLI(t, nil, "format", "-i", path); res.err != nil { + t.Fatal(res.err) + } + got, _ := os.ReadFile(path) + if !strings.HasPrefix(string(got), "\uFEFFSELECT") { + t.Fatalf("byte order mark lost or output wrong: %q", got) + } + if res := runCLI(t, nil, "validate", path); res.err != nil { + t.Fatalf("validate of a BOM file: %v", res.err) + } +} + +func TestCLI_ValidateStdinIsNamedStdin(t *testing.T) { + res := runCLI(t, str("SELECT 1"), "validate") + if res.err != nil { + t.Fatal(res.err) + } + if !strings.Contains(res.stdout, "") || strings.Contains(res.stdout, os.TempDir()) { + t.Fatalf("stdin result is not labelled :\n%s", res.stdout) + } + res = runCLI(t, str("SELEC"), "validate") + if res.err == nil || !strings.Contains(res.stderr+res.stdout, "") { + t.Fatalf("invalid stdin not reported under : err=%v\n%s%s", res.err, res.stdout, res.stderr) + } +} + +func TestCLI_ErrorsAreNotPrintedByCobraAndUsageIsSilent(t *testing.T) { + res := runCLI(t, str("SELEC"), "validate") + if res.err == nil { + t.Fatal("expected a validation failure") + } + if strings.Contains(res.stderr, "Usage:") || strings.Contains(res.stdout, "Usage:") { + t.Fatalf("usage printed for a syntax error:\n%s%s", res.stdout, res.stderr) + } + if strings.Contains(res.stderr, "Error:") { + t.Fatalf("cobra printed the error that main prints:\n%s", res.stderr) + } + // Argument errors still show usage. + res = runCLI(t, nil, "parse", "a", "b") + if res.err == nil || !strings.Contains(res.stderr+res.stdout, "Usage:") { + t.Fatalf("argument error without usage: err=%v\n%s%s", res.err, res.stdout, res.stderr) + } +} + +func TestCLI_ValidateQuietFailsSilently(t *testing.T) { + res := runCLI(t, str("SELEC"), "validate", "--quiet") + if res.err == nil || !IsSilentError(res.err) { + t.Fatalf("quiet validation failure: err = %v, want a silent error", res.err) + } + if res.stdout != "" || res.stderr != "" { + t.Fatalf("quiet mode printed output: %q %q", res.stdout, res.stderr) + } + // Non-validation errors are still reported. + res = runCLI(t, nil, "validate", "--quiet", "--dialect", "bogus", "SELECT 1") + if res.err == nil || IsSilentError(res.err) { + t.Fatalf("bad dialect under --quiet: err = %v, want a visible error", res.err) + } +} + +func TestCLI_ValidateAcceptsBatchScripts(t *testing.T) { + tsql := writeTemp(t, "batches.sql", "CREATE TABLE a (id INT)\nGO\nSELECT id FROM a\nGO\n") + res := runCLI(t, nil, "validate", "--dialect", "sqlserver", tsql) + if res.err != nil { + t.Fatalf("T-SQL GO script: %v\n%s%s", res.err, res.stdout, res.stderr) + } + // Without the dialect, GO is not a separator and the file is invalid. + if res = runCLI(t, nil, "validate", "--dialect", "postgresql", tsql); res.err == nil { + t.Fatal("GO script accepted for postgresql") + } + + mysql := writeTemp(t, "delim.sql", "DELIMITER //\nSELECT 1//\nSELECT 2//\nDELIMITER ;\nSELECT 3;\n") + if res = runCLI(t, nil, "validate", "--dialect", "mysql", mysql); res.err != nil { + t.Fatalf("DELIMITER script: %v\n%s%s", res.err, res.stdout, res.stderr) + } + + // A genuinely broken statement is still reported, with the whole-file + // position. + broken := writeTemp(t, "broken.sql", "SELECT 1;\nSELEC 2;\n") + res = runCLI(t, nil, "validate", broken) + if res.err == nil || !strings.Contains(res.stderr+res.stdout, "line 2") { + t.Fatalf("broken file: err=%v\n%s%s", res.err, res.stdout, res.stderr) + } +} + +func TestCLI_GlobalFlagsAreHonouredOrRejected(t *testing.T) { + // -f is rejected where there is only one output format. + for _, args := range [][]string{ + {"lint", "-f", "json", "SELECT 1"}, + {"format", "-f", "json", "SELECT 1"}, + {"transpile", "-f", "yaml", "SELECT 1"}, + } { + res := runCLI(t, nil, args...) + if res.err == nil || !strings.Contains(res.err.Error(), "-f/--format") { + t.Errorf("%v: err = %v, want a -f/--format rejection", args, res.err) + } + } + // "-f auto" is the default and is fine. + if res := runCLI(t, nil, "format", "-f", "auto", "SELECT 1"); res.err != nil { + t.Errorf("format -f auto: %v", res.err) + } + + // -o is honoured by parse and transpile. + dir := t.TempDir() + out := filepath.Join(dir, "ast.txt") + if res := runCLI(t, nil, "parse", "-o", out, "SELECT 1"); res.err != nil { + t.Fatal(res.err) + } else if res.stdout != "" { + t.Fatalf("parse -o still wrote to stdout: %q", res.stdout) + } + if b, err := os.ReadFile(out); err != nil || len(b) == 0 { + t.Fatalf("parse -o produced no file: %v", err) + } + out = filepath.Join(dir, "t.sql") + if res := runCLI(t, nil, "transpile", "--from", "mysql", "--to", "postgres", "-o", out, "SELECT 1"); res.err != nil { + t.Fatal(res.err) + } + if b, err := os.ReadFile(out); err != nil || !strings.Contains(string(b), "SELECT 1") { + t.Fatalf("transpile -o file = %q, %v", b, err) + } + + // format -o with several inputs would overwrite the file repeatedly. + a := writeTemp(t, "a.sql", "SELECT 1") + b := writeTemp(t, "b.sql", "SELECT 2") + if res := runCLI(t, nil, "format", "-o", filepath.Join(dir, "o.sql"), a, b); res.err == nil { + t.Fatal("format -o with two inputs accepted") + } + + // validate maps -f json / -o onto its own output flags. + res := runCLI(t, nil, "validate", "-f", "json", "SELECT 1") + if res.err != nil || !strings.Contains(res.stdout, "{") { + t.Fatalf("validate -f json: %v\n%s", res.err, res.stdout) + } + if res = runCLI(t, nil, "validate", "-f", "yaml", "SELECT 1"); res.err == nil { + t.Fatal("validate -f yaml accepted") + } + jsonOut := filepath.Join(dir, "v.json") + if res = runCLI(t, nil, "validate", "-f", "json", "-o", jsonOut, "SELECT 1"); res.err != nil { + t.Fatalf("validate -f json -o: %v", res.err) + } + if b, err := os.ReadFile(jsonOut); err != nil || !strings.Contains(string(b), "{") { + t.Fatalf("validate -o file = %q, %v", b, err) + } + if res = runCLI(t, nil, "validate", "-o", filepath.Join(dir, "x"), "SELECT 1"); res.err == nil { + t.Fatal("validate -o without a machine-readable format accepted") + } +} diff --git a/cmd/gosqlx/cmd/cmd_utils.go b/cmd/gosqlx/cmd/cmd_utils.go index d8383be8..d07b205a 100644 --- a/cmd/gosqlx/cmd/cmd_utils.go +++ b/cmd/gosqlx/cmd/cmd_utils.go @@ -15,12 +15,14 @@ package cmd import ( + "errors" "fmt" "strings" "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" @@ -57,7 +59,7 @@ func trackChangedFlags(cmd *cobra.Command) map[string]bool { // Names are matched case-sensitively, as keywords.IsValidDialect does. func validateDialectName(dialect string) error { if dialect != "" && !keywords.IsValidDialect(dialect) { - return fmt.Errorf("unknown SQL dialect %q; valid dialects: %s", dialect, validDialectsText()) + return fmt.Errorf("unknown SQL dialect %q; valid dialects: %s (aliases: %s)", dialect, validDialectsText(), dialectAliasText()) } return nil } @@ -105,3 +107,19 @@ func parserForDialect(dialect string) *parser.Parser { } return parser.NewParser(parser.WithDialect(dialect)) } + +// errNoStatements is returned by commands that need a statement to work on +// (parse, analyze) when the input is empty, whitespace or only comments. +var errNoStatements = errors.New("no SQL statements found (input is empty or contains only comments)") + +// hasStatementTokens reports whether tokens holds anything besides the +// end-of-input marker. Empty, whitespace-only and comment-only input has no +// statements; every command treats it the same way. +func hasStatementTokens(tokens []models.TokenWithSpan) bool { + for i := range tokens { + if tokens[i].Token.Type != models.TokenTypeEOF { + return true + } + } + return false +} diff --git a/cmd/gosqlx/cmd/dialect_alias.go b/cmd/gosqlx/cmd/dialect_alias.go new file mode 100644 index 00000000..9c9f9062 --- /dev/null +++ b/cmd/gosqlx/cmd/dialect_alias.go @@ -0,0 +1,62 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "sort" + "strings" +) + +// dialectAliases maps the alternative names users commonly type to the +// canonical dialect names of keywords.DialectNames. Matching is exact +// (lower case), like the canonical names. +var dialectAliases = map[string]string{ + "postgres": "postgresql", + "pg": "postgresql", + "mssql": "sqlserver", + "tsql": "sqlserver", + "sql-server": "sqlserver", + "sqlite3": "sqlite", +} + +// canonicalDialect returns the canonical dialect name for name: aliases are +// resolved, anything else is returned unchanged for validateDialectName to +// accept or reject. +func canonicalDialect(name string) string { + if canon, ok := dialectAliases[name]; ok { + return canon + } + return name +} + +// normalizeDialectFlag resolves aliases in the dialect flag value *name in +// place and rejects unknown dialects. Every command that takes --dialect +// calls it first, so "postgres", "pg", "mssql" and "tsql" work everywhere, +// not only in transpile. +func normalizeDialectFlag(name *string) error { + *name = canonicalDialect(*name) + return validateDialectName(*name) +} + +// dialectAliasText lists the accepted aliases for error messages, e.g. +// "mssql, pg, postgres, sql-server, sqlite3, tsql". +func dialectAliasText() string { + names := make([]string, 0, len(dialectAliases)) + for alias := range dialectAliases { + names = append(names, alias) + } + sort.Strings(names) + return strings.Join(names, ", ") +} diff --git a/cmd/gosqlx/cmd/format.go b/cmd/gosqlx/cmd/format.go index c394a081..ac6246e7 100644 --- a/cmd/gosqlx/cmd/format.go +++ b/cmd/gosqlx/cmd/format.go @@ -32,6 +32,8 @@ var ( formatCheck bool formatMaxLine int formatDialect string + + formatNoUppercase bool ) // formatCmd represents the format command @@ -44,7 +46,7 @@ Examples: gosqlx format query.sql # Format to stdout gosqlx format -i query.sql # Format in-place gosqlx format --indent 4 query.sql # Use 4-space indentation - gosqlx format --no-uppercase query.sql # Keep original keyword case + gosqlx format --no-uppercase query.sql # Write keywords in lower case gosqlx format --compact query.sql # Compact format (minimal whitespace) gosqlx format --check query.sql # Check if formatting is needed (CI mode) gosqlx format "*.sql" # Format all SQL files @@ -64,9 +66,19 @@ Performance: 100x faster than SQLFluff for equivalent operations`, func formatRun(cmd *cobra.Command, args []string) error { // Reject unknown dialect names early before any parsing. - if err := validateDialectName(formatDialect); err != nil { + if err := normalizeDialectFlag(&formatDialect); err != nil { + return err + } + + if err := rejectGlobalFormat(cmd); err != nil { return err } + if err := resolveUppercaseFlags(cmd); err != nil { + return err + } + if outputFile != "" && len(args) > 1 { + return fmt.Errorf("-o/--output writes one file but %d inputs were given; format them one at a time or use -i", len(args)) + } // Handle stdin input if ShouldReadFromStdin(args) { @@ -251,6 +263,19 @@ func init() { formatCmd.Flags().IntVar(&formatMaxLine, "max-line", 80, "maximum line length (config: format.max_line_length)") formatCmd.Flags().StringVar(&formatDialect, "dialect", "", dialectFlagUsage("default: postgresql")) - // Add negation flags - formatCmd.Flags().BoolVar(&formatUppercase, "no-uppercase", false, "keep original keyword case") + // Negation flag. It has its own variable: sharing formatUppercase made + // --no-uppercase set uppercase to true. + formatCmd.Flags().BoolVar(&formatNoUppercase, "no-uppercase", false, "write keywords in lower case (opposite of --uppercase)") +} + +// resolveUppercaseFlags folds --no-uppercase into formatUppercase. +func resolveUppercaseFlags(cmd *cobra.Command) error { + if !formatNoUppercase { + return nil + } + if flagSet(cmd, "uppercase") && formatUppercase { + return fmt.Errorf("--uppercase and --no-uppercase cannot be used together") + } + formatUppercase = false + return nil } diff --git a/cmd/gosqlx/cmd/formatter.go b/cmd/gosqlx/cmd/formatter.go index 788f3fc6..c6b22ba0 100644 --- a/cmd/gosqlx/cmd/formatter.go +++ b/cmd/gosqlx/cmd/formatter.go @@ -208,8 +208,25 @@ func (f *Formatter) formatFile(filename string) FileFormatterResult { return result } -// formatSQL formats a SQL string +// utf8BOM is the byte order mark some editors put at the start of a file. +const utf8BOM = "\uFEFF" + +// formatSQL formats a SQL string. A leading UTF-8 byte order mark is not SQL: +// it is set aside and written back in front of the result, so formatting a +// file in place does not strip it. func (f *Formatter) formatSQL(sql string) (string, error) { + if strings.HasPrefix(sql, utf8BOM) { + out, err := f.formatSQLText(strings.TrimPrefix(sql, utf8BOM)) + if err != nil { + return "", err + } + return utf8BOM + out, nil + } + return f.formatSQLText(sql) +} + +// formatSQLText formats SQL text without a byte order mark. +func (f *Formatter) formatSQLText(sql string) (string, error) { // Acquire a tokenizer for the configured dialect (pooled for the default) tkz, releaseTkz, err := tokenizerForDialect(f.Opts.Dialect) if err != nil { @@ -226,8 +243,12 @@ func (f *Formatter) formatSQL(sql string) (string, error) { if len(tokens) == 0 { return "", nil } - - // Convert tokens for parser using centralized converter + if !hasStatementTokens(tokens) { + // Whitespace or comments only: there is nothing to format, and the + // text is returned untouched rather than reported as a syntax error. + return sql, nil + } + comments := tkz.Comments // Parse to AST with proper error handling for memory management p := parserForDialect(f.Opts.Dialect) @@ -256,11 +277,18 @@ func (f *Formatter) formatSQL(sql string) (string, error) { Dialect: f.Opts.Dialect, } - // Create formatter and format the AST + // Create formatter and format the AST. Comments are put back around the + // statements they belonged to; one that cannot be put back (an optimizer + // hint away from its statement's first keyword) refuses the whole file. formatter := NewSQLFormatter(formatterOpts) - formatted, err := formatter.Format(parsedAST) + var formatted string + if len(comments) == 0 { + formatted, err = formatter.Format(parsedAST) + } else { + formatted, err = formatter.FormatWithComments(parsedAST, tokens, comments) + } if err != nil { - return "", fmt.Errorf("render: %w", err) + return "", fmt.Errorf("render: %w (input left unchanged)", err) } // Refuse output that fails verifyFormattedSQL, so format, format -i and @@ -283,10 +311,43 @@ func (f *Formatter) formatSQL(sql string) (string, error) { if err := verifyFormattedSQL(parsedAST, tokens, verifyText, f.Opts.Dialect); err != nil { return "", err } + // Comments are not part of the AST, so verify them on the real output. + if err := verifyComments(comments, formatted, f.Opts.Dialect); err != nil { + return "", err + } return formatted, nil } +// verifyComments requires the formatted output to contain exactly the +// comments of the input, so format -i can never delete or invent one. +func verifyComments(input []models.Comment, formatted, dialect string) error { + tkz, release, err := tokenizerForDialect(dialect) + if err != nil { + return fmt.Errorf("tokenizer setup failed: %w", err) + } + defer release() + if _, err := tkz.TokenizeString(formatted); err != nil { + return fmt.Errorf("formatted output does not re-tokenize (formatter bug; input left unchanged): %w", err) + } + pool := make(map[string]int, len(input)) + for _, c := range tkz.Comments { + pool[c.Text]++ + } + for _, c := range input { + if pool[c.Text] == 0 { + return fmt.Errorf("formatted output loses the comment %q (formatter bug; input left unchanged)", c.Text) + } + pool[c.Text]-- + } + for text, n := range pool { + if n > 0 { + return fmt.Errorf("formatted output adds the comment %q (formatter bug; input left unchanged)", text) + } + } + return nil +} + // verifyFormattedSQL re-parses formatted with the same dialect and checks // that it yields the same number of statements, the same AST.SQL(), the same // canonical pkg/formatter rendering of each statement, and the same string diff --git a/cmd/gosqlx/cmd/global_flags.go b/cmd/gosqlx/cmd/global_flags.go new file mode 100644 index 00000000..915109e1 --- /dev/null +++ b/cmd/gosqlx/cmd/global_flags.go @@ -0,0 +1,69 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "fmt" + "io" + + "github.com/spf13/cobra" +) + +// The global -f/--format and -o/--output flags are inherited by every +// command. A command either honours a flag or rejects it when the user sets +// it; silently ignoring a flag the user typed is never an option. + +// flagSet reports whether the user explicitly passed the named flag. +func flagSet(cmd *cobra.Command, name string) bool { + f := cmd.Flags().Lookup(name) + return f != nil && f.Changed +} + +// formatSet reports whether -f/--format asks for something other than the +// default "auto". The value, not the flag's Changed state, is what matters: +// "-f auto" is the default spelled out. +func formatSet() bool { return format != "" && format != "auto" } + +// rejectGlobalFormat fails when -f/--format was set to anything but "auto" on +// a command that has only one output format. +func rejectGlobalFormat(cmd *cobra.Command) error { + if formatSet() { + return fmt.Errorf("%s does not support -f/--format (got %q)", cmd.CommandPath(), format) + } + return nil +} + +// rejectGlobalOutput fails when -o/--output was set on a command that only +// writes to stdout. +func rejectGlobalOutput(cmd *cobra.Command) error { + if outputFile != "" { + return fmt.Errorf("%s does not support -o/--output", cmd.CommandPath()) + } + return nil +} + +// outputSink returns the writer a command should print its result to and a +// finish function to call once it has printed. With -o the result is buffered +// and written to the file by finish; otherwise it goes straight to stdout. +func outputSink(cmd *cobra.Command) (io.Writer, func() error) { + if outputFile == "" { + return cmd.OutOrStdout(), func() error { return nil } + } + var buf bytes.Buffer + return &buf, func() error { + return WriteOutput(buf.Bytes(), outputFile, cmd.OutOrStdout()) + } +} diff --git a/cmd/gosqlx/cmd/highlight.go b/cmd/gosqlx/cmd/highlight.go index 932dd29c..e0406ca5 100644 --- a/cmd/gosqlx/cmd/highlight.go +++ b/cmd/gosqlx/cmd/highlight.go @@ -146,7 +146,7 @@ func newHighlightConfig(format, theme, dialect, color string, standalone bool) ( if !ok { return highlightConfig{}, fmt.Errorf("unknown theme %q; valid themes: %s", theme, strings.Join(highlight.ThemeNames(), ", ")) } - if err := validateDialectName(dialect); err != nil { + if err := normalizeDialectFlag(&dialect); err != nil { return highlightConfig{}, err } color = strings.ToLower(strings.TrimSpace(color)) diff --git a/cmd/gosqlx/cmd/lint.go b/cmd/gosqlx/cmd/lint.go index 49c7ff3b..e037645a 100644 --- a/cmd/gosqlx/cmd/lint.go +++ b/cmd/gosqlx/cmd/lint.go @@ -75,7 +75,11 @@ Pipeline/Stdin Examples: Exit Codes: 0 - No violations found - 1 - Errors or warnings found (warnings only if --fail-on-warn is set)`, + 1 - Violations found, or the SQL has a syntax error. With --auto-fix the + result is judged after the fixes are applied. + +Input with no statements (empty, whitespace or only comments) is not a syntax +error.`, Args: cobra.MinimumNArgs(0), RunE: lintRun, } @@ -84,7 +88,10 @@ func lintRun(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Reject unknown dialect names early before any parsing. - if err := validateDialectName(lintDialect); err != nil { + if err := normalizeDialectFlag(&lintDialect); err != nil { + return err + } + if err := rejectGlobalFormat(cmd); err != nil { return err } @@ -139,7 +146,8 @@ func lintRun(cmd *cobra.Command, args []string) error { fmt.Fprintln(outWriter, "\nApplying auto-fixes...") fixCount := 0 - for _, fileResult := range result.Files { + fixedIdx := make([]int, 0, len(result.Files)) + for idx, fileResult := range result.Files { if len(fileResult.Violations) == 0 { continue } @@ -185,10 +193,23 @@ func lintRun(cmd *cobra.Command, args []string) error { continue } fixCount++ + fixedIdx = append(fixedIdx, idx) fmt.Fprintf(outWriter, "Fixed: %s\n", fileResult.Filename) } } + // Judge the run by what is left after the fixes, not by the report + // printed before them. + if len(fixedIdx) > 0 { + for _, idx := range fixedIdx { + before := result.Files[idx] + after := l.LintFile(before.Filename) + result.TotalViolations += len(after.Violations) - len(before.Violations) + result.Files[idx] = after + } + fmt.Fprintf(outWriter, "\n%d violation(s) remain after auto-fix\n", result.TotalViolations) + } + fmt.Fprintf(outWriter, "\nAuto-fixed %d file(s)\n", fixCount) } @@ -219,9 +240,12 @@ func lintRun(cmd *cobra.Command, args []string) error { warningCount := 0 infoCount := 0 fileErrorCount := 0 + syntaxErrorCount := 0 for _, fileResult := range result.Files { if fileResult.Error != nil { fileErrorCount++ + } else if fileResult.SyntaxError() != nil { + syntaxErrorCount++ } for _, violation := range fileResult.Violations { switch violation.Severity { @@ -240,6 +264,9 @@ func lintRun(cmd *cobra.Command, args []string) error { } totalViolations := errorCount + warningCount + infoCount + if syntaxErrorCount > 0 { + return fmt.Errorf("%d file(s) contain SQL syntax errors (and %d violation(s))", syntaxErrorCount, totalViolations) + } if totalViolations > 0 { return fmt.Errorf("found %d violation(s): %d error(s), %d warning(s), %d info(s)", totalViolations, errorCount, warningCount, infoCount) @@ -281,13 +308,22 @@ func lintFromStdin(cmd *cobra.Command) error { // Display results fmt.Fprintf(outWriter, "Linting stdin input:\n\n") + synErr := result.SyntaxError() + if synErr != nil { + fmt.Fprintf(outWriter, "Syntax error: %v\n\n", synErr) + } + if len(result.Violations) == 0 { - fmt.Fprintln(outWriter, "No violations found.") + if synErr == nil { + fmt.Fprintln(outWriter, "No violations found.") + } // Write to file if specified if outputFile != "" { - return WriteOutput(outputBuf.Bytes(), outputFile, cmd.OutOrStdout()) + if err := WriteOutput(outputBuf.Bytes(), outputFile, cmd.OutOrStdout()); err != nil { + return err + } } - return nil + return lintOutcomeError(result.Violations, synErr) } fmt.Fprintf(outWriter, "Found %d violation(s):\n\n", len(result.Violations)) @@ -295,7 +331,8 @@ func lintFromStdin(cmd *cobra.Command) error { fmt.Fprintf(outWriter, "%d. %s\n", i+1, linter.FormatViolation(violation)) } - // Apply auto-fix if requested + // Apply auto-fix if requested. The exit status then reflects what is left + // in the fixed text. if lintAutoFix { fmt.Fprintln(outWriter, "\nAuto-fixed output:") fixed := string(content) @@ -308,6 +345,11 @@ func lintFromStdin(cmd *cobra.Command) error { } } fmt.Fprintln(outWriter, fixed) + if fixed != string(content) { + result = l.LintString(fixed, "stdin") + synErr = result.SyntaxError() + fmt.Fprintf(outWriter, "\n%d violation(s) remain after auto-fix\n", len(result.Violations)) + } } // Write to file if specified @@ -317,23 +359,7 @@ func lintFromStdin(cmd *cobra.Command) error { } } - // Exit with error code if there were violations - errorCount := 0 - warningCount := 0 - for _, violation := range result.Violations { - switch violation.Severity { - case linter.SeverityError: - errorCount++ - case linter.SeverityWarning: - warningCount++ - } - } - - if errorCount > 0 || (lintFailOnWarn && warningCount > 0) { - return fmt.Errorf("%d error(s) and %d warning(s) found", errorCount, warningCount) - } - - return nil + return lintOutcomeError(result.Violations, synErr) } // lintInlineSQL lints inline SQL passed as a command argument @@ -347,17 +373,19 @@ func lintInlineSQL(cmd *cobra.Command, sql string) error { outWriter = &outputBuf } - if len(result.Violations) == 0 { - fmt.Fprintln(outWriter, "No violations found.") - if outputFile != "" { - return WriteOutput(outputBuf.Bytes(), outputFile, cmd.OutOrStdout()) - } - return nil + synErr := result.SyntaxError() + if synErr != nil { + fmt.Fprintf(outWriter, "Syntax error: %v\n\n", synErr) } - fmt.Fprintf(outWriter, "Found %d violation(s):\n\n", len(result.Violations)) - for i, violation := range result.Violations { - fmt.Fprintf(outWriter, "%d. %s\n", i+1, linter.FormatViolation(violation)) + switch { + case len(result.Violations) > 0: + fmt.Fprintf(outWriter, "Found %d violation(s):\n\n", len(result.Violations)) + for i, violation := range result.Violations { + fmt.Fprintf(outWriter, "%d. %s\n", i+1, linter.FormatViolation(violation)) + } + case synErr == nil: + fmt.Fprintln(outWriter, "No violations found.") } if outputFile != "" { @@ -366,9 +394,16 @@ func lintInlineSQL(cmd *cobra.Command, sql string) error { } } + return lintOutcomeError(result.Violations, synErr) +} + +// lintOutcomeError maps one lint run of stdin or inline SQL to the command's +// result: invalid SQL and error-severity violations always fail it, warnings +// only with --fail-on-warn. +func lintOutcomeError(violations []linter.Violation, synErr error) error { errorCount := 0 warningCount := 0 - for _, violation := range result.Violations { + for _, violation := range violations { switch violation.Severity { case linter.SeverityError: errorCount++ @@ -377,10 +412,12 @@ func lintInlineSQL(cmd *cobra.Command, sql string) error { } } + if synErr != nil { + return fmt.Errorf("invalid SQL: %w", synErr) + } if errorCount > 0 || (lintFailOnWarn && warningCount > 0) { return fmt.Errorf("%d error(s) and %d warning(s) found", errorCount, warningCount) } - return nil } diff --git a/cmd/gosqlx/cmd/parse.go b/cmd/gosqlx/cmd/parse.go index c32d74e8..ae2b3e32 100644 --- a/cmd/gosqlx/cmd/parse.go +++ b/cmd/gosqlx/cmd/parse.go @@ -60,7 +60,7 @@ Performance: Direct AST inspection without intermediate representations`, func parseRun(cmd *cobra.Command, args []string) error { // Reject unknown dialect names early before any parsing. - if err := validateDialectName(parseDialect); err != nil { + if err := normalizeDialectFlag(&parseDialect); err != nil { return err } @@ -101,7 +101,8 @@ func parseRun(cmd *cobra.Command, args []string) error { }) // Create parser with injectable output writers - parser := NewParser(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts) + out, finish := outputSink(cmd) + parser := NewParser(out, cmd.ErrOrStderr(), opts) // Run parsing result, err := parser.Parse(args[0]) @@ -115,7 +116,10 @@ func parseRun(cmd *cobra.Command, args []string) error { } // Display the result - return parser.Display(result) + if err := parser.Display(result); err != nil { + return err + } + return finish() } // parseFromStdin handles parsing from stdin input @@ -159,7 +163,8 @@ func parseFromStdin(cmd *cobra.Command) error { }) // Create parser - parser := NewParser(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts) + out, finish := outputSink(cmd) + parser := NewParser(out, cmd.ErrOrStderr(), opts) // Parse the stdin content (Parse accepts string input directly) result, err := parser.Parse(string(content)) @@ -173,7 +178,10 @@ func parseFromStdin(cmd *cobra.Command) error { } // Display the result - return parser.Display(result) + if err := parser.Display(result); err != nil { + return err + } + return finish() } func init() { diff --git a/cmd/gosqlx/cmd/parser_cmd.go b/cmd/gosqlx/cmd/parser_cmd.go index f0d9e3c4..16dbf445 100644 --- a/cmd/gosqlx/cmd/parser_cmd.go +++ b/cmd/gosqlx/cmd/parser_cmd.go @@ -94,6 +94,11 @@ func (p *Parser) Parse(input string) (*ParserResult, error) { return result, nil } + if !hasStatementTokens(tokens) { + result.Error = errNoStatements + return result, result.Error + } + // Parse to AST with proper error handling for memory management pr := parserForDialect(p.Opts.Dialect) defer pr.Release() diff --git a/cmd/gosqlx/cmd/root.go b/cmd/gosqlx/cmd/root.go index 11a08b3f..fd142440 100644 --- a/cmd/gosqlx/cmd/root.go +++ b/cmd/gosqlx/cmd/root.go @@ -15,6 +15,8 @@ package cmd import ( + "errors" + "github.com/spf13/cobra" "github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/actioncmd" @@ -175,6 +177,19 @@ func Execute() error { return rootCmd.Execute() } +// silentError is a failure whose exit status matters but whose message the +// user asked not to see (validate --quiet). +type silentError struct{ error } + +func (e silentError) Unwrap() error { return e.error } + +// IsSilentError reports whether err must be turned into an exit status +// without printing anything (the caller prints every other error once). +func IsSilentError(err error) bool { + var s silentError + return errors.As(err, &s) +} + // init initializes the root command and registers global flags. // // This function is called automatically during package initialization. @@ -194,6 +209,15 @@ func init() { // the library (and the LSP, which reports gosqlx.Version) cannot drift. rootCmd.Version = Version + // Errors are returned to main, which prints each one exactly once, so + // cobra must not print them as well. Usage is printed for flag and + // argument errors (cobra validates those before this hook runs) but not + // for failures of the work itself, e.g. a syntax error in the input. + rootCmd.SilenceErrors = true + rootCmd.PersistentPreRun = func(cmd *cobra.Command, _ []string) { + cmd.SilenceUsage = true + } + // Global flags available to all subcommands rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") rootCmd.PersistentFlags().StringVarP(&outputFile, "output", "o", "", "output file (default: stdout)") diff --git a/cmd/gosqlx/cmd/sql_formatter.go b/cmd/gosqlx/cmd/sql_formatter.go index 68e931d8..d3f78884 100644 --- a/cmd/gosqlx/cmd/sql_formatter.go +++ b/cmd/gosqlx/cmd/sql_formatter.go @@ -19,6 +19,7 @@ import ( "strings" pkgformatter "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" ) @@ -78,19 +79,38 @@ func NewSQLFormatter(opts FormatterOptions) *SQLFormatter { // Format renders every statement of astObj, separated by "; " in compact mode // and ";\n\n" otherwise. No trailing semicolon is added. func (f *SQLFormatter) Format(astObj *ast.AST) (string, error) { - if astObj == nil { - return "", errors.New("cannot format a nil AST") + stmts, err := f.formatStatements(astObj) + if err != nil { + return "", err } - sep := ";\n\n" - if f.compact { - sep = "; " + return pkgformatter.JoinWithComments(stmts, nil, nil, f.layout()) +} + +// FormatWithComments is Format for input that had comments: tokens and +// comments are those of the source text, and the comments are put back around +// the statements they belonged to (see pkgformatter.JoinWithComments). +func (f *SQLFormatter) FormatWithComments(astObj *ast.AST, tokens []models.TokenWithSpan, comments []models.Comment) (string, error) { + stmts, err := f.formatStatements(astObj) + if err != nil { + return "", err } - var sb strings.Builder + return pkgformatter.JoinWithComments(stmts, tokens, comments, f.layout()) +} + +func (f *SQLFormatter) formatStatements(astObj *ast.AST) ([]string, error) { + if astObj == nil { + return nil, errors.New("cannot format a nil AST") + } + stmts := make([]string, len(astObj.Statements)) for i, stmt := range astObj.Statements { - if i > 0 { - sb.WriteString(sep) - } - sb.WriteString(pkgformatter.FormatStatement(stmt, f.style)) + stmts[i] = pkgformatter.FormatStatement(stmt, f.style) + } + return stmts, nil +} + +func (f *SQLFormatter) layout() pkgformatter.CommentLayout { + if f.compact { + return pkgformatter.CommentLayout{Terminator: ";", Gap: " "} } - return sb.String(), nil + return pkgformatter.CommentLayout{Terminator: ";", Gap: "\n\n"} } diff --git a/cmd/gosqlx/cmd/transpile.go b/cmd/gosqlx/cmd/transpile.go index ba07b949..349004a5 100644 --- a/cmd/gosqlx/cmd/transpile.go +++ b/cmd/gosqlx/cmd/transpile.go @@ -17,7 +17,6 @@ package cmd import ( "fmt" "io" - "os" "strings" "github.com/spf13/cobra" @@ -44,6 +43,9 @@ Examples: gosqlx transpile --from postgres --to sqlite "CREATE TABLE t (id SERIAL PRIMARY KEY, tags TEXT)"`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := rejectGlobalFormat(cmd); err != nil { + return err + } fromStr, _ := cmd.Flags().GetString("from") toStr, _ := cmd.Flags().GetString("to") @@ -61,7 +63,7 @@ Examples: sql = args[0] } else { // Read from stdin. - data, readErr := io.ReadAll(os.Stdin) + data, readErr := io.ReadAll(cmd.InOrStdin()) if readErr != nil { return fmt.Errorf("reading stdin: %w", readErr) } @@ -76,8 +78,9 @@ Examples: if err != nil { return fmt.Errorf("transpile: %w", err) } - fmt.Println(result) - return nil + out, finish := outputSink(cmd) + fmt.Fprintln(out, result) + return finish() }, } @@ -92,14 +95,14 @@ func init() { // parseDialectFlag converts a dialect name string to a keywords.SQLDialect value. func parseDialectFlag(s string) (keywords.SQLDialect, error) { - switch strings.ToLower(s) { + switch canonicalDialect(strings.ToLower(s)) { case "mysql": return keywords.DialectMySQL, nil - case "postgres", "postgresql": + case "postgresql": return keywords.DialectPostgreSQL, nil case "sqlite": return keywords.DialectSQLite, nil - case "sqlserver", "mssql": + case "sqlserver": return keywords.DialectSQLServer, nil case "oracle": return keywords.DialectOracle, nil diff --git a/cmd/gosqlx/cmd/validate.go b/cmd/gosqlx/cmd/validate.go index cfcf39ee..36b3ec35 100644 --- a/cmd/gosqlx/cmd/validate.go +++ b/cmd/gosqlx/cmd/validate.go @@ -81,14 +81,15 @@ func validateRun(cmd *cobra.Command, args []string) error { return nil } - // In quiet/check mode, silence all cobra output - only exit code matters - if validateQuiet { - cmd.SilenceErrors = true - cmd.SilenceUsage = true - } + // In quiet/check mode only the exit code matters: failures are returned + // as silent errors, which main turns into exit status 1 without printing. // Reject unknown dialect names early before any parsing. - if err := validateDialectName(validateDialect); err != nil { + if err := normalizeDialectFlag(&validateDialect); err != nil { + return err + } + + if err := applyGlobalValidateFlags(cmd); err != nil { return err } @@ -190,7 +191,7 @@ func validateRun(cmd *cobra.Command, args []string) error { // Return error if there were invalid files if result.InvalidFiles > 0 { - return fmt.Errorf("validation failed: %d invalid file(s)", result.InvalidFiles) + return validationFailed(fmt.Errorf("validation failed: %d invalid file(s)", result.InvalidFiles)) } return nil @@ -209,22 +210,12 @@ func validateFromStdin(cmd *cobra.Command) error { return fmt.Errorf("stdin validation failed: %w", err) } - // Create a temporary file to leverage existing validation logic - tmpFile, err := os.CreateTemp("", "gosqlx-stdin-*.sql") - if err != nil { - return fmt.Errorf("failed to create temporary file: %w", err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - defer func() { _ = tmpFile.Close() }() - - // Write stdin content to temp file - if _, err := tmpFile.Write(content); err != nil { - return fmt.Errorf("failed to write to temporary file: %w", err) - } - if err := tmpFile.Close(); err != nil { - return fmt.Errorf("failed to close temporary file: %w", err) - } + return validateContent(cmd, stdinDisplayName, content) +} +// validateContent validates one in-memory input (stdin, inline SQL) and +// reports it under name in the selected output format. +func validateContent(cmd *cobra.Command, name string, content []byte) error { // Load configuration cfg, err := config.LoadDefault() if err != nil { @@ -237,7 +228,7 @@ func validateFromStdin(cmd *cobra.Command) error { // Create validator options quietMode := validateQuiet || validateOutputFormat == OutputFormatSARIF || validateOutputFormat == OutputFormatJSON opts := ValidatorOptionsFromConfig(cfg, flagsChanged, ValidatorFlags{ - Recursive: false, // stdin is always single input + Recursive: false, // a single input Pattern: "", Quiet: quietMode, ShowStats: validateStats && validateOutputFormat == OutputFormatText, // Only show text stats for text output @@ -249,15 +240,8 @@ func validateFromStdin(cmd *cobra.Command) error { // Create validator validator := NewValidator(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts) - // Validate the temporary file - result, err := validator.Validate([]string{tmpFile.Name()}) - if err != nil { - return err - } - - // Update result to show "stdin" instead of temp file path - // The validation has already output results with temp file path - // Different output formats are handled below + // Validate the content in memory; results are reported under name + result := validator.ValidateInput(name, content) // Handle different output formats switch validateOutputFormat { @@ -275,7 +259,7 @@ func validateFromStdin(cmd *cobra.Command) error { fmt.Fprintf(cmd.OutOrStdout(), "SARIF output written to %s\n", validateOutputFile) } case OutputFormatJSON: - jsonData, err := output.FormatValidationJSON(result, []string{"stdin"}, validateStats) + jsonData, err := output.FormatValidationJSON(result, []string{name}, validateStats) if err != nil { return fmt.Errorf("failed to generate JSON output: %w", err) } @@ -291,15 +275,62 @@ func validateFromStdin(cmd *cobra.Command) error { // Return error if validation failed if result.InvalidFiles > 0 { - return fmt.Errorf("validation failed: %d invalid file(s)", result.InvalidFiles) + return validationFailed(fmt.Errorf("validation failed: %d invalid file(s)", result.InvalidFiles)) } return nil } +// applyGlobalValidateFlags maps the global -o/--output and -f/--format flags +// onto validate's own --output-file and --output-format. Both only make sense +// for the machine-readable formats; text output always goes to stdout. +func applyGlobalValidateFlags(cmd *cobra.Command) error { + if formatSet() { + if flagSet(cmd, "output-format") && validateOutputFormat != format { + return fmt.Errorf("-f/--format %q conflicts with --output-format %q", format, validateOutputFormat) + } + switch format { + case OutputFormatJSON, OutputFormatText: + validateOutputFormat = format + default: + return fmt.Errorf("validate does not support -f/--format %q (use json, or --output-format sarif)", format) + } + } + if outputFile != "" { + if validateOutputFile != "" && validateOutputFile != outputFile { + return fmt.Errorf("-o/--output %q conflicts with --output-file %q", outputFile, validateOutputFile) + } + validateOutputFile = outputFile + if validateOutputFormat == "" || validateOutputFormat == OutputFormatText { + return fmt.Errorf("-o/--output needs a machine-readable format: add -f json or --output-format sarif") + } + } + return nil +} + +// inlineDisplayName is how results name SQL given as a command argument. +const inlineDisplayName = "" + +// stdinDisplayName is how results name input read from standard input. +const stdinDisplayName = "" + +// validationFailed wraps a validation failure. With --quiet only the exit +// status matters, so the error is returned silent: main exits 1 and prints +// nothing. +func validationFailed(err error) error { + if validateQuiet { + return silentError{err} + } + return err +} + // validateInlineSQL validates inline SQL passed as a command argument. // Uses the fast-path Validate() which skips full AST construction (#274). func validateInlineSQL(cmd *cobra.Command, sql string) error { + if validateOutputFormat == OutputFormatJSON || validateOutputFormat == OutputFormatSARIF { + // A machine-readable report needs the full result, not the fast path. + return validateContent(cmd, inlineDisplayName, []byte(sql)) + } var err error if validateDialect != "" { err = parser.ValidateWithDialect(sql, keywords.SQLDialect(validateDialect)) @@ -310,7 +341,7 @@ func validateInlineSQL(cmd *cobra.Command, sql string) error { if !validateQuiet { fmt.Fprintf(cmd.ErrOrStderr(), "❌ Invalid SQL: %v\n", err) } - return fmt.Errorf("validation failed: %w", err) + return validationFailed(fmt.Errorf("validation failed: %w", err)) } if sql == "" { diff --git a/cmd/gosqlx/cmd/validator.go b/cmd/gosqlx/cmd/validator.go index 8733e26e..73e64f77 100644 --- a/cmd/gosqlx/cmd/validator.go +++ b/cmd/gosqlx/cmd/validator.go @@ -15,6 +15,7 @@ package cmd import ( + "errors" "fmt" "io" "os" @@ -24,6 +25,8 @@ import ( "github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/config" "github.com/ajitpratap0/GoSQLX/cmd/gosqlx/internal/output" + pkgerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" @@ -242,14 +245,79 @@ func (v *Validator) validateFile(filename string) output.FileValidationResult { return result } - data := inputResult.Content - result.Size = int64(len(data)) + return v.validateData(filename, inputResult.Content) +} +// ValidateInput validates in-memory content (for example stdin) and reports +// it under the display name name instead of a file path. +func (v *Validator) ValidateInput(name string, data []byte) *output.ValidationResult { + start := time.Now() + fileResult := v.validateData(name, data) + result := &output.ValidationResult{ + Files: []output.FileValidationResult{fileResult}, + TotalFiles: 1, + TotalBytes: fileResult.Size, + } + switch { + case fileResult.Error != nil: + if !v.Opts.Quiet { + fmt.Fprintf(v.Err, "❌ %s: %v\n", name, fileResult.Error) // #nosec G705 + } + result.InvalidFiles++ + case fileResult.Valid: + if !v.Opts.Quiet { + fmt.Fprintf(v.Out, "✅ %s: Valid SQL\n", name) // #nosec G705 + } + result.ValidFiles++ + default: + if !v.Opts.Quiet { + fmt.Fprintf(v.Out, "❌ %s: Invalid SQL\n", name) // #nosec G705 + } + result.InvalidFiles++ + } + result.Duration = time.Since(start) + if v.Opts.ShowStats { + v.displayStats(result) + } + return result +} + +// validateData validates the content of one input. Content with no +// statements (empty, whitespace or only comments) is valid. +// +// The whole content is parsed first, so errors carry exact positions. Only +// when that fails is the content split into statements (SplitStatements: GO +// batches, DELIMITER scripts, procedural bodies) and each statement checked +// on its own: a script the parser accepts statement by statement is valid, and +// a file past the tokenizer's token limit is validated without it. +func (v *Validator) validateData(name string, data []byte) output.FileValidationResult { + result := output.FileValidationResult{Path: name, Size: int64(len(data))} if len(data) == 0 { result.Valid = true - return result // Empty inputs are considered valid + return result + } + + empty, wholeErr := v.checkSQL(string(data)) + if empty || wholeErr == nil { + result.Valid = true + return result + } + + if ok, stmtErr := v.checkStatements(string(data)); ok { + result.Valid = true + return result + } else if stmtErr != nil && isTokenLimitError(wholeErr) { + // The whole-file error only says the file is too big to read at once. + result.Error = stmtErr + return result } + result.Error = wholeErr + return result +} +// checkSQL tokenizes and parses src as one unit. empty reports that src holds +// no statements. +func (v *Validator) checkSQL(src string) (empty bool, err error) { // Use pooled tokenizer for performance with dialect support tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) @@ -259,35 +327,60 @@ func (v *Validator) validateFile(filename string) output.FileValidationResult { tkz.SetDialect(keywords.SQLDialect(v.Opts.Dialect)) } - // Tokenize - tokens, err := tkz.Tokenize(data) + tokens, err := tkz.TokenizeString(src) if err != nil { - result.Error = fmt.Errorf("tokenization failed: %w", err) - return result + return false, fmt.Errorf("tokenization failed: %w", err) } - - if len(tokens) == 0 { - result.Valid = true - return result + if !hasStatementTokens(tokens) { + return true, nil } - // Convert TokenWithSpan to Token using centralized converter - // Parse to validate syntax with proper error handling for memory management p := parser.NewParser(parser.WithDialect(v.Opts.Dialect)) astObj, err := p.ParseFromModelTokens(tokens) if err != nil { - result.Error = fmt.Errorf("parsing failed: %w", err) - return result + return false, fmt.Errorf("parsing failed: %w", err) } // CRITICAL: Always release AST - defer func() { - ast.ReleaseAST(astObj) - }() + ast.ReleaseAST(astObj) + return false, nil +} - result.Valid = true - return result +// checkStatements validates src statement by statement. ok is true when src +// holds at least one statement and every statement is valid. Otherwise err is +// the first failing statement's error, prefixed with its number and start +// line. +func (v *Validator) checkStatements(src string) (ok bool, err error) { + segments := gosqlx.SplitStatements(src, v.Opts.Dialect) + checked := 0 + cursor := 0 + for _, seg := range segments { + trimmed := strings.TrimSpace(seg) + if trimmed == "" { + continue + } + at := strings.Index(src[cursor:], seg) + line := 1 + if at >= 0 { + line = 1 + strings.Count(src[:cursor+at], "\n") + cursor += at + len(seg) + } + empty, segErr := v.checkSQL(seg) + if segErr != nil { + return false, fmt.Errorf("statement %d (starting at line %d): %w", checked+1, line, segErr) + } + if !empty { + checked++ + } + } + return checked > 0, nil +} + +// isTokenLimitError reports whether err is the tokenizer's token-limit error. +func isTokenLimitError(err error) bool { + var e *pkgerrors.Error + return errors.As(err, &e) && e.Code == pkgerrors.ErrCodeTokenLimitReached } // expandFileArgs expands file arguments (glob patterns, directories) into a list of files diff --git a/cmd/gosqlx/main.go b/cmd/gosqlx/main.go index bb467843..43053131 100644 --- a/cmd/gosqlx/main.go +++ b/cmd/gosqlx/main.go @@ -81,7 +81,9 @@ import ( // See package documentation for comprehensive usage information. func main() { if err := cmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + if !cmd.IsSilentError(err) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } os.Exit(1) } } From dd9bf6a1df36fa8958450ed3156f7bd2d14fe9cf Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:32:39 +0530 Subject: [PATCH 32/95] docs: remove APIs and settings that do not exist; document CLI changes USAGE_GUIDE referenced parser.NewTokenConverter (never existed; the tokenizer's output goes straight to ParseFromModelTokens) and one tokenizer example lacked the pkg/models import. TROUBLESHOOTING referenced Parser.SetMaxTokens, SetMaxRecursionDepth and parser.max_tokens / max_recursion_depth config keys; the limits are compile-time constants (MaxRecursionDepth is 100, not 200). The token-limit error hint no longer tells users to adjust MaxTokens. CLI_GUIDE, LINTING_RULES and the CHANGELOG describe the new lint/format/validate behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 38 ++++++++++++++++++++++++ docs/CLI_GUIDE.md | 15 ++++++++-- docs/LINTING_RULES.md | 2 +- docs/TROUBLESHOOTING.md | 64 +++++++++++++++++++++-------------------- docs/USAGE_GUIDE.md | 36 ++++++++++------------- pkg/errors/builders.go | 2 +- pkg/errors/doc.go | 2 +- 7 files changed, 102 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f66f83..06f8e2fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **`SplitStatements` keeps compound statements whole**: `BEGIN ... END` blocks + (trigger, procedure and function bodies, T-SQL `IF ... BEGIN`, `BEGIN TRY`, + PostgreSQL `BEGIN ATOMIC`), `CASE ... END`, `IF`/`LOOP`/`WHILE`/`REPEAT` + inside a block and Oracle `DECLARE`/`IS`/`AS` sections are no longer cut at + the first inner `;`. Transaction statements (`BEGIN;`, `BEGIN TRANSACTION`, + `BEGIN IMMEDIATE`) are not blocks, and a script whose blocks do not balance + falls back to plain `;` splitting. Client batch separators are honoured and + removed from the output: a `GO [n]` line for SQL Server, `DELIMITER` for + MySQL/MariaDB and a lone `/` line for Oracle. +- **`gosqlx.Lint` / `LintWithDialect` report invalid SQL**: SQL that does not + tokenize or parse returns an error wrapping `ErrTokenize` or `ErrSyntax` + together with the violations of the text and token rules. Callers that + ignored the error and treated an empty result as clean must now check it. + `linter.FileResult` gains `TokenizeError`, `ParseError` and `SyntaxError()`. +- **`gosqlx.Format` and `gosqlx format` keep comments and optimizer hints**: + comments stay with their statement (a comment inside a statement moves to the + line above it) and `/*+ ... */` hints directly after a statement's first + keyword stay in place. Input whose hints or MySQL `/*! ... */` comments + cannot be placed faithfully is refused (`formatter.ErrUnplaceableComment`) + instead of being rewritten without them, and `format -i` verifies that the + output holds exactly the input's comments. New `formatter.JoinWithComments` + and `gosqlx.WithComments()`. +- **CLI**: `lint` exits 1 on syntax errors and `--auto-fix` judges the fixed + text; `format --no-uppercase` lower-cases keywords (it used to upper-case + them); `postgres`, `pg`, `mssql`, `tsql` and `sqlite3` are accepted by every + `--dialect` flag; errors are printed once and usage only for flag and + argument errors; `validate --quiet` exits silently; `validate` names stdin + ``, validates `GO`/`DELIMITER` scripts and files over the token + limit statement by statement; empty and comment-only input is valid for + `validate`, `lint` and `format`; `-o` works for `parse` and `transpile`, + `-f`/`-o` map onto `validate`'s output flags and are rejected where + unsupported; `format` keeps a leading byte order mark. +- **`ParseReaderMultiple`** skips segments that hold only comments. +- **Docs**: removed `parser.NewTokenConverter`, `SetMaxTokens`, + `SetMaxRecursionDepth` and the `parser.max_tokens` config key, none of which + exist; the limits are constants. + ## [1.16.0] - 2026-10-22 — Performance Audit, SQL Highlighting, Dialect-Aware LSP, Claude Code Plugin & ChatGPT App, Correctness Sweep Covers everything merged since v1.15.0: the October 2026 performance audit diff --git a/docs/CLI_GUIDE.md b/docs/CLI_GUIDE.md index 5ae118d4..88cbbc34 100644 --- a/docs/CLI_GUIDE.md +++ b/docs/CLI_GUIDE.md @@ -174,6 +174,13 @@ cat query.sql | gosqlx validate && echo "Valid SQL" | 0 | Valid SQL - no syntax errors | | 1 | Invalid SQL - syntax errors detected | +Input with no statements (empty, whitespace only, or only comments) is valid. +A file that fails as a whole is checked again statement by statement, so +scripts that use the T-SQL `GO` batch separator or the MySQL `DELIMITER` +directive validate, and a file over the tokenizer's 1,000,000 token limit +validates when every statement is under it. Results read from stdin are named +``. + #### Performance Target - **Latency**: <10ms for typical queries (50-500 characters) @@ -199,7 +206,7 @@ gosqlx format [file...] [flags] | `--dialect` | string | postgresql | SQL dialect (generic, postgresql, mysql, mariadb, sqlserver, oracle, sqlite, snowflake, bigquery, redshift, clickhouse); unknown names are rejected | | `--indent` | int | 2 | Indentation size in spaces (0-8) | | `--uppercase` | bool | true | Uppercase SQL keywords | -| `--no-uppercase` | bool | false | Keep original keyword case | +| `--no-uppercase` | bool | false | Write keywords in lower case (cannot be combined with `--uppercase`) | | `--compact` | bool | false | Compact format (minimal whitespace) | | `--check` | bool | false | Check if formatting is needed (CI mode) | | `--max-line` | int | 80 | Maximum line length (0-500) | @@ -534,10 +541,14 @@ gosqlx lint - | Code | Meaning | |------|---------| | 0 | No violations found | -| 1 | Any violation found (error, warning, or info) - usable as CI gate | +| 1 | Any violation found (error, warning, or info), or the SQL has a syntax error - usable as CI gate | > Since v1.9.0, `gosqlx lint` exits 1 whenever any violation is found (error, warning, or info). > `--fail-on-warn` is therefore redundant for exit codes. +> +> SQL that does not tokenize or parse is reported as a syntax error and also exits 1; the style +> rules that need no parse tree still report their violations. With `--auto-fix` the exit code +> reflects what is left after the fixes. --- diff --git a/docs/LINTING_RULES.md b/docs/LINTING_RULES.md index ab98549a..ed051e1d 100644 --- a/docs/LINTING_RULES.md +++ b/docs/LINTING_RULES.md @@ -717,7 +717,7 @@ gosqlx lint --fail-on-warn query.sql | Code | Meaning | |------|---------| | 0 | No violations (or info only) | -| 1 | Violations found (errors/warnings) | +| 1 | Violations found (errors/warnings), or the SQL has a syntax error | --- diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7366894b..a06b537e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -274,12 +274,11 @@ lsp: diagnostics: max_diagnostics: 50 # Limit diagnostic count debounce_ms: 1000 # Reduce parsing frequency - -parser: - max_recursion_depth: 100 - max_tokens: 50000 ``` +The parser's own limits (`parser.MaxRecursionDepth`, `tokenizer.MaxTokens`) are +fixed constants, not settings; see "Complex Nested Query Parsing" below. + **Monitor Memory Usage:** ```go import ( @@ -867,38 +866,45 @@ func TestJSONOperatorParsing(sql string) { **Symptom:** Deeply nested queries fail with "recursion depth limit" error -**Common Cause:** Parser hits max recursion depth (default 200) +**Common Cause:** The parser hits its recursion limit. `parser.MaxRecursionDepth` +is 100 and, like the tokenizer's `MaxTokens` (1,000,000 tokens per input, +comments included) and `MaxInputSize` (10 MB), it is a compile-time constant: +there is no config key and no setter (`SetMaxRecursionDepth` and `SetMaxTokens` +do not exist). The limits protect the process from stack exhaustion and +runaway memory on hostile input. **Solutions:** -```yaml -# .gosqlx.yml - Increase recursion limit -parser: - max_recursion_depth: 500 # Increase for complex queries - max_tokens: 100000 # Increase token limit if needed -``` -**Code Solution:** -```go -import "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" +- Flatten the query. Replace deeply nested derived tables with CTEs (see + below); each CTE parses at a shallow depth. +- For input over the token or size limit, split it into statements and parse + them one at a time. Each statement gets its own budget: -func ParseComplexQuery(sql string) error { - p := parser.NewParser() +```go +import ( + "fmt" + "strings" - // Increase limits for complex queries - p.SetMaxRecursionDepth(500) - p.SetMaxTokens(100000) + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" +) - tokens, _ := /* tokenize */ - astTree, err := p.Parse(tokens) - if err != nil { - return err +func ParseLargeScript(script string) error { + for i, stmt := range gosqlx.SplitStatements(script, "postgresql") { + if strings.TrimSpace(stmt) == "" { + continue + } + if _, err := gosqlx.Parse(stmt); err != nil { + return fmt.Errorf("statement %d: %w", i+1, err) + } } - defer ast.ReleaseAST(astTree) - return nil } ``` +`gosqlx validate` does this for you: when a file fails as a whole it re-checks +it statement by statement, so a file past the token limit validates as long as +each statement is under it. + **Refactor Complex Query:** ```sql -- Instead of deep nesting: @@ -1027,11 +1033,11 @@ SELECT * FROM users WHERE name = 'John''s Pizza'; **E1006 - Input Too Large** - **Cause:** SQL input exceeds size limits (DoS protection) -- **Fix:** Split large queries or increase limits if appropriate +- **Fix:** Split the input into smaller statements (the 10 MB limit is a constant) **E1007 - Token Limit Reached** - **Cause:** Too many tokens generated (DoS protection) -- **Fix:** Simplify query or increase limits +- **Fix:** Split the input into smaller statements; the 1,000,000 token limit is a constant (see `gosqlx.SplitStatements`) **E1008 - Tokenizer Panic** - **Cause:** Internal tokenizer error (recovered panic) @@ -1215,10 +1221,6 @@ lsp: incremental_sync: true parser: - # Reduce recursion depth for faster parsing - max_recursion_depth: 200 - max_tokens: 50000 - # Enable parser caching cache_enabled: true cache_ttl_seconds: 300 # 5 minutes diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 136974ff..ca14ef76 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -102,6 +102,7 @@ package main import ( "fmt" "log" + "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" ) @@ -139,8 +140,9 @@ package main import ( "fmt" - "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" ) func ParseSQL(sql string) error { @@ -153,23 +155,18 @@ func ParseSQL(sql string) error { return err } - // Step 2: Convert to parser tokens using the proper converter - converter := parser.NewTokenConverter() - result, err := converter.Convert(tokens) - if err != nil { - return fmt.Errorf("token conversion failed: %w", err) - } - - // Step 3: Parse + // Step 2: Parse the tokenizer's output directly. No conversion step is + // needed: ParseFromModelTokens takes []models.TokenWithSpan. p := parser.NewParser() defer p.Release() - ast, err := p.Parse(result.Tokens) + tree, err := p.ParseFromModelTokens(tokens) if err != nil { return err } + defer ast.ReleaseAST(tree) // CRITICAL: return the AST to its pool - fmt.Printf("Parsed: %T\n", ast) + fmt.Printf("Parsed %d statement(s)\n", len(tree.Statements)) return nil } ``` @@ -1887,19 +1884,16 @@ func ProcessWithPreallocation(sql string) error { return err } - // Convert tokens using the proper converter - converter := parser.NewTokenConverter() - result, err := converter.Convert(tokens) - if err != nil { - return err - } - - // Parse with pre-converted tokens + // Parse the tokenizer's output directly p := parser.NewParser() defer p.Release() - _, err = p.Parse(result.Tokens) - return err + tree, err := p.ParseFromModelTokens(tokens) + if err != nil { + return err + } + ast.ReleaseAST(tree) + return nil } ``` diff --git a/pkg/errors/builders.go b/pkg/errors/builders.go index 70de257b..579bafba 100644 --- a/pkg/errors/builders.go +++ b/pkg/errors/builders.go @@ -255,7 +255,7 @@ func TokenLimitReachedError(count, maxTokens int, location models.Location, sql ErrCodeTokenLimitReached, fmt.Sprintf("token count %d exceeds limit of %d tokens", count, maxTokens), location, - ).WithContext(sql, 1).WithHint(fmt.Sprintf("Simplify query or adjust MaxTokens limit (currently %d)", maxTokens)) + ).WithContext(sql, 1).WithHint(fmt.Sprintf("Split the input into smaller statements; the limit is fixed at %d tokens per input", maxTokens)) } // TokenizerPanicError creates an E1008 error for a panic that was recovered inside diff --git a/pkg/errors/doc.go b/pkg/errors/doc.go index 6324ef43..07953a2c 100644 --- a/pkg/errors/doc.go +++ b/pkg/errors/doc.go @@ -304,7 +304,7 @@ // // Token count limits // err := errors.TokenLimitReachedError(15000, 10000, location, sqlSource) // // Message: "token count 15000 exceeds limit of 10000 tokens" -// // Hint: "Simplify query or adjust MaxTokens limit (currently 10000)" +// // Hint: "Split the input into smaller statements; the limit is fixed at 10000 tokens per input" // // Panic Recovery: // From d2c7f72fdb7aa439c44a30c906da7f98c5156bae Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:33:35 +0530 Subject: [PATCH 33/95] fix(parser,ast,formatter): CTE keyword names, SEARCH/CYCLE, PIVOT forms - accept non-reserved keywords as CTE names (dbt's source, target, final); FINAL is a plain name outside ClickHouse; CTE names quote like table aliases - SQL:1999 SEARCH {DEPTH|BREADTH} FIRST BY ... SET and CYCLE ... SET [TO ... DEFAULT ...] [USING ...] on recursive CTEs (PostgreSQL, Oracle) - PIVOT: several aliased aggregates, PIVOT XML, IN (ANY [ORDER BY]), IN (subquery), alias column list, and the source alias of the pivoted table (SQL Server (SELECT ...) s PIVOT (...) p no longer drops s) - UNPIVOT INCLUDE / EXCLUDE NULLS and Oracle multi-column UNPIVOT - Snowflake FROM VALUES without parentheses and stage paths with file names (@stage/data.csv (FILE_FORMAT => ...)) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/render.go | 47 +--- pkg/formatter/render_query_ext.go | 5 +- pkg/formatter/table_factor_render_test.go | 18 ++ pkg/sql/ast/ast_clauses.go | 24 +- pkg/sql/ast/cte_search_cycle.go | 134 +++++++++++ pkg/sql/ast/pivot_ext.go | 252 ++++++++++++++++++++ pkg/sql/ast/pool_statement_release.go | 1 + pkg/sql/ast/quote_names.go | 18 +- pkg/sql/ast/reserved_name_words_test.go | 3 +- pkg/sql/ast/sql.go | 11 +- pkg/sql/ast/sql_table_hints_pivot.go | 37 +-- pkg/sql/ast/table_factor_test.go | 26 +++ pkg/sql/parser/cte.go | 16 +- pkg/sql/parser/parser.go | 4 + pkg/sql/parser/pivot.go | 92 ++++++-- pkg/sql/parser/select_subquery.go | 55 ++--- pkg/sql/parser/table_factor.go | 270 ++++++++++++++++++++++ pkg/sql/parser/table_factor_test.go | 106 +++++++++ testdata/realworld/known_failures.txt | 16 -- 19 files changed, 964 insertions(+), 171 deletions(-) create mode 100644 pkg/sql/ast/cte_search_cycle.go create mode 100644 pkg/sql/ast/pivot_ext.go diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index fe4503ae..18eede79 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -1387,6 +1387,8 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { sb.WriteString(" ") sb.WriteString(ast.QuoteTableAlias(t.Alias)) sb.WriteString(ast.TableColumnAliasesSQL(t)) + } else { + sb.WriteString(ast.TableRefSourceAliasSQL(t)) } sb.WriteString(ast.TableRefPostAliasSQL(t, f.kw)) // SQL Server table hints follow the alias and precede PIVOT/UNPIVOT (#604). @@ -1404,36 +1406,8 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { } sb.WriteString(")") } - if t.Pivot != nil { - sb.WriteString(" ") - sb.WriteString(f.kw("PIVOT")) - sb.WriteString(" (") - sb.WriteString(exprSQL(t.Pivot.AggregateFunction)) - sb.WriteString(" ") - sb.WriteString(f.kw("FOR")) - sb.WriteString(" ") - sb.WriteString(ast.QuotePivotColumn(t.Pivot.PivotColumn)) - sb.WriteString(" ") - sb.WriteString(f.kw("IN")) - sb.WriteString(" (") - writePivotItems(&sb, t.Pivot.InValues) - sb.WriteString("))") - } - if t.Unpivot != nil { - sb.WriteString(" ") - sb.WriteString(f.kw("UNPIVOT")) - sb.WriteString(" (") - sb.WriteString(ast.QuotePivotColumn(t.Unpivot.ValueColumn)) - sb.WriteString(" ") - sb.WriteString(f.kw("FOR")) - sb.WriteString(" ") - sb.WriteString(ast.QuotePivotColumn(t.Unpivot.NameColumn)) - sb.WriteString(" ") - sb.WriteString(f.kw("IN")) - sb.WriteString(" (") - writePivotItems(&sb, t.Unpivot.InColumns) - sb.WriteString("))") - } + sb.WriteString(ast.PivotSQL(t.Pivot, f.kw)) + sb.WriteString(ast.UnpivotSQL(t.Unpivot, f.kw)) sb.WriteString(ast.MatchRecognizeSQL(t.MatchRecognize, f.kw)) if t.Alias != "" && ast.TableAliasFollowsClause(t) { // PIVOT/UNPIVOT aliases conventionally use AS to avoid ambiguity @@ -1442,22 +1416,11 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { sb.WriteString(f.kw("AS")) sb.WriteString(" ") sb.WriteString(ast.QuoteTableAlias(t.Alias)) + sb.WriteString(ast.TableColumnAliasesSQL(t)) } return sb.String() } -// writePivotItems writes PIVOT IN values or UNPIVOT IN columns separated by -// ", ", each in its stored source form when well formed and double-quoted -// otherwise (see ast.QuotePivotItem). -func writePivotItems(sb *strings.Builder, items []string) { - for i, item := range items { - if i > 0 { - sb.WriteString(", ") - } - sb.WriteString(ast.QuotePivotItem(item)) - } -} - // sampleSQL renders a ClickHouse SAMPLE clause. func sampleSQL(s *ast.SampleClause, f *nodeFormatter) string { var sb strings.Builder diff --git a/pkg/formatter/render_query_ext.go b/pkg/formatter/render_query_ext.go index 1561c14b..a292945c 100644 --- a/pkg/formatter/render_query_ext.go +++ b/pkg/formatter/render_query_ext.go @@ -36,10 +36,10 @@ func cteSQL(cte *ast.CommonTableExpr, f *nodeFormatter) string { sb.WriteString(" ") sb.WriteString(f.kw("AS")) sb.WriteString(" ") - sb.WriteString(ast.QuoteName(cte.Name)) + sb.WriteString(ast.QuoteTableAlias(cte.Name)) return sb.String() } - sb.WriteString(ast.QuoteName(cte.Name)) + sb.WriteString(ast.QuoteTableAlias(cte.Name)) sb.WriteString(" ") if len(cte.Columns) > 0 { sb.WriteString("(") @@ -59,5 +59,6 @@ func cteSQL(cte *ast.CommonTableExpr, f *nodeFormatter) string { sb.WriteString("(") sb.WriteString(FormatStatement(cte.Statement, nestedOpts(f.opts))) sb.WriteString(")") + sb.WriteString(ast.CTESearchCycleSQL(cte, f.kw)) return sb.String() } diff --git a/pkg/formatter/table_factor_render_test.go b/pkg/formatter/table_factor_render_test.go index dfdbcfc8..d8b3928c 100644 --- a/pkg/formatter/table_factor_render_test.go +++ b/pkg/formatter/table_factor_render_test.go @@ -81,6 +81,24 @@ func TestRender_QueryExpressionsAndTableFactors(t *testing.T) { {"sqlserver table function", keywords.DialectSQLServer, "SELECT * FROM STRING_SPLIT('a,b', ',')", "SELECT * FROM STRING_SPLIT('a,b', ',')"}, {"sqlite table function", keywords.DialectSQLite, "SELECT * FROM t, json_each(t.a)", "SELECT * FROM t, json_each(t.a)"}, {"flatten keyword arguments", keywords.DialectSnowflake, "SELECT * FROM t, LATERAL FLATTEN(input => t.a, outer => TRUE) f", "SELECT * FROM t, LATERAL FLATTEN(input => t.a, outer => TRUE) f"}, + // CTE names and SEARCH / CYCLE + {"keyword cte names", keywords.DialectSnowflake, "WITH source AS (SELECT 1 AS a), target AS (SELECT a FROM source) SELECT * FROM target", "WITH source AS (SELECT 1 AS a), target AS (SELECT a FROM source) SELECT * FROM target"}, + {"final cte name", keywords.DialectSnowflake, "WITH final AS (SELECT 1 AS a) SELECT * FROM final", "WITH final AS (SELECT 1 AS a) SELECT * FROM final"}, + {"cte search", keywords.DialectPostgreSQL, "WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 5) SEARCH BREADTH FIRST BY n SET ord SELECT * FROM r ORDER BY ord", "WITH RECURSIVE r (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 5) SEARCH BREADTH FIRST BY n SET ord SELECT * FROM r ORDER BY ord"}, + {"cte cycle using", keywords.DialectPostgreSQL, "WITH RECURSIVE r AS (SELECT 1 AS n UNION SELECT n + 1 FROM r) CYCLE n SET is_cycle USING path SELECT * FROM r", "WITH RECURSIVE r AS (SELECT 1 AS n UNION SELECT n + 1 FROM r) CYCLE n SET is_cycle USING path SELECT * FROM r"}, + {"oracle search and cycle", keywords.DialectOracle, "WITH b (id, pid) AS (SELECT id, pid FROM p UNION ALL SELECT p.id, p.pid FROM p JOIN b ON p.pid = b.id) SEARCH DEPTH FIRST BY id SET ord CYCLE id SET is_cycle TO 'Y' DEFAULT 'N' SELECT * FROM b", "WITH b (id, pid) AS (SELECT id, pid FROM p UNION ALL SELECT p.id, p.pid FROM p INNER JOIN b ON p.pid = b.id) SEARCH DEPTH FIRST BY id SET ord CYCLE id SET is_cycle TO 'Y' DEFAULT 'N' SELECT * FROM b"}, + // Snowflake FROM forms + {"snowflake bare values", keywords.DialectSnowflake, "SELECT * FROM VALUES (1, 'a'), (2, 'b') AS v (id, name)", "SELECT * FROM (VALUES (1, 'a'), (2, 'b')) v(id, name)"}, + {"snowflake stage file", keywords.DialectSnowflake, "SELECT $1, $2 FROM @my_stage/data.csv (FILE_FORMAT => 'my_csv')", "SELECT $1, $2 FROM @my_stage/data.csv (FILE_FORMAT => 'my_csv')"}, + // PIVOT / UNPIVOT + {"oracle multi aggregate pivot", keywords.DialectOracle, "SELECT * FROM (SELECT a, b, c FROM t) PIVOT (SUM(c) AS total, COUNT(*) AS cnt FOR a IN (1 AS x, 2 AS y))", "SELECT * FROM (SELECT a, b, c FROM t) PIVOT (SUM(c) AS total, COUNT(*) AS cnt FOR a IN (1 AS x, 2 AS y))"}, + {"pivot source alias", keywords.DialectSQLServer, "SELECT * FROM (SELECT a, q FROM t) s PIVOT (SUM(a) FOR q IN ([Q1], [Q2])) AS p", "SELECT * FROM (SELECT a, q FROM t) s PIVOT (SUM(a) FOR q IN ([Q1], [Q2])) AS p"}, + {"pivot xml any", keywords.DialectOracle, "SELECT * FROM sales PIVOT XML (SUM(amount) FOR product IN (ANY))", "SELECT * FROM sales PIVOT XML (SUM(amount) FOR product IN (ANY))"}, + {"pivot any order by", keywords.DialectSnowflake, "SELECT * FROM q PIVOT (AVG(amount) FOR quarter IN (ANY ORDER BY quarter))", "SELECT * FROM q PIVOT (AVG(amount) FOR quarter IN (ANY ORDER BY quarter))"}, + {"pivot in subquery", keywords.DialectSnowflake, "SELECT * FROM q PIVOT (AVG(amount) FOR quarter IN (SELECT DISTINCT quarter FROM qs))", "SELECT * FROM q PIVOT (AVG(amount) FOR quarter IN (SELECT DISTINCT quarter FROM qs))"}, + {"pivot alias columns", keywords.DialectSnowflake, "SELECT * FROM m PIVOT (SUM(amount) FOR month IN ('JAN', 'FEB')) AS p (emp_id, jan, feb)", "SELECT * FROM m PIVOT (SUM(amount) FOR month IN ('JAN', 'FEB')) AS p(emp_id, jan, feb)"}, + {"unpivot include nulls", keywords.DialectSnowflake, "SELECT * FROM m UNPIVOT INCLUDE NULLS (sales FOR month IN (jan, feb)) AS u", "SELECT * FROM m UNPIVOT INCLUDE NULLS (sales FOR month IN (jan, feb)) AS u"}, + {"multi-column unpivot", keywords.DialectOracle, "SELECT * FROM w UNPIVOT ((units, revenue) FOR period IN ((u1, r1) AS 'P1', (u2, r2) AS 'P2'))", "SELECT * FROM w UNPIVOT ((units, revenue) FOR period IN ((u1, r1) AS 'P1', (u2, r2) AS 'P2'))"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 822f0b0f..92b31cd7 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -48,6 +48,10 @@ type CommonTableExpr struct { ScalarExpr Expression // ClickHouse: WITH AS (scalar CTE, no subquery) Materialized *bool // nil = default, true = MATERIALIZED, false = NOT MATERIALIZED Pos models.Location // Source position of the CTE name (1-based line and column) + // Search and Cycle are the SQL:1999 SEARCH / CYCLE clauses of a + // recursive CTE (PostgreSQL 14+, Oracle); nil when absent. + Search *CTESearchClause + Cycle *CTECycleClause } func (c *CommonTableExpr) statementNode() {} @@ -60,6 +64,9 @@ func (c CommonTableExpr) Children() []Node { if c.ScalarExpr != nil { nodes = append(nodes, c.ScalarExpr) } + if c.Cycle != nil { + nodes = append(nodes, c.Cycle) + } return nodes } @@ -826,15 +833,20 @@ type PivotClause struct { PivotColumn string // The column used for pivoting, e.g. region InValues []string // The values to pivot on, e.g. [North], [South] Pos models.Location // Source position of the PIVOT keyword + // PivotExtensions holds the Oracle / Snowflake forms: several aliased + // aggregates, PIVOT XML, IN (ANY ...) / IN (subquery) and the alias of + // the pivoted source (see pivot_ext.go). + PivotExtensions } func (p *PivotClause) expressionNode() {} func (p PivotClause) TokenLiteral() string { return "PIVOT" } func (p PivotClause) Children() []Node { + var nodes []Node if p.AggregateFunction != nil { - return []Node{p.AggregateFunction} + nodes = append(nodes, p.AggregateFunction) } - return nil + return p.pivotExtensionChildren(nodes) } // UnpivotClause represents the SQL Server / Oracle UNPIVOT operator for column-to-row @@ -846,6 +858,14 @@ type UnpivotClause struct { NameColumn string // The target name column, e.g. region InColumns []string // The source columns to unpivot, e.g. north_sales, south_sales Pos models.Location // Source position of the UNPIVOT keyword + // NullHandling is INCLUDE or EXCLUDE for UNPIVOT INCLUDE / EXCLUDE NULLS, + // "" when omitted. + NullHandling string + // ValueColumns holds the value columns of an Oracle multi-column UNPIVOT + // ((units, revenue) FOR ...); ValueColumn is then its first entry. + ValueColumns []string + // SourceAlias is the alias of the unpivoted table or derived table. + SourceAlias string } func (u *UnpivotClause) expressionNode() {} diff --git a/pkg/sql/ast/cte_search_cycle.go b/pkg/sql/ast/cte_search_cycle.go new file mode 100644 index 00000000..a15b6eee --- /dev/null +++ b/pkg/sql/ast/cte_search_cycle.go @@ -0,0 +1,134 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// CTESearchClause is the SEARCH clause of a recursive CTE: +// +// SEARCH {DEPTH | BREADTH} FIRST BY col [, col ...] SET sequence_col +type CTESearchClause struct { + // Breadth is true for BREADTH FIRST, false for DEPTH FIRST. + Breadth bool + // By are the ordering columns. + By []string + // Set is the generated sequence column. + Set string + Pos models.Location +} + +// CTECycleClause is the CYCLE clause of a recursive CTE: +// +// CYCLE col [, col ...] SET mark_col [TO value DEFAULT value] [USING path_col] +// +// PostgreSQL 14+ takes the optional TO / DEFAULT values and requires USING; +// Oracle requires TO / DEFAULT and has no USING. +type CTECycleClause struct { + // Columns are the columns checked for cycles. + Columns []string + // Set is the generated cycle mark column. + Set string + // To and Default are the mark values for a cycle and for no cycle. + To Expression + Default Expression + // Using is the generated path column (PostgreSQL). + Using string + Pos models.Location +} + +func (c *CTECycleClause) expressionNode() {} +func (c CTECycleClause) TokenLiteral() string { return "CYCLE" } +func (c CTECycleClause) Children() []Node { + var nodes []Node + if c.To != nil { + nodes = append(nodes, c.To) + } + if c.Default != nil { + nodes = append(nodes, c.Default) + } + return nodes +} + +// CTESearchCycleSQL returns the " SEARCH ..." and " CYCLE ..." clauses of +// cte, with keywords passed through kw (nil keeps them upper case), or "". +func CTESearchCycleSQL(cte *CommonTableExpr, kw func(string) string) string { + if cte == nil || (cte.Search == nil && cte.Cycle == nil) { + return "" + } + if kw == nil { + kw = identityKeyword + } + var sb strings.Builder + writeCTESearchCycle(&sb, cte, kw) + return sb.String() +} + +func writeCTESearchCycle(w textWriter, cte *CommonTableExpr, kw func(string) string) { + if s := cte.Search; s != nil && len(s.By) > 0 && s.Set != "" { + w.WriteByte(' ') + w.WriteString(kw("SEARCH")) + w.WriteByte(' ') + if s.Breadth { + w.WriteString(kw("BREADTH FIRST BY")) + } else { + w.WriteString(kw("DEPTH FIRST BY")) + } + w.WriteByte(' ') + writeQuotedNameList(w, s.By) + w.WriteByte(' ') + w.WriteString(kw("SET")) + w.WriteByte(' ') + w.WriteString(QuoteName(s.Set)) + } + if c := cte.Cycle; c != nil && len(c.Columns) > 0 && c.Set != "" { + w.WriteByte(' ') + w.WriteString(kw("CYCLE")) + w.WriteByte(' ') + writeQuotedNameList(w, c.Columns) + w.WriteByte(' ') + w.WriteString(kw("SET")) + w.WriteByte(' ') + w.WriteString(QuoteName(c.Set)) + if c.To != nil && c.Default != nil { + w.WriteByte(' ') + w.WriteString(kw("TO")) + w.WriteByte(' ') + w.WriteString(exprSQL(c.To)) + w.WriteByte(' ') + w.WriteString(kw("DEFAULT")) + w.WriteByte(' ') + w.WriteString(exprSQL(c.Default)) + } + if c.Using != "" { + w.WriteByte(' ') + w.WriteString(kw("USING")) + w.WriteByte(' ') + w.WriteString(QuoteName(c.Using)) + } + } +} + +func writeQuotedNameList(w textWriter, names []string) { + for i, n := range names { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(QuoteName(n)) + } +} diff --git a/pkg/sql/ast/pivot_ext.go b/pkg/sql/ast/pivot_ext.go new file mode 100644 index 00000000..fc260f9b --- /dev/null +++ b/pkg/sql/ast/pivot_ext.go @@ -0,0 +1,252 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import "strings" + +// PivotAggregate is one aggregate of a PIVOT clause with its optional alias: +// SUM(amount) AS total. +type PivotAggregate struct { + Expr Expression + Alias string +} + +// PivotExtensions holds the PIVOT forms beyond a single aggregate and a +// literal IN list: +// +// Oracle: PIVOT (SUM(c) AS total, COUNT(*) AS cnt FOR a IN (1 AS x, 2 AS y)) +// Oracle: PIVOT XML (SUM(c) FOR a IN (ANY)) +// Snowflake: PIVOT (AVG(a) FOR q IN (ANY ORDER BY q)), IN (SELECT ...) +type PivotExtensions struct { + // Aggregates lists every aggregate when there is more than one or one + // has an alias; AggregateFunction is then Aggregates[0].Expr. + Aggregates []PivotAggregate + // XML is the Oracle PIVOT XML form. + XML bool + // InAny is IN (ANY [ORDER BY ...]) in place of a value list. + InAny bool + // InAnyOrderBy is the Snowflake ORDER BY of IN (ANY ORDER BY ...). + InAnyOrderBy []OrderByExpression + // InSubquery is IN (SELECT ...) in place of a value list. + InSubquery Statement + // SourceAlias is the alias of the pivoted table or derived table: + // (SELECT ...) s PIVOT (...) AS p. The PIVOT alias stays in + // TableReference.Alias. + SourceAlias string +} + +func (e *PivotExtensions) pivotExtensionChildren(nodes []Node) []Node { + for i := 1; i < len(e.Aggregates); i++ { + if e.Aggregates[i].Expr != nil { + nodes = append(nodes, e.Aggregates[i].Expr) + } + } + for i := range e.InAnyOrderBy { + nodes = append(nodes, &e.InAnyOrderBy[i]) + } + if e.InSubquery != nil { + nodes = append(nodes, e.InSubquery) + } + return nodes +} + +// PivotSQL returns " PIVOT (...)" for p with keywords passed through kw +// (nil keeps them upper case), or "" for nil. +func PivotSQL(p *PivotClause, kw func(string) string) string { + if p == nil { + return "" + } + if kw == nil { + kw = identityKeyword + } + var sb strings.Builder + writePivot(&sb, p, kw) + return sb.String() +} + +// UnpivotSQL returns " UNPIVOT (...)" for u with keywords passed through kw +// (nil keeps them upper case), or "" for nil. +func UnpivotSQL(u *UnpivotClause, kw func(string) string) string { + if u == nil { + return "" + } + if kw == nil { + kw = identityKeyword + } + var sb strings.Builder + writeUnpivot(&sb, u, kw) + return sb.String() +} + +// TableRefSourceAliasSQL returns " alias" for the alias of the table that a +// PIVOT / UNPIVOT applies to, or "". +func TableRefSourceAliasSQL(t *TableReference) string { + alias := tableRefSourceAlias(t) + if alias == "" { + return "" + } + return " " + QuoteTableAlias(alias) +} + +func tableRefSourceAlias(t *TableReference) string { + if t == nil { + return "" + } + if t.Pivot != nil { + return t.Pivot.SourceAlias + } + if t.Unpivot != nil { + return t.Unpivot.SourceAlias + } + return "" +} + +func writePivot(w textWriter, p *PivotClause, kw func(string) string) { + w.WriteByte(' ') + w.WriteString(kw("PIVOT")) + if p.XML { + w.WriteByte(' ') + w.WriteString(kw("XML")) + } + w.WriteString(" (") + if len(p.Aggregates) > 0 { + for i, a := range p.Aggregates { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(exprSQL(a.Expr)) + if a.Alias != "" { + w.WriteByte(' ') + w.WriteString(kw("AS")) + w.WriteByte(' ') + w.WriteString(QuoteName(a.Alias)) + } + } + } else { + w.WriteString(exprSQL(p.AggregateFunction)) + } + w.WriteByte(' ') + w.WriteString(kw("FOR")) + w.WriteByte(' ') + w.WriteString(QuotePivotColumn(p.PivotColumn)) + w.WriteByte(' ') + w.WriteString(kw("IN")) + w.WriteString(" (") + switch { + case p.InAny: + w.WriteString(kw("ANY")) + if len(p.InAnyOrderBy) > 0 { + w.WriteByte(' ') + w.WriteString(kw("ORDER BY")) + w.WriteByte(' ') + for i := range p.InAnyOrderBy { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(exprSQL(p.InAnyOrderBy[i].Expression)) + if !p.InAnyOrderBy[i].Ascending { + w.WriteByte(' ') + w.WriteString(kw("DESC")) + } + } + } + case p.InSubquery != nil: + w.WriteString(stmtSQL(p.InSubquery)) + default: + writePivotItemList(w, p.InValues) + } + w.WriteString("))") +} + +func writeUnpivot(w textWriter, u *UnpivotClause, kw func(string) string) { + w.WriteByte(' ') + w.WriteString(kw("UNPIVOT")) + switch strings.ToUpper(u.NullHandling) { + case "INCLUDE", "EXCLUDE": + w.WriteByte(' ') + w.WriteString(kw(strings.ToUpper(u.NullHandling) + " NULLS")) + } + w.WriteString(" (") + if len(u.ValueColumns) > 1 { + w.WriteByte('(') + for i, c := range u.ValueColumns { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(QuotePivotColumn(c)) + } + w.WriteByte(')') + } else { + w.WriteString(QuotePivotColumn(u.ValueColumn)) + } + w.WriteByte(' ') + w.WriteString(kw("FOR")) + w.WriteByte(' ') + w.WriteString(QuotePivotColumn(u.NameColumn)) + w.WriteByte(' ') + w.WriteString(kw("IN")) + w.WriteString(" (") + writePivotItemList(w, u.InColumns) + w.WriteString("))") +} + +func writePivotItemList(w textWriter, items []string) { + for i, item := range items { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(QuotePivotItem(item)) + } +} + +// pivotTupleLen returns the length of a parenthesized atom list "(a, b)" at +// the start of s (an Oracle multi-column UNPIVOT IN entry), or 0. +func pivotTupleLen(s string) int { + if s == "" || s[0] != '(' { + return 0 + } + i := 1 + for { + n := pivotAtomLen(s[i:]) + if n == 0 { + return 0 + } + i += n + if strings.HasPrefix(s[i:], ", ") { + i += 2 + continue + } + if i < len(s) && s[i] == ')' { + return i + 1 + } + return 0 + } +} + +// releasePivotExtensions releases the expressions only PivotExtensions +// holds: the aggregates after the first (the first is AggregateFunction) +// and the IN (ANY ORDER BY) expressions. +func releasePivotExtensions(p *PivotClause, mode releaseMode) { + for i := 1; i < len(p.Aggregates); i++ { + releaseExpression(p.Aggregates[i].Expr, mode) + } + for i := range p.InAnyOrderBy { + releaseExpression(p.InAnyOrderBy[i].Expression, mode) + } + if p.InSubquery != nil { + releaseStatement(p.InSubquery, mode) + } + p.PivotExtensions = PivotExtensions{} +} diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index db7fb957..2636de2f 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -541,6 +541,7 @@ func releaseTableReference(tr *TableReference, mode releaseMode) { } // Pivot.AggregateFunction is an Expression. if tr.Pivot != nil { + releasePivotExtensions(tr.Pivot, mode) releaseExpression(tr.Pivot.AggregateFunction, mode) tr.Pivot.AggregateFunction = nil tr.Pivot = nil diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index ca3a633a..26d80c6a 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -32,8 +32,9 @@ import ( // reservedNameWords lists the words the parser rejects as a bare name, keyed // in upper case. A true value means the word is rejected bare in every name -// position, including FROM/JOIN table names; false means it is rejected only -// in CTE names and CTE/view column lists. +// position, including FROM/JOIN table names and CTE names (which take the +// same words as a table alias); false means it is rejected only in CTE/view +// column lists. // // The lists were derived by parsing every keyword bare in each of these // positions across all nine supported dialects. A word belongs here only if @@ -51,7 +52,7 @@ var reservedNameWords = map[string]bool{ "CURRENT": true, "DELETE": true, "DESC": true, "DESCRIBE": true, "DISTINCT": true, "DROP": true, "ELSE": true, "END": true, "EXCEPT": true, "EXCLUDE": true, "EXISTS": true, "EXPLAIN": true, "FALSE": true, "FETCH": true, "FILTER": true, - "FINAL": true, "FIRST": true, "FOLLOWING": true, "FOR": true, "FOREIGN": true, + "FIRST": true, "FOLLOWING": true, "FOR": true, "FOREIGN": true, "FROM": true, "FULL": true, "GROUP": true, "GROUPING": true, "GROUPS": true, "HASH": true, "HAVING": true, "IF": true, "ILIKE": true, "IN": true, "INNER": true, "INSERT": true, "INTERSECT": true, "INTERVAL": true, "INTO": true, @@ -69,7 +70,7 @@ var reservedNameWords = map[string]bool{ "UNBOUNDED": true, "UNION": true, "UNIQUE": true, "UPDATE": true, "USING": true, "VALUES": true, "WHEN": true, "WHERE": true, "WITH": true, "WITHIN": true, - // Reserved only in CTE names and CTE/view column lists. + // Reserved only in CTE/view column lists. "COLUMN": false, "DATABASES": false, "DEFAULT": false, "INDEX": false, "KEY": false, "MATCHED": false, "PARTITION": false, "ROWS": false, "SOURCE": false, "TABLE": false, "TABLES": false, "TARGET": false, "VIEW": false, @@ -410,7 +411,7 @@ func QuoteColumnName(name string) string { // there). TestConstraintNameWords keeps the list in step with the parser. var constraintNameWords = map[string]bool{ "ANY": true, "AUTO_INCREMENT": true, "CONNECTOR": true, "DCPROPERTIES": true, - "MEMBER": true, "NOCREATEDB": true, "NOCREATEROLE": true, "NOLOGIN": true, + "FINAL": true, "MEMBER": true, "NOCREATEDB": true, "NOCREATEROLE": true, "NOLOGIN": true, "NOSUPERUSER": true, "OWNER": true, "POLICY": true, "RESET": true, "SOME": true, "UNTIL": true, "URL": true, "VALID": true, } @@ -494,6 +495,13 @@ func writeFunctionName(buf *bytes.Buffer, name string) { if name == "" { return } + if isStageReference(name) { + // Snowflake stage read with options: @stage/path (FILE_FORMAT => ...). + // The space keeps "(" out of the stage path. + buf.WriteString(name) + buf.WriteByte(' ') + return + } multi := strings.IndexByte(name, '.') >= 0 for { dot := strings.IndexByte(name, '.') diff --git a/pkg/sql/ast/reserved_name_words_test.go b/pkg/sql/ast/reserved_name_words_test.go index 2d1a914d..6d33d445 100644 --- a/pkg/sql/ast/reserved_name_words_test.go +++ b/pkg/sql/ast/reserved_name_words_test.go @@ -44,7 +44,8 @@ func TestReservedNameWordsRejectedBare(t *testing.T) { {"from_table", "SELECT a FROM %s", true}, {"cte_column", "WITH c (%s) AS (SELECT 1) SELECT * FROM c", false}, {"view_column", "CREATE VIEW v (%s) AS SELECT 1", false}, - {"cte_name", "WITH %s AS (SELECT 1) SELECT * FROM x", false}, + // A CTE name takes the same words as a table name. + {"cte_name", "WITH %s AS (SELECT 1) SELECT * FROM x", true}, } for word, inAll := range words { w := strings.ToLower(word) diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 7b2573ff..0d47ada5 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -2266,6 +2266,9 @@ func writeTableRef(buf *bytes.Buffer, t *TableReference) { buf.WriteByte(' ') writeTableAlias(buf, t.Alias) writeTableColumnAliases(buf, t) + } else if alias := tableRefSourceAlias(t); alias != "" { + buf.WriteByte(' ') + writeTableAlias(buf, alias) } writeTableRefPostAlias(buf, t, identityKeyword) // Parser order: table hints, then PIVOT/UNPIVOT, then its AS alias (#604). @@ -2278,6 +2281,7 @@ func writeTableRef(buf *bytes.Buffer, t *TableReference) { if t.Alias != "" && tableAliasFollowsClause(t) { buf.WriteString(" AS ") writeTableAlias(buf, t.Alias) + writeTableColumnAliases(buf, t) } } @@ -2448,10 +2452,12 @@ func writeCTE(buf *bytes.Buffer, cte *CommonTableExpr) { if cte.ScalarExpr != nil { writeExpr(buf, cte.ScalarExpr) buf.WriteString(" AS ") - writeName(buf, cte.Name) + writeTableAlias(buf, cte.Name) return } - writeName(buf, cte.Name) + // A CTE name is quoted like a table alias, so position-only words + // (WITH source AS ...) stay bare and keep their case semantics. + writeTableAlias(buf, cte.Name) if len(cte.Columns) > 0 { buf.WriteString(" (") writeNameList(buf, cte.Columns) @@ -2468,6 +2474,7 @@ func writeCTE(buf *bytes.Buffer, cte *CommonTableExpr) { buf.WriteByte('(') writeStmt(buf, cte.Statement) buf.WriteByte(')') + writeCTESearchCycle(buf, cte, identityKeyword) } func writeOnConflict(buf *bytes.Buffer, oc *OnConflict) { diff --git a/pkg/sql/ast/sql_table_hints_pivot.go b/pkg/sql/ast/sql_table_hints_pivot.go index 385de262..937abb20 100644 --- a/pkg/sql/ast/sql_table_hints_pivot.go +++ b/pkg/sql/ast/sql_table_hints_pivot.go @@ -122,18 +122,13 @@ func QuotePivotItem(item string) string { return buf.String() } -func writePivotItem(buf *bytes.Buffer, item string) { - if isWellFormedPivotItem(item) { - buf.WriteString(item) - return - } - writeQuotedIdentifier(buf, item) -} - // isWellFormedPivotItem reports whether item is "atom" or "atom AS atom" // (see QuotePivotItem). func isWellFormedPivotItem(item string) bool { n := pivotAtomLen(item) + if n == 0 { + n = pivotTupleLen(item) + } if n == 0 { return false } @@ -215,18 +210,7 @@ func writePivotClause(buf *bytes.Buffer, p *PivotClause) { if p == nil { return } - buf.WriteString(" PIVOT (") - writeExpr(buf, p.AggregateFunction) - buf.WriteString(" FOR ") - writePivotColumn(buf, p.PivotColumn) - buf.WriteString(" IN (") - for i, v := range p.InValues { - if i > 0 { - buf.WriteString(", ") - } - writePivotItem(buf, v) - } - buf.WriteString("))") + writePivot(buf, p, identityKeyword) } // writeUnpivotClause writes " UNPIVOT (val FOR name IN (c1, c2))", quoting @@ -235,16 +219,5 @@ func writeUnpivotClause(buf *bytes.Buffer, u *UnpivotClause) { if u == nil { return } - buf.WriteString(" UNPIVOT (") - writePivotColumn(buf, u.ValueColumn) - buf.WriteString(" FOR ") - writePivotColumn(buf, u.NameColumn) - buf.WriteString(" IN (") - for i, c := range u.InColumns { - if i > 0 { - buf.WriteString(", ") - } - writePivotItem(buf, c) - } - buf.WriteString("))") + writeUnpivot(buf, u, identityKeyword) } diff --git a/pkg/sql/ast/table_factor_test.go b/pkg/sql/ast/table_factor_test.go index eac24e21..250ee927 100644 --- a/pkg/sql/ast/table_factor_test.go +++ b/pkg/sql/ast/table_factor_test.go @@ -136,3 +136,29 @@ func TestSetOperationKeyword(t *testing.T) { } } } + +// TestReleaseTableReference_PivotExtensions checks that releasing a PIVOT +// with several aggregates releases each aggregate once and clears the +// extensions. +func TestReleaseTableReference_PivotExtensions(t *testing.T) { + first := &FunctionCall{Name: "SUM", Arguments: []Expression{&Identifier{Name: "a"}}} + second := &FunctionCall{Name: "COUNT", Arguments: []Expression{&Identifier{Name: "b"}}} + pivot := &PivotClause{ + AggregateFunction: first, + PivotColumn: "k", + PivotExtensions: PivotExtensions{ + Aggregates: []PivotAggregate{{Expr: first, Alias: "s"}, {Expr: second, Alias: "c"}}, + InAny: true, + InAnyOrderBy: []OrderByExpression{{Expression: &Identifier{Name: "k"}}}, + SourceAlias: "src", + }, + } + ref := TableReference{Name: "t", Pivot: pivot} + releaseTableReference(&ref, releaseAll) + if ref.Pivot != nil { + t.Fatal("Pivot not cleared") + } + if !reflect.DeepEqual(pivot.PivotExtensions, PivotExtensions{}) { + t.Errorf("PivotExtensions not cleared: %+v", pivot.PivotExtensions) + } +} diff --git a/pkg/sql/parser/cte.go b/pkg/sql/parser/cte.go index 1b55b6c4..a1372a5c 100644 --- a/pkg/sql/parser/cte.go +++ b/pkg/sql/parser/cte.go @@ -138,7 +138,7 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { // ClickHouse scalar CTE: WITH AS , ... // Detected when the token after WITH is not an identifier, or is an // identifier not followed by AS/( (which would be a standard CTE). - if p.dialect == string(keywords.DialectClickHouse) && !p.isIdentifier() { + if p.dialect == string(keywords.DialectClickHouse) && !p.isIdentifier() && !p.isCTENameStart() { scalarExpr, err := p.parseExpression() if err != nil { return nil, err @@ -160,8 +160,9 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { }, nil } - // Parse CTE name (supports double-quoted identifiers) - if !p.isIdentifier() { + // Parse CTE name (supports double-quoted identifiers and keywords + // used as names: WITH source AS (...), WITH final AS (...)) + if !p.isIdentifier() && !p.isCTENameStart() { return nil, p.expectedError("CTE name") } cteNamePos := p.currentLocation() @@ -263,13 +264,18 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { } p.advance() // Consume ) - return &ast.CommonTableExpr{ + cte := &ast.CommonTableExpr{ Name: name, Columns: columns, Statement: stmt, Materialized: materialized, Pos: cteNamePos, - }, nil + } + // SQL:1999 SEARCH / CYCLE clauses of a recursive CTE. + if err := p.parseCTESearchCycle(cte); err != nil { + return nil, err + } + return cte, nil } // parseMainStatementAfterWith parses the main statement after WITH clause. diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 58de851c..0ea5de83 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -1234,6 +1234,10 @@ func (p *Parser) isNonReservedKeyword() bool { case "TARGET", "SOURCE", "MATCHED", "VALUE", "NAME", "TYPE", "STATUS", "TABLES", "DATABASES": return true + case "FINAL": + // A table modifier only in ClickHouse; a plain name elsewhere + // (dbt's WITH final AS (...) SELECT * FROM final). + return !p.IsClickHouse() } } return false diff --git a/pkg/sql/parser/pivot.go b/pkg/sql/parser/pivot.go index bc9ed2fd..2e180dc1 100644 --- a/pkg/sql/parser/pivot.go +++ b/pkg/sql/parser/pivot.go @@ -132,15 +132,22 @@ func (p *Parser) isUnpivotKeyword() bool { func (p *Parser) parsePivotClause() (*ast.PivotClause, error) { pos := p.currentLocation() p.advance() // consume PIVOT + clause := &ast.PivotClause{Pos: pos} + + // Oracle PIVOT XML + if p.IsOracle() && p.isTokenMatch("XML") && p.peekToken().Token.Type == models.TokenTypeLParen { + clause.XML = true + p.advance() // consume XML + } if !p.isType(models.TokenTypeLParen) { return nil, p.expectedError("( after PIVOT") } p.advance() // consume ( - // Parse aggregate function expression (e.g. SUM(sales)) - aggFunc, err := p.parseExpression() - if err != nil { + // Parse the aggregate function expressions (e.g. SUM(sales)), each with + // an optional alias (Oracle: SUM(c) AS total, COUNT(*) AS cnt). + if err := p.parsePivotAggregates(clause); err != nil { return nil, err } @@ -169,6 +176,13 @@ func (p *Parser) parsePivotClause() (*ast.PivotClause, error) { } p.advance() // consume ( + clause.PivotColumn = pivotCol + if handled, err := p.parsePivotInSpecial(clause); err != nil { + return nil, err + } else if handled { + return clause, nil + } + // Parse IN values — identifiers, numbers, or string literals, each with // an optional AS alias (Oracle syntax: 'North' AS north). var inValues []string @@ -205,12 +219,8 @@ func (p *Parser) parsePivotClause() (*ast.PivotClause, error) { } p.advance() // close PIVOT ) - return &ast.PivotClause{ - AggregateFunction: aggFunc, - PivotColumn: pivotCol, - InValues: inValues, - Pos: pos, - }, nil + clause.InValues = inValues + return clause, nil } // parseUnpivotClause parses UNPIVOT (value_col FOR name_col IN (columns)). @@ -219,17 +229,36 @@ func (p *Parser) parseUnpivotClause() (*ast.UnpivotClause, error) { pos := p.currentLocation() p.advance() // consume UNPIVOT + // Oracle / Snowflake: UNPIVOT {INCLUDE | EXCLUDE} NULLS + nullHandling := "" + if (p.isTokenMatch("INCLUDE") || p.isTokenMatch("EXCLUDE")) && + equalsUpper(p.peekToken().Token.Value, "NULLS") { + nullHandling = strings.ToUpper(p.currentToken.Token.Value) + p.advance() // consume INCLUDE / EXCLUDE + p.advance() // consume NULLS + } + if !p.isType(models.TokenTypeLParen) { return nil, p.expectedError("( after UNPIVOT") } p.advance() // consume ( - // Parse value column name - if !p.isIdentifier() { - return nil, p.expectedError("value column name in UNPIVOT") + // Parse value column name, or an Oracle column list: (units, revenue) + var valueCols []string + if p.IsOracle() && p.isType(models.TokenTypeLParen) { + cols, err := p.parseParenthesizedWordList("value column name in UNPIVOT") + if err != nil { + return nil, err + } + valueCols = cols + } else { + if !p.isIdentifier() { + return nil, p.expectedError("value column name in UNPIVOT") + } + valueCols = []string{p.currentToken.Token.Value} + p.advance() } - valueCol := p.currentToken.Token.Value - p.advance() + valueCol := valueCols[0] // Expect FOR keyword if !p.isType(models.TokenTypeFor) { @@ -260,11 +289,21 @@ func (p *Parser) parseUnpivotClause() (*ast.UnpivotClause, error) { // north_sales AS 'North'). var cols []string for !p.isType(models.TokenTypeRParen) && !p.isType(models.TokenTypeEOF) { - if !p.isIdentifier() { - return nil, p.expectedError("column name in UNPIVOT IN list") + var col string + if len(valueCols) > 1 && p.isType(models.TokenTypeLParen) { + // Multi-column UNPIVOT entry: (u1, r1) AS 'P1' + tuple, err := p.parsePivotTuple() + if err != nil { + return nil, err + } + col = tuple + } else { + if !p.isIdentifier() { + return nil, p.expectedError("column name in UNPIVOT IN list") + } + col = renderQuotedIdent(p.currentToken.Token) + p.advance() } - col := renderQuotedIdent(p.currentToken.Token) - p.advance() // Optional alias: AS if p.isType(models.TokenTypeAs) { p.advance() // consume AS @@ -292,12 +331,17 @@ func (p *Parser) parseUnpivotClause() (*ast.UnpivotClause, error) { } p.advance() // close UNPIVOT ) - return &ast.UnpivotClause{ - ValueColumn: valueCol, - NameColumn: nameCol, - InColumns: cols, - Pos: pos, - }, nil + clause := &ast.UnpivotClause{ + ValueColumn: valueCol, + NameColumn: nameCol, + InColumns: cols, + Pos: pos, + NullHandling: nullHandling, + } + if len(valueCols) > 1 { + clause.ValueColumns = valueCols + } + return clause, nil } // supportsTableFunction reports whether the current dialect allows diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index 1d7fb9be..b28011f2 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -78,10 +78,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { for p.isType(models.TokenTypeDiv) { stageName += "/" p.advance() - if p.isIdentifier() || p.isType(models.TokenTypeKeyword) { - stageName += p.currentToken.Token.Value - p.advance() - } + stageName += p.parseStagePathSegment() } tableRef = ast.TableReference{ Name: stageName, @@ -97,6 +94,14 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } tableRef.TableFunc = funcCall } + } else if p.IsSnowflake() && p.isType(models.TokenTypeValues) && + p.peekToken().Token.Type == models.TokenTypeLParen { + // Snowflake: FROM VALUES (1, 'a'), (2, 'b') AS v (id, name) + values, err := p.parseValuesQuery() + if err != nil { + return tableRef, err + } + tableRef = ast.TableReference{Lateral: isLateral, Values: values} } else if p.isClickHouseKeywordTableFunction() { // ClickHouse table function named by a keyword token: url(...), // merge(...), values(...), null(...). @@ -192,24 +197,9 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } } - // SQL Server / Oracle PIVOT clause - if p.isPivotKeyword() { - pivot, err := p.parsePivotClause() - if err != nil { - return tableRef, err - } - tableRef.Pivot = pivot - p.parsePivotAlias(&tableRef) - } - - // SQL Server / Oracle UNPIVOT clause - if p.isUnpivotKeyword() { - unpivot, err := p.parseUnpivotClause() - if err != nil { - return tableRef, err - } - tableRef.Unpivot = unpivot - p.parsePivotAlias(&tableRef) + // SQL Server / Oracle / Snowflake PIVOT or UNPIVOT clause + if err := p.parsePivotSuffix(&tableRef); err != nil { + return tableRef, err } // Snowflake / Oracle MATCH_RECOGNIZE clause @@ -376,24 +366,9 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error } } - // SQL Server / Oracle PIVOT clause - if p.isPivotKeyword() { - pivot, err := p.parsePivotClause() - if err != nil { - return ref, err - } - ref.Pivot = pivot - p.parsePivotAlias(&ref) - } - - // SQL Server / Oracle UNPIVOT clause - if p.isUnpivotKeyword() { - unpivot, err := p.parseUnpivotClause() - if err != nil { - return ref, err - } - ref.Unpivot = unpivot - p.parsePivotAlias(&ref) + // SQL Server / Oracle / Snowflake PIVOT or UNPIVOT clause + if err := p.parsePivotSuffix(&ref); err != nil { + return ref, err } return ref, nil diff --git a/pkg/sql/parser/table_factor.go b/pkg/sql/parser/table_factor.go index 4ac0c68e..b7eac84d 100644 --- a/pkg/sql/parser/table_factor.go +++ b/pkg/sql/parser/table_factor.go @@ -611,3 +611,273 @@ func (p *Parser) acceptsTableFunctionCall() bool { return p.IsSQLServer() || p.IsSQLite() || p.IsOracle() || p.IsMySQL() || p.IsMariaDB() || p.IsRedshift() } + +// isCTENameStart reports whether the current token can name a CTE: any +// token accepted as an alias, so non-reserved keywords work as in dbt's +// WITH source AS (...), final AS (...). +func (p *Parser) isCTENameStart() bool { + return p.canBeAlias() +} + +// parseCTESearchCycle parses the optional SEARCH and CYCLE clauses after a +// CTE's query: +// +// SEARCH {DEPTH | BREADTH} FIRST BY col [, ...] SET seq_col +// CYCLE col [, ...] SET mark_col [TO value DEFAULT value] [USING path_col] +func (p *Parser) parseCTESearchCycle(cte *ast.CommonTableExpr) error { + if p.isTokenMatch("SEARCH") && (equalsUpper(p.peekToken().Token.Value, "DEPTH") || + equalsUpper(p.peekToken().Token.Value, "BREADTH")) { + search := &ast.CTESearchClause{Pos: p.currentLocation()} + p.advance() // Consume SEARCH + search.Breadth = equalsUpper(p.currentToken.Token.Value, "BREADTH") + p.advance() // Consume DEPTH / BREADTH + if !p.isTokenMatch("FIRST") { + return p.expectedError("FIRST after SEARCH DEPTH / BREADTH") + } + p.advance() // Consume FIRST + if !p.isType(models.TokenTypeBy) { + return p.expectedError("BY after SEARCH ... FIRST") + } + p.advance() // Consume BY + cols, err := p.parseCTEClauseColumns() + if err != nil { + return err + } + search.By = cols + set, err := p.parseCTEClauseSet() + if err != nil { + return err + } + search.Set = set + cte.Search = search + } + if p.isTokenMatch("CYCLE") && p.peekToken().Token.Type != models.TokenTypeLParen { + cycle := &ast.CTECycleClause{Pos: p.currentLocation()} + p.advance() // Consume CYCLE + cols, err := p.parseCTEClauseColumns() + if err != nil { + return err + } + cycle.Columns = cols + set, err := p.parseCTEClauseSet() + if err != nil { + return err + } + cycle.Set = set + if p.isTokenMatch("TO") { + p.advance() // Consume TO + to, err := p.parsePrimaryExpression() + if err != nil { + return err + } + if !p.isType(models.TokenTypeDefault) && !p.isTokenMatch("DEFAULT") { + return p.expectedError("DEFAULT in CYCLE clause") + } + p.advance() // Consume DEFAULT + def, err := p.parsePrimaryExpression() + if err != nil { + return err + } + cycle.To, cycle.Default = to, def + } + if p.isType(models.TokenTypeUsing) { + p.advance() // Consume USING + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return p.expectedError("path column after USING") + } + cycle.Using = p.currentToken.Token.Value + p.advance() + } + cte.Cycle = cycle + } + return nil +} + +// parseCTEClauseColumns parses "col [, col ...]" of a SEARCH / CYCLE clause. +func (p *Parser) parseCTEClauseColumns() ([]string, error) { + var cols []string + for { + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("column name") + } + cols = append(cols, p.currentToken.Token.Value) + p.advance() + if !p.isType(models.TokenTypeComma) { + return cols, nil + } + p.advance() // Consume , + } +} + +// parseCTEClauseSet parses "SET column" of a SEARCH / CYCLE clause. +func (p *Parser) parseCTEClauseSet() (string, error) { + if !p.isType(models.TokenTypeSet) && !p.isTokenMatch("SET") { + return "", p.expectedError("SET") + } + p.advance() // Consume SET + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return "", p.expectedError("column name after SET") + } + col := p.currentToken.Token.Value + p.advance() + return col, nil +} + +// parseStagePathSegment consumes one segment of a Snowflake stage path after +// a "/": a word or number, optionally with dotted parts (data.csv, +// part.2024.json). It returns "" when no segment follows (a trailing "/"). +func (p *Parser) parseStagePathSegment() string { + isPart := func() bool { + return p.isIdentifier() || p.isType(models.TokenTypeKeyword) || + p.isType(models.TokenTypeNumber) || (p.isWordToken() && !p.isStringLiteral()) + } + if !isPart() { + return "" + } + seg := p.currentToken.Token.Value + p.advance() + for p.isType(models.TokenTypePeriod) { + next := p.peekToken().Token + if next.Type != models.TokenTypeIdentifier && next.Type != models.TokenTypeKeyword && + next.Type != models.TokenTypeNumber { + break + } + p.advance() // Consume . + seg += "." + p.currentToken.Token.Value + p.advance() + } + return seg +} + +// parsePivotAggregates parses the aggregate list of a PIVOT clause up to FOR: +// agg [[AS] alias] [, agg [[AS] alias] ...]. AggregateFunction is the first +// aggregate; Aggregates lists all of them when there is more than one or an +// alias. +func (p *Parser) parsePivotAggregates(clause *ast.PivotClause) error { + var aggs []ast.PivotAggregate + named := false + for { + expr, err := p.parseExpression() + if err != nil { + return err + } + agg := ast.PivotAggregate{Expr: expr} + if p.isType(models.TokenTypeAs) { + p.advance() // consume AS + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return p.expectedError("alias after AS in PIVOT") + } + agg.Alias = p.currentToken.Token.Value + p.advance() + } else if p.isIdentifier() { + agg.Alias = p.currentToken.Token.Value + p.advance() + } + named = named || agg.Alias != "" + aggs = append(aggs, agg) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // consume , + } + clause.AggregateFunction = aggs[0].Expr + if len(aggs) > 1 || named { + clause.Aggregates = aggs + } + return nil +} + +// parsePivotInSpecial parses a PIVOT IN list that is ANY [ORDER BY ...] or a +// subquery, with the current token after "IN (", through the closing ")" of +// the clause. It reports false when the list is a plain value list. +func (p *Parser) parsePivotInSpecial(clause *ast.PivotClause) (bool, error) { + switch { + case p.isType(models.TokenTypeAny): + clause.InAny = true + p.advance() // consume ANY + if p.isType(models.TokenTypeOrder) { + order, err := p.parseOrderByClause() + if err != nil { + return false, err + } + clause.InAnyOrderBy = order + } + case p.isAnyType(models.TokenTypeSelect, models.TokenTypeWith): + q, err := p.parseQueryExpression("SELECT in PIVOT IN list") + if err != nil { + return false, err + } + clause.InSubquery = q + default: + return false, nil + } + if !p.isType(models.TokenTypeRParen) { + return false, p.expectedError(") to close PIVOT IN list") + } + p.advance() // close IN list ) + if !p.isType(models.TokenTypeRParen) { + return false, p.expectedError(") to close PIVOT clause") + } + p.advance() // close PIVOT ) + return true, nil +} + +// parsePivotTuple parses an Oracle multi-column UNPIVOT IN entry "(a, b)" +// and returns it in source form. +func (p *Parser) parsePivotTuple() (string, error) { + p.advance() // consume ( + var sb strings.Builder + sb.WriteByte('(') + for i := 0; ; i++ { + if !p.isIdentifier() { + return "", p.expectedError("column name in UNPIVOT IN list") + } + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(renderQuotedIdent(p.currentToken.Token)) + p.advance() + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // consume , + } + if !p.isType(models.TokenTypeRParen) { + return "", p.expectedError(") in UNPIVOT IN list") + } + p.advance() // consume ) + sb.WriteByte(')') + return sb.String(), nil +} + +// parsePivotSuffix parses the PIVOT / UNPIVOT clause of a table reference +// with its alias and optional column alias list. An alias already read for +// the table becomes the clause's source alias: (SELECT ...) s PIVOT (...) p. +func (p *Parser) parsePivotSuffix(ref *ast.TableReference) error { + if p.isPivotKeyword() { + pivot, err := p.parsePivotClause() + if err != nil { + return err + } + pivot.SourceAlias, ref.Alias = ref.Alias, "" + ref.Pivot = pivot + } else if p.isUnpivotKeyword() { + unpivot, err := p.parseUnpivotClause() + if err != nil { + return err + } + unpivot.SourceAlias, ref.Alias = ref.Alias, "" + ref.Unpivot = unpivot + } else { + return nil + } + if ref.ColumnAliases != nil { + // A column list of the source alias has no PIVOT form to keep. + return p.expectedError("PIVOT or UNPIVOT after a table alias without a column list") + } + p.parsePivotAlias(ref) + if ref.Alias != "" { + return p.parseTableColumnAliases(ref) + } + return nil +} diff --git a/pkg/sql/parser/table_factor_test.go b/pkg/sql/parser/table_factor_test.go index 33351320..242242ef 100644 --- a/pkg/sql/parser/table_factor_test.go +++ b/pkg/sql/parser/table_factor_test.go @@ -360,3 +360,109 @@ func TestNamedArgumentKeywordNames(t *testing.T) { t.Errorf("names = %v", names) } } + +func TestCTEKeywordNamesAndSearchCycle(t *testing.T) { + sel, ok := parseOne(t, keywords.DialectSnowflake, + "WITH source AS (SELECT 1 AS a), final AS (SELECT a FROM source) SELECT * FROM final").(*ast.SelectStatement) + if !ok || sel.With == nil || len(sel.With.CTEs) != 2 { + t.Fatal("want a SELECT with two CTEs") + } + if sel.With.CTEs[0].Name != "source" || sel.With.CTEs[1].Name != "final" || sel.From[0].Name != "final" { + t.Errorf("names = %q, %q, from %q", sel.With.CTEs[0].Name, sel.With.CTEs[1].Name, sel.From[0].Name) + } + + sel, ok = parseOne(t, keywords.DialectOracle, + "WITH b (id) AS (SELECT 1 FROM dual) SEARCH BREADTH FIRST BY id SET ord CYCLE id SET c TO 'Y' DEFAULT 'N' SELECT * FROM b").(*ast.SelectStatement) + if !ok || sel.With == nil || len(sel.With.CTEs) != 1 { + t.Fatal("want a SELECT with one CTE") + } + cte := sel.With.CTEs[0] + if cte.Search == nil || !cte.Search.Breadth || !reflect.DeepEqual(cte.Search.By, []string{"id"}) || cte.Search.Set != "ord" { + t.Errorf("Search = %+v", cte.Search) + } + if cte.Cycle == nil || !reflect.DeepEqual(cte.Cycle.Columns, []string{"id"}) || cte.Cycle.Set != "c" || + cte.Cycle.To == nil || cte.Cycle.Default == nil || cte.Cycle.Using != "" { + t.Errorf("Cycle = %+v", cte.Cycle) + } + + // Reserved words are still rejected as CTE names. + if tree, err := gosqlx.ParseWithDialect("WITH select AS (SELECT 1) SELECT 1", keywords.DialectPostgreSQL); err == nil { + ast.ReleaseAST(tree) + t.Error("WITH select AS (...) parsed") + } + // FINAL stays a table modifier in ClickHouse. + if tree, err := gosqlx.ParseWithDialect("SELECT * FROM final", keywords.DialectClickHouse); err == nil { + ast.ReleaseAST(tree) + t.Error("ClickHouse FROM final parsed as a table name") + } +} + +func TestPivotExtensions(t *testing.T) { + cases := []struct { + name string + dialect keywords.SQLDialect + sql string + check func(t *testing.T, ref *ast.TableReference) + }{ + {"aggregates with aliases", keywords.DialectOracle, "SELECT * FROM t PIVOT (SUM(c) AS total, COUNT(*) cnt FOR a IN (1 AS x))", func(t *testing.T, ref *ast.TableReference) { + p := ref.Pivot + if p == nil || len(p.Aggregates) != 2 || p.Aggregates[0].Alias != "total" || p.Aggregates[1].Alias != "cnt" || + p.AggregateFunction != p.Aggregates[0].Expr { + t.Errorf("got %+v", p) + } + }}, + {"single aggregate keeps the simple form", keywords.DialectSQLServer, "SELECT * FROM t PIVOT (SUM(a) FOR q IN ([Q1])) AS p", func(t *testing.T, ref *ast.TableReference) { + if ref.Pivot == nil || ref.Pivot.Aggregates != nil || ref.Pivot.AggregateFunction == nil || ref.Alias != "p" { + t.Errorf("got %+v alias %q", ref.Pivot, ref.Alias) + } + }}, + {"source alias", keywords.DialectSQLServer, "SELECT * FROM (SELECT a, q FROM t) s PIVOT (SUM(a) FOR q IN ([Q1])) AS p", func(t *testing.T, ref *ast.TableReference) { + if ref.Pivot == nil || ref.Pivot.SourceAlias != "s" || ref.Alias != "p" { + t.Errorf("got source %+v alias %q", ref.Pivot, ref.Alias) + } + }}, + {"xml any", keywords.DialectOracle, "SELECT * FROM t PIVOT XML (SUM(a) FOR p IN (ANY))", func(t *testing.T, ref *ast.TableReference) { + if ref.Pivot == nil || !ref.Pivot.XML || !ref.Pivot.InAny { + t.Errorf("got %+v", ref.Pivot) + } + }}, + {"any order by", keywords.DialectSnowflake, "SELECT * FROM t PIVOT (AVG(a) FOR q IN (ANY ORDER BY q DESC))", func(t *testing.T, ref *ast.TableReference) { + if ref.Pivot == nil || !ref.Pivot.InAny || len(ref.Pivot.InAnyOrderBy) != 1 || ref.Pivot.InAnyOrderBy[0].Ascending { + t.Errorf("got %+v", ref.Pivot) + } + }}, + {"in subquery", keywords.DialectSnowflake, "SELECT * FROM t PIVOT (AVG(a) FOR q IN (SELECT q FROM u))", func(t *testing.T, ref *ast.TableReference) { + if ref.Pivot == nil || ref.Pivot.InSubquery == nil { + t.Errorf("got %+v", ref.Pivot) + } + }}, + {"alias column list", keywords.DialectSnowflake, "SELECT * FROM m PIVOT (SUM(a) FOR q IN ('A')) AS p (k, a)", func(t *testing.T, ref *ast.TableReference) { + if ref.Alias != "p" || ref.ColumnAliases == nil || !reflect.DeepEqual(ref.ColumnAliases.Names, []string{"k", "a"}) { + t.Errorf("got alias %q columns %+v", ref.Alias, ref.ColumnAliases) + } + }}, + {"unpivot nulls and columns", keywords.DialectOracle, "SELECT * FROM w UNPIVOT EXCLUDE NULLS ((u, r) FOR p IN ((u1, r1) AS 'P1'))", func(t *testing.T, ref *ast.TableReference) { + u := ref.Unpivot + if u == nil || u.NullHandling != "EXCLUDE" || !reflect.DeepEqual(u.ValueColumns, []string{"u", "r"}) || + u.ValueColumn != "u" || !reflect.DeepEqual(u.InColumns, []string{"(u1, r1) AS 'P1'"}) { + t.Errorf("got %+v", u) + } + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.check(t, firstFrom(t, parseOne(t, tc.dialect, tc.sql))) + }) + } +} + +func TestSnowflakeFromValuesAndStagePath(t *testing.T) { + ref := firstFrom(t, parseOne(t, keywords.DialectSnowflake, "SELECT * FROM VALUES (1, 'a'), (2, 'b') AS v (id, name)")) + if ref.Values == nil || len(ref.Values.Rows) != 2 || ref.Alias != "v" { + t.Errorf("got %+v", ref) + } + ref = firstFrom(t, parseOne(t, keywords.DialectSnowflake, "SELECT $1 FROM @my_stage/2024/data.csv (FILE_FORMAT => 'f') t")) + if ref.Name != "@my_stage/2024/data.csv" || ref.TableFunc == nil || ref.Alias != "t" { + t.Errorf("got name %q func %v alias %q", ref.Name, ref.TableFunc, ref.Alias) + } +} diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index 16b74c1b..70c9214e 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -263,10 +263,6 @@ oracle/03_ddl_migrations.sql#6 parse oracle/03_ddl_migrations.sql#7 drop oracle/03_ddl_migrations.sql#8 parse oracle/03_ddl_migrations.sql#9 parse -oracle/04_window_analytics.sql#19 parse -oracle/04_window_analytics.sql#22 parse -oracle/04_window_analytics.sql#23 parse -oracle/04_window_analytics.sql#24 parse oracle/05_dialect_specific.sql#19 parse oracle/05_dialect_specific.sql#32 parse oracle/06_etl_merge.sql#10 parse @@ -290,8 +286,6 @@ oracle/07_admin_ops.sql#6 parse oracle/07_admin_ops.sql#7 parse oracle/07_admin_ops.sql#8 parse oracle/07_admin_ops.sql#9 parse -oracle/08_complex_nested.sql#0 parse -oracle/08_complex_nested.sql#15 parse postgresql/03_ddl_migrations.sql#1 parse postgresql/03_ddl_migrations.sql#10 parse postgresql/03_ddl_migrations.sql#12 parse @@ -333,8 +327,6 @@ postgresql/03_ddl_migrations.sql#9 parse postgresql/05_dialect_specific.sql#35 drop postgresql/05_dialect_specific.sql#40 parse postgresql/05_dialect_specific.sql#52 parse -postgresql/06_cte_dml_etl.sql#5 parse -postgresql/06_cte_dml_etl.sql#6 parse postgresql/07_admin_ops.sql#20 parse postgresql/07_admin_ops.sql#41 parse postgresql/07_admin_ops.sql#42 parse @@ -360,7 +352,6 @@ snowflake/03_ddl_migrations.sql#6 parse snowflake/03_ddl_migrations.sql#7 parse snowflake/03_ddl_migrations.sql#8 reparse snowflake/03_ddl_migrations.sql#9 reparse -snowflake/05_dialect_specific.sql#24 parse snowflake/05_dialect_specific.sql#25 parse snowflake/05_dialect_specific.sql#42 drop snowflake/05_dialect_specific.sql#49 parse @@ -388,12 +379,6 @@ snowflake/07_admin.sql#48 parse snowflake/07_admin.sql#49 parse snowflake/07_admin.sql#50 parse snowflake/07_admin.sql#51 parse -snowflake/08_pivot_match.sql#1 parse -snowflake/08_pivot_match.sql#13 parse -snowflake/08_pivot_match.sql#2 parse -snowflake/08_pivot_match.sql#5 parse -snowflake/09_dbt_models.sql#0 parse -snowflake/09_dbt_models.sql#1 parse snowflake/09_dbt_models.sql#12 parse snowflake/09_dbt_models.sql#15 parse snowflake/09_dbt_models.sql#2 parse @@ -432,7 +417,6 @@ sqlite/12_orm_generated.sql#11 drop sqlite/12_orm_generated.sql#12 drop sqlite/12_orm_generated.sql#8 parse sqlite/12_orm_generated.sql#9 drop -sqlserver/02_reporting.sql#21 drop sqlserver/03_ddl_migrations.sql#0 parse sqlserver/03_ddl_migrations.sql#1 parse sqlserver/03_ddl_migrations.sql#10 parse From 989ae703b9c276c0b2277a61ace90bf50ce8522a Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:37:17 +0530 Subject: [PATCH 34/95] fix(ast,parser,formatter,transpiler): keep needed identifier quotes and use target delimiters The AST now records how names stored as plain strings were delimited (ast.NameQuote: which dotted parts must stay delimited, the source delimiter, and an Oracle bare-'#' mark). The parser sets it for identifiers and their qualifiers, select-item and table aliases, table, function, CTE and view names and column lists, INSERT/UPDATE/DELETE and MERGE targets and aliases, SET and MERGE columns, JOIN USING and ON CONFLICT columns, and CREATE TABLE/VIEW names and column definitions. A delimited name is recorded when bare it would mean something else: letter case in a folding dialect (upper case in PostgreSQL/generic, lower case in Oracle/Snowflake), a keyword ("table", "source"), or a niladic/contextual word ("user", "PRIOR"). SQL() and the formatter keep those quotes in the source's delimiters, so "CreatedAt", "Company" aliases, "table", s."select"(1) and a MySQL `exists` alias round-trip; a formatter with a MySQL/MariaDB dialect writes content-required delimiters as backticks. Oracle bare names such as s.serial# and emp# stay bare instead of becoming case-sensitive "serial#". Transpile rewrites every recorded delimiter to the target's (backticks for MySQL/MariaDB, brackets for SQL Server, double quotes otherwise), so MySQL backticks and T-SQL brackets no longer leak into PostgreSQL output. Removes the fixed corpusFormatKnownGaps entries (gorm_joins, mysql and oracle 01_app_crud, postgresql 08_supabase_rls_misc) and updates tests that pinned the old "case-only quotes render bare" behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_corpus_test.go | 6 +- cmd/gosqlx/cmd/format_guard_test.go | 12 +- cmd/gosqlx/cmd/format_quoting_test.go | 61 +++ pkg/formatter/name_quote.go | 73 +++ pkg/formatter/name_quote_test.go | 113 +++++ .../quoted_identifier_render_test.go | 6 +- pkg/formatter/render.go | 32 +- .../render_alias_quoting_632_test.go | 2 +- pkg/formatter/render_dml.go | 6 +- pkg/formatter/render_injection_632_test.go | 4 +- pkg/gosqlx/string_literal_roundtrip_test.go | 2 +- pkg/sql/ast/ast.go | 13 +- pkg/sql/ast/ast_clauses.go | 18 +- pkg/sql/ast/ast_dml.go | 7 + pkg/sql/ast/ast_expressions.go | 7 + pkg/sql/ast/ast_statements.go | 18 + pkg/sql/ast/name_quote.go | 439 ++++++++++++++++++ pkg/sql/ast/name_quote_restyle.go | 100 ++++ pkg/sql/ast/name_quote_test.go | 205 ++++++++ pkg/sql/ast/pool.go | 2 + pkg/sql/ast/pool_statement_release.go | 8 + pkg/sql/ast/quote_names.go | 33 +- pkg/sql/ast/quoted_identifier_render_test.go | 72 +-- pkg/sql/ast/render_alias_quoting_632_test.go | 2 +- .../ast/render_ddl_name_quoting_632_test.go | 4 +- pkg/sql/ast/render_injection_632_test.go | 17 +- pkg/sql/ast/sql.go | 62 +-- pkg/sql/ast/sql_dml.go | 6 +- pkg/sql/parser/connect_by_expressions_test.go | 6 +- pkg/sql/parser/cte.go | 7 + pkg/sql/parser/ddl.go | 3 +- pkg/sql/parser/ddl_columns.go | 7 +- pkg/sql/parser/ddl_view.go | 4 +- pkg/sql/parser/dml_common.go | 23 +- pkg/sql/parser/dml_insert.go | 9 +- pkg/sql/parser/dml_merge.go | 10 +- pkg/sql/parser/expressions_literal.go | 10 +- pkg/sql/parser/expressions_primary_ext.go | 1 + pkg/sql/parser/name_quote.go | 194 ++++++++ pkg/sql/parser/name_quote_test.go | 188 ++++++++ pkg/sql/parser/parser.go | 36 +- pkg/sql/parser/pivot.go | 2 + pkg/sql/parser/select.go | 6 +- pkg/sql/parser/select_clauses.go | 2 +- pkg/sql/parser/select_ext.go | 1 + pkg/sql/parser/select_subquery.go | 16 +- pkg/sql/tokenizer/keyword_lookup.go | 39 ++ pkg/sql/tokenizer/keyword_lookup_test.go | 44 ++ pkg/transpiler/quoting.go | 46 ++ pkg/transpiler/quoting_test.go | 89 ++++ pkg/transpiler/transpiler.go | 1 + 51 files changed, 1888 insertions(+), 186 deletions(-) create mode 100644 cmd/gosqlx/cmd/format_quoting_test.go create mode 100644 pkg/formatter/name_quote.go create mode 100644 pkg/formatter/name_quote_test.go create mode 100644 pkg/sql/ast/name_quote.go create mode 100644 pkg/sql/ast/name_quote_restyle.go create mode 100644 pkg/sql/ast/name_quote_test.go create mode 100644 pkg/sql/parser/name_quote.go create mode 100644 pkg/sql/parser/name_quote_test.go create mode 100644 pkg/sql/tokenizer/keyword_lookup.go create mode 100644 pkg/sql/tokenizer/keyword_lookup_test.go create mode 100644 pkg/transpiler/quoting.go create mode 100644 pkg/transpiler/quoting_test.go diff --git a/cmd/gosqlx/cmd/format_corpus_test.go b/cmd/gosqlx/cmd/format_corpus_test.go index b4dd872b..3b12892b 100644 --- a/cmd/gosqlx/cmd/format_corpus_test.go +++ b/cmd/gosqlx/cmd/format_corpus_test.go @@ -30,11 +30,7 @@ import ( // rendering gap. For these the CLI must refuse to format (return an error) // rather than emit changed SQL; once the gap is fixed they simply pass. var corpusFormatKnownGaps = map[string]string{ - "corpus/orm/gorm_joins.sql": "follow-up: a name quoted only for case (\"Company\") renders bare; the AST does not record quoting, so the CLI refuses", - "realworld/mysql/01_app_crud.sql": "follow-up: a reserved-word alias (`exists`) is written in ANSI double quotes; alias quoting is not dialect-aware", - "realworld/mysql/10_misc_functions.sql": "follow-up: a string literal is rewritten by the formatter", - "realworld/oracle/01_app_crud.sql": "follow-up: a case-sensitive quoted name (\"CreatedAt\") renders bare in some clause", - "realworld/postgresql/08_supabase_rls_misc.sql": "follow-up: a quoted reserved-word name (\"table\") renders bare in some clause", + "realworld/mysql/10_misc_functions.sql": "follow-up: a string literal is rewritten by the formatter", } // corpusDialect maps a testdata path to the dialect its SQL is written in. diff --git a/cmd/gosqlx/cmd/format_guard_test.go b/cmd/gosqlx/cmd/format_guard_test.go index 3e4e1714..e9b6e32e 100644 --- a/cmd/gosqlx/cmd/format_guard_test.go +++ b/cmd/gosqlx/cmd/format_guard_test.go @@ -33,14 +33,10 @@ func TestFormatSQL_RefusesLostQuotes(t *testing.T) { dialect string in string }{ - {name: "alter mixed case", in: `ALTER TABLE t ADD COLUMN "Mixed" INT`}, - // Refused by checkMySQLDoubleQuotes: in ALTER, `select` would render as - // "select". (In a SELECT list checkQuotedTokens refuses it first.) - {name: "alter mysql backtick keyword", dialect: "mysql", in: "ALTER TABLE `Users` ADD COLUMN `select` INT"}, - {name: "select mixed case", in: `SELECT "Mixed" FROM t`}, - {name: "select mixed case table", in: `SELECT a FROM "Mixed"`}, - {name: "create table quoted", in: `CREATE TABLE "Mixed" ("order" INT)`}, - {name: "oracle lowercase quoted", dialect: "oracle", in: `SELECT "abc" FROM t`}, + // Names the AST does not record quoting for (ALTER TABLE targets, + // index names) still render bare, so the CLI must refuse. + {name: "alter table mixed case", in: `ALTER TABLE "Mixed" ADD COLUMN a INT`}, + {name: "create index mixed case", in: `CREATE INDEX "Idx" ON t (a)`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/cmd/gosqlx/cmd/format_quoting_test.go b/cmd/gosqlx/cmd/format_quoting_test.go new file mode 100644 index 00000000..46bd1b6b --- /dev/null +++ b/cmd/gosqlx/cmd/format_quoting_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import "testing" + +// TestFormatSQL_KeepsNeededQuotes checks that the CLI formats input whose +// quoted names need their quotes (letter case, keywords, MySQL backtick +// aliases) and keeps those quotes, where it used to refuse because the +// renderer wrote them bare or in the wrong delimiters. +func TestFormatSQL_KeepsNeededQuotes(t *testing.T) { + tests := []struct { + name string + dialect string + in string + want string + }{ + {name: "select mixed case", in: `SELECT "Mixed" FROM t`, want: `SELECT "Mixed" FROM t`}, + {name: "select mixed case table", in: `SELECT a FROM "Mixed"`, want: `SELECT a FROM "Mixed"`}, + {name: "qualified mixed case alias", in: `SELECT "C"."name" AS "CName" FROM "companies" AS "C"`, + want: `SELECT "C".name AS "CName" FROM companies "C"`}, + {name: "keyword table name", dialect: "postgresql", in: `SELECT 1 FROM "table"`, want: `SELECT 1 FROM "table"`}, + {name: "user column", dialect: "postgresql", in: `SELECT "user" FROM t`, want: `SELECT "user" FROM t`}, + {name: "oracle lowercase quoted", dialect: "oracle", in: `SELECT "abc" FROM t`, want: `SELECT "abc" FROM t`}, + {name: "oracle mixed case", dialect: "oracle", in: `SELECT "ID", "CreatedAt" FROM "AppUsers"`, + want: `SELECT ID, "CreatedAt" FROM "AppUsers"`}, + {name: "oracle bare hash", dialect: "oracle", in: `SELECT s.serial#, emp# FROM v$session s`, + want: `SELECT s.serial#, emp# FROM v$session s`}, + {name: "create table quoted", in: `CREATE TABLE "Mixed" ("order" INT)`, want: `CREATE TABLE "Mixed" ("order" INT)`}, + {name: "alter add mixed case column", in: `ALTER TABLE t ADD COLUMN "Mixed" INT`, want: `ALTER TABLE t ADD COLUMN "Mixed" INT`}, + {name: "mysql backtick reserved alias", dialect: "mysql", in: "SELECT EXISTS(SELECT 1) AS `exists`", + want: "SELECT EXISTS (SELECT 1) AS `exists`"}, + {name: "mysql backtick keyword column", dialect: "mysql", in: "ALTER TABLE `Users` ADD COLUMN `select` INT", + want: "ALTER TABLE Users ADD COLUMN `select` INT"}, + {name: "mysql backtick table alias", dialect: "mysql", in: "SELECT `o`.`id` FROM `order` AS `o`", + want: "SELECT o.id FROM `order` o"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, err := formatWith(tc.in, CLIFormatterOptions{Compact: true, Uppercase: true, Dialect: tc.dialect}) + if err != nil { + t.Fatalf("formatSQL(%q): %v", tc.in, err) + } + if out != tc.want { + t.Fatalf("formatSQL(%q) = %q, want %q", tc.in, out, tc.want) + } + }) + } +} diff --git a/pkg/formatter/name_quote.go b/pkg/formatter/name_quote.go new file mode 100644 index 00000000..d1750645 --- /dev/null +++ b/pkg/formatter/name_quote.go @@ -0,0 +1,73 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter + +import ( + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// dialectDelimiter returns the identifier delimiter a dialect requires +// instead of ANSI double quotes, or 0 when double quotes are fine. MySQL and +// MariaDB read "..." as a string literal unless ANSI_QUOTES is set, so a name +// they need delimited must use backticks. +func dialectDelimiter(dialect string) rune { + switch strings.ToLower(dialect) { + case "mysql", "mariadb": + return '`' + } + return 0 +} + +// nq returns q with the target dialect's delimiter as its default style, so +// a name that needs delimiters only because of its content is written with +// the delimiters the dialect accepts (see dialectDelimiter). A style recorded +// from the source is kept. +func (f *nodeFormatter) nq(q ast.NameQuote) ast.NameQuote { + if d := dialectDelimiter(f.opts.Dialect); d != 0 { + return q.WithDefaultStyle(d) + } + return q +} + +// quoteNamesWith renders a CTE or view column list like quoteNames, +// honouring each name's recorded delimiters (qs by index, may be nil). +func (f *nodeFormatter) quoteNamesWith(names []string, qs []ast.NameQuote) string { + parts := make([]string, len(names)) + for i, n := range names { + var q ast.NameQuote + if i < len(qs) { + q = qs[i] + } + parts[i] = ast.QuoteNameWith(n, f.nq(q)) + } + return strings.Join(parts, ", ") +} + +// quoteColumnNamesWith joins single-part DDL column names like +// quoteColumnNames, honouring each name's recorded delimiters (qs by index, +// may be nil). +func (f *nodeFormatter) quoteColumnNamesWith(names []string, qs []ast.NameQuote) string { + parts := make([]string, len(names)) + for i, n := range names { + var q ast.NameQuote + if i < len(qs) { + q = qs[i] + } + parts[i] = ast.QuoteColumnNameWith(n, f.nq(q)) + } + return strings.Join(parts, ", ") +} diff --git a/pkg/formatter/name_quote_test.go b/pkg/formatter/name_quote_test.go new file mode 100644 index 00000000..80dd6711 --- /dev/null +++ b/pkg/formatter/name_quote_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" +) + +// TestNameQuote_FormatKeepsCaseQuotes checks that FormatStatement keeps the +// quotes a name needs for its letter case where the dialect folds unquoted +// names, in the clauses the formatter renders itself (FROM and JOIN tables +// and aliases, CTE names and columns, DML targets, CREATE TABLE / VIEW), and +// drops them where the dialect does not fold. +func TestNameQuote_FormatKeepsCaseQuotes(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + in string + want string + }{ + {"pg select", keywords.DialectPostgreSQL, + `SELECT "C"."name" AS "CName" FROM "companies" AS "C" JOIN "Users" "U" ON "U".cid = "C".id`, + `SELECT "C".name AS "CName" FROM companies "C" INNER JOIN "Users" "U" ON "U".cid = "C".id`}, + {"pg cte", keywords.DialectPostgreSQL, + `WITH "Cte"("Total") AS (SELECT 1) SELECT "Total" FROM "Cte"`, + `WITH "Cte" ("Total") AS (SELECT 1) SELECT "Total" FROM "Cte"`}, + {"pg update", keywords.DialectPostgreSQL, + `UPDATE "MyTable" AS "T" SET "ColA" = 1 WHERE "T"."ColB" = 2`, + `UPDATE "MyTable" "T" SET "ColA" = 1 WHERE "T"."ColB" = 2`}, + {"pg delete", keywords.DialectPostgreSQL, `DELETE FROM "MyTable" WHERE "Id" = 1`, `DELETE FROM "MyTable" WHERE "Id" = 1`}, + {"pg insert", keywords.DialectPostgreSQL, `INSERT INTO "MyTable" ("ColA") VALUES (1)`, `INSERT INTO "MyTable" ("ColA") VALUES (1)`}, + {"pg merge", keywords.DialectPostgreSQL, + `MERGE INTO t AS "Tgt" USING s AS "Src" ON "Tgt".id = "Src".id WHEN MATCHED THEN UPDATE SET "Val" = 1 WHEN NOT MATCHED THEN INSERT ("Id") VALUES (1)`, + `MERGE INTO t "Tgt" USING s "Src" ON "Tgt".id = "Src".id WHEN MATCHED THEN UPDATE SET "Val" = 1 WHEN NOT MATCHED THEN INSERT ("Id") VALUES (1)`}, + {"pg create table", keywords.DialectPostgreSQL, `CREATE TABLE "MyTable" ("ColA" INT)`, `CREATE TABLE "MyTable" ("ColA" INT)`}, + {"pg create view", keywords.DialectPostgreSQL, `CREATE VIEW "V" ("A") AS SELECT 1`, `CREATE VIEW "V" ("A") AS SELECT 1`}, + {"oracle lower case", keywords.DialectOracle, `SELECT "ID", "id" FROM "T" "t"`, `SELECT ID, "id" FROM T "t"`}, + {"mysql case not needed", keywords.DialectMySQL, "SELECT `Id` FROM `Users` AS `U`", "SELECT Id FROM Users U"}, + {"mysql reserved alias", keywords.DialectMySQL, "SELECT 1 AS `exists` FROM `order` AS `group`", "SELECT 1 AS `exists` FROM `order` `group`"}, + {"sqlserver reserved", keywords.DialectSQLServer, "SELECT [Group] FROM [order] [Key]", "SELECT [Group] FROM [order] [Key]"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := parser.ParseWithDialect(tc.in, tc.dialect) + if err != nil { + t.Fatalf("parse %q: %v", tc.in, err) + } + defer ast.ReleaseAST(tree) + style := ast.CompactStyle() + style.Dialect = string(tc.dialect) + got := FormatStatement(tree.Statements[0], style) + if got != tc.want { + t.Fatalf("FormatStatement mismatch\n got: %s\nwant: %s", got, tc.want) + } + }) + } +} + +// TestNameQuote_FormatDialectDelimiter checks that a name needing delimiters +// because of its content, with no delimiter recorded (a hand-built AST), is +// written with backticks when the target dialect is MySQL or MariaDB, which +// read "..." as a string, and with ANSI double quotes otherwise. +func TestNameQuote_FormatDialectDelimiter(t *testing.T) { + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "a"}}, + From: []ast.TableReference{{Name: "my db.order", Alias: "select"}}, + } + tests := []struct { + dialect string + want string + }{ + {"mysql", "SELECT a FROM `my db`.`order` `select`"}, + {"MariaDB", "SELECT a FROM `my db`.`order` `select`"}, + {"postgresql", `SELECT a FROM "my db"."order" "select"`}, + {"", `SELECT a FROM "my db"."order" "select"`}, + } + for _, tc := range tests { + style := ast.CompactStyle() + style.Dialect = tc.dialect + if got := FormatStatement(stmt, style); got != tc.want { + t.Errorf("dialect %q: got %s, want %s", tc.dialect, got, tc.want) + } + } +} + +// TestNameQuote_FormatNoAllocForPlainNames checks that the dialect-aware name +// quoting does not allocate for names that need no delimiters. +func TestNameQuote_FormatNoAllocForPlainNames(t *testing.T) { + q := ast.NameQuoteOf('`') + if n := testing.AllocsPerRun(100, func() { + _ = ast.QuoteQualifiedNameWith("db.users", q) + _ = ast.QuoteTableAliasWith("u", q) + _ = ast.QuoteNameWith("total", q) + }); n != 0 { + t.Errorf("plain names allocate %v times, want 0", n) + } +} diff --git a/pkg/formatter/quoted_identifier_render_test.go b/pkg/formatter/quoted_identifier_render_test.go index 53e945f3..cf0651fc 100644 --- a/pkg/formatter/quoted_identifier_render_test.go +++ b/pkg/formatter/quoted_identifier_render_test.go @@ -42,8 +42,10 @@ func TestQuotedIdentifier_FormatAST_Issue611(t *testing.T) { "CREATE VIEW v (\"select\") AS\nSELECT 1"}, {"select_reserved", `SELECT "select" FROM t`, "", "SELECT \"select\"\nFROM t"}, - {"select_space_case_table_space", `SELECT "a b", "Mixed" FROM "my table"`, `SELECT "a b", Mixed FROM "my table"`, - "SELECT \"a b\", Mixed\nFROM \"my table\""}, + // Quotes kept for letter case depend on the dialect's folding; see + // TestNameQuote_FormatKeepsCaseQuotes. + {"select_space_table_space", `SELECT "a b", "c d" FROM "my table"`, "", + "SELECT \"a b\", \"c d\"\nFROM \"my table\""}, {"mview_column_reserved", `CREATE MATERIALIZED VIEW mv ("select") AS SELECT 1`, "", "CREATE MATERIALIZED VIEW mv (\"select\") AS\nSELECT 1"}, {"cte_name_reserved", `WITH "select" AS (SELECT 1) SELECT * FROM "select"`, "", diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index febdfe94..80721b7f 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -587,7 +587,7 @@ func renderCreateTable(c *ast.CreateTableStatement, opts ast.FormatOptions) stri sb.WriteString(f.kw("IF NOT EXISTS")) sb.WriteString(" ") } - sb.WriteString(ast.QuoteQualifiedName(c.Name)) + sb.WriteString(ast.QuoteQualifiedNameWith(c.Name, f.nq(c.NameQuote))) if opts.NewlinePerClause { sb.WriteString(" (\n") @@ -845,11 +845,11 @@ func renderCreateView(c *ast.CreateViewStatement, opts ast.FormatOptions) string sb.WriteString(f.kw("IF NOT EXISTS")) sb.WriteString(" ") } - sb.WriteString(ast.QuoteQualifiedName(c.Name)) + sb.WriteString(ast.QuoteQualifiedNameWith(c.Name, f.nq(c.NameQuote))) if len(c.Columns) > 0 { sb.WriteString(" (") - sb.WriteString(quoteNames(c.Columns)) + sb.WriteString(f.quoteNamesWith(c.Columns, c.ColumnQuotes)) sb.WriteString(")") } @@ -1030,7 +1030,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(tableRefSQL(&m.TargetTable, f)) if m.TargetAlias != "" { sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(m.TargetAlias)) + sb.WriteString(ast.QuoteTableAliasWith(m.TargetAlias, f.nq(m.TargetAliasQuote))) } sb.WriteString(f.clauseSep()) @@ -1039,7 +1039,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(tableRefSQL(&m.SourceTable, f)) if m.SourceAlias != "" { sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(m.SourceAlias)) + sb.WriteString(ast.QuoteTableAliasWith(m.SourceAlias, f.nq(m.SourceAliasQuote))) } sb.WriteString(f.clauseSep()) @@ -1083,7 +1083,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sets := make([]string, len(when.Action.SetClauses)) for i, sc := range when.Action.SetClauses { - sets[i] = ast.QuoteQualifiedName(sc.Column) + " = " + exprSQL(sc.Value) + sets[i] = ast.QuoteQualifiedNameWith(sc.Column, f.nq(sc.ColumnQuote)) + " = " + exprSQL(sc.Value) } sb.WriteString(strings.Join(sets, ", ")) case "DELETE": @@ -1096,7 +1096,7 @@ func renderMerge(m *ast.MergeStatement, opts ast.FormatOptions) string { } else { if len(when.Action.Columns) > 0 { sb.WriteString(" (") - sb.WriteString(quoteColumnNames(when.Action.Columns)) + sb.WriteString(f.quoteColumnNamesWith(when.Action.Columns, when.Action.ColumnQuotes)) sb.WriteString(")") } if len(when.Action.Values) > 0 { @@ -1279,9 +1279,9 @@ func renderWith(w *ast.WithClause, f *nodeFormatter) string { } ctes := make([]string, len(w.CTEs)) for i, cte := range w.CTEs { - s := ast.QuoteName(cte.Name) + " " + s := ast.QuoteNameWith(cte.Name, f.nq(cte.NameQuote)) + " " if len(cte.Columns) > 0 { - s += "(" + quoteNames(cte.Columns) + ") " + s += "(" + f.quoteNamesWith(cte.Columns, cte.ColumnQuotes) + ") " } s += f.kw("AS") + " (" s += FormatStatement(cte.Statement, nestedOpts(f.opts)) @@ -1380,18 +1380,22 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { sb.WriteString(stmtSQL(t.SetSubquery)) sb.WriteString(")") } else { - sb.WriteString(ast.QuoteQualifiedName(t.Name)) + sb.WriteString(ast.QuoteQualifiedNameWith(t.Name, f.nq(t.NameQuote))) } if t.Alias != "" && t.Pivot == nil && t.Unpivot == nil { sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(t.Alias)) + sb.WriteString(ast.QuoteTableAliasWith(t.Alias, f.nq(t.AliasQuote))) if t.ColumnAliases != nil && len(t.ColumnAliases.Names) > 0 && t.TableFunc != nil { sb.WriteString("(") for i, c := range t.ColumnAliases.Names { if i > 0 { sb.WriteString(", ") } - sb.WriteString(ast.QuoteColumnName(c)) + var q ast.NameQuote + if i < len(t.ColumnAliases.Quotes) { + q = t.ColumnAliases.Quotes[i] + } + sb.WriteString(ast.QuoteColumnNameWith(c, f.nq(q))) } sb.WriteString(")") } @@ -1451,7 +1455,7 @@ func tableRefSQL(t *ast.TableReference, f *nodeFormatter) string { sb.WriteString(" ") sb.WriteString(f.kw("AS")) sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(t.Alias)) + sb.WriteString(ast.QuoteTableAliasWith(t.Alias, f.nq(t.AliasQuote))) } return sb.String() } @@ -1679,7 +1683,7 @@ func onConflictSQL(oc *ast.OnConflict) string { // columnDefSQL renders a column definition. func columnDefSQL(c *ast.ColumnDef) string { var sb strings.Builder - sb.WriteString(ast.QuoteColumnName(c.Name)) + sb.WriteString(ast.QuoteColumnNameWith(c.Name, c.NameQuote)) sb.WriteString(" ") sb.WriteString(ast.QuoteDataType(c.Type)) for _, con := range c.Constraints { diff --git a/pkg/formatter/render_alias_quoting_632_test.go b/pkg/formatter/render_alias_quoting_632_test.go index d0769886..4ea8bd43 100644 --- a/pkg/formatter/render_alias_quoting_632_test.go +++ b/pkg/formatter/render_alias_quoting_632_test.go @@ -215,7 +215,7 @@ func TestFormat_AliasQuoting_PR632(t *testing.T) { {`SELECT * FROM t AS "u; DROP TABLE x; --"`, "SELECT *\nFROM t \"u; DROP TABLE x; --\""}, {`SELECT amount AS total FROM t`, "SELECT amount AS total\nFROM t"}, {`SELECT * FROM t AS u`, "SELECT *\nFROM t u"}, - {`SELECT amount AS "Total" FROM t ORDER BY "Total"`, "SELECT amount AS Total\nFROM t\nORDER BY Total"}, + {`SELECT amount AS "Total" FROM t ORDER BY "Total"`, "SELECT amount AS \"Total\"\nFROM t\nORDER BY \"Total\""}, {`SELECT amount AS "select" FROM t ORDER BY "select"`, "SELECT amount AS \"select\"\nFROM t\nORDER BY \"select\""}, } for _, tc := range cases { diff --git a/pkg/formatter/render_dml.go b/pkg/formatter/render_dml.go index e28fed6b..fff25f61 100644 --- a/pkg/formatter/render_dml.go +++ b/pkg/formatter/render_dml.go @@ -53,10 +53,10 @@ func writeDMLTargets(sb *strings.Builder, f *nodeFormatter, d *ast.DMLTargetFiel } first := true if name != "" { - sb.WriteString(ast.QuoteQualifiedName(name)) + sb.WriteString(ast.QuoteQualifiedNameWith(name, f.nq(d.TableNameQuote))) if alias != "" { sb.WriteString(" ") - sb.WriteString(ast.QuoteTableAlias(alias)) + sb.WriteString(ast.QuoteTableAliasWith(alias, f.nq(d.AliasQuote))) } first = false } @@ -145,7 +145,7 @@ func writeInsertHead(sb *strings.Builder, f *nodeFormatter, i *ast.InsertStateme sb.WriteString(exprSQL(i.TableFunction)) return } - sb.WriteString(ast.QuoteQualifiedName(i.TableName)) + sb.WriteString(ast.QuoteQualifiedNameWith(i.TableName, f.nq(i.TableNameQuote))) } // writeInsertBeforeSource writes ClickHouse SETTINGS and PostgreSQL diff --git a/pkg/formatter/render_injection_632_test.go b/pkg/formatter/render_injection_632_test.go index c2b3aa1e..327f91be 100644 --- a/pkg/formatter/render_injection_632_test.go +++ b/pkg/formatter/render_injection_632_test.go @@ -101,8 +101,8 @@ func TestQuotedIdentifier_Format_ConsistentWithDefinitions_PR632(t *testing.T) { sql string want string }{ - {"order_by_alias", `SELECT amount AS "Total" FROM t ORDER BY "Total"`, "SELECT amount AS Total\nFROM t\nORDER BY Total"}, - {"cte_column", `WITH cte (a, "B") AS (SELECT 1, 2) SELECT "B" FROM cte`, "WITH cte (a, B) AS (SELECT 1, 2)\nSELECT B\nFROM cte"}, + {"order_by_alias", `SELECT amount AS "Total" FROM t ORDER BY "Total"`, "SELECT amount AS \"Total\"\nFROM t\nORDER BY \"Total\""}, + {"cte_column", `WITH cte (a, "B") AS (SELECT 1, 2) SELECT "B" FROM cte`, "WITH cte (a, \"B\") AS (SELECT 1, 2)\nSELECT \"B\"\nFROM cte"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/gosqlx/string_literal_roundtrip_test.go b/pkg/gosqlx/string_literal_roundtrip_test.go index f8326742..485f4a92 100644 --- a/pkg/gosqlx/string_literal_roundtrip_test.go +++ b/pkg/gosqlx/string_literal_roundtrip_test.go @@ -263,7 +263,7 @@ func TestLexicalLiteralsFormat(t *testing.T) { {keywords.DialectPostgreSQL, `SELECT a$b FROM t`, "a$b"}, {keywords.DialectSQLite, `SELECT a FROM t WHERE a = :name`, ":name"}, {keywords.DialectSQLite, `SELECT ?1, ?2`, "?1, ?2"}, - {keywords.DialectSQLite, `SELECT [col] FROM [my table]`, `"my table"`}, + {keywords.DialectSQLite, `SELECT [col] FROM [my table]`, `[my table]`}, {keywords.DialectClickHouse, `SELECT a FROM t WHERE x = {p:UInt64}`, "{p:UInt64}"}, } for _, tt := range tests { diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 1898f86a..d2775504 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -129,10 +129,9 @@ type Identifier struct { Pos models.Location // Source position of this identifier (1-based line and column) // Quoted reports that Name was written as a double-quoted (delimited) // identifier. SQL() re-quotes it when Name is a reserved word ("select") - // so it re-parses as a name. Case alone ("Mixed") is not re-quoted, to - // stay consistent with aliases and column lists, which record no quoting. - // It is set only for ANSI double quotes; QuoteStyle records MySQL - // backticks and SQL Server brackets. + // so it re-parses as a name; quotes needed for letter case ("Mixed") are + // recorded in Quotes. It is set only for ANSI double quotes; QuoteStyle + // records MySQL backticks and SQL Server brackets. Quoted bool // QuoteStyle is the opening delimiter the source used for the quoted // part(s) of the reference: '"', '`' (MySQL, MariaDB, ClickHouse) or @@ -141,6 +140,12 @@ type Identifier struct { // quotes with the same delimiters, so a MySQL `from` is not turned into // the string "from". QuoteStyle rune + // Quotes records which parts of the reference (part 0 is Name, part 1 + // the last qualifier part, ...) were delimited in the source and must + // stay delimited, such as a name quoted for its letter case ("CreatedAt") + // or a keyword ("table"); see NameQuote. Its style, when set, takes + // precedence over QuoteStyle. + Quotes NameQuote } func (i *Identifier) expressionNode() {} diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 5b343da1..f38e05e7 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -48,6 +48,11 @@ type CommonTableExpr struct { ScalarExpr Expression // ClickHouse: WITH AS (scalar CTE, no subquery) Materialized *bool // nil = default, true = MATERIALIZED, false = NOT MATERIALIZED Pos models.Location // Source position of the CTE name (1-based line and column) + // NameQuote records the delimiters of Name when they must be kept, and + // ColumnQuotes those of each Columns entry by index (nil when no + // column keeps delimiters); see NameQuote. + NameQuote NameQuote + ColumnQuotes []NameQuote } func (c *CommonTableExpr) statementNode() {} @@ -197,7 +202,12 @@ type TableReference struct { // The three flags are kept together so they share one word of padding; // TableReference is copied by value on the parser hot path. WithOrdinality bool - TableHints []string // SQL Server table hints: WITH (NOLOCK), WITH (ROWLOCK, UPDLOCK), etc. + // NameQuote and AliasQuote record the parts of Name and Alias the + // source delimited and that must stay delimited (see NameQuote). They + // share the flags' word of padding. + NameQuote NameQuote + AliasQuote NameQuote + TableHints []string // SQL Server table hints: WITH (NOLOCK), WITH (ROWLOCK, UPDLOCK), etc. // TableFunc is a function-call table reference such as // Snowflake LATERAL FLATTEN(input => col), TABLE(my_func(1,2)), // IDENTIFIER('t'), or PostgreSQL unnest(array_col). When set, Name @@ -235,6 +245,9 @@ type TableReference struct { // in unnest(arr) AS g(a, b). Names holds the column names in order. type ColumnAliasList struct { Names []string + // Quotes holds the NameQuote of each name by index; it is nil when no + // name keeps delimiters. + Quotes []NameQuote } func (t *TableReference) statementNode() {} @@ -565,6 +578,9 @@ type ColumnDef struct { Name string Type string Constraints []ColumnConstraint + // NameQuote records the delimiters of Name when they must be kept + // (see NameQuote). + NameQuote NameQuote } func (c *ColumnDef) expressionNode() {} diff --git a/pkg/sql/ast/ast_dml.go b/pkg/sql/ast/ast_dml.go index 5a96f09f..424c39ce 100644 --- a/pkg/sql/ast/ast_dml.go +++ b/pkg/sql/ast/ast_dml.go @@ -49,6 +49,10 @@ type DMLTargetFields struct { Limit Expression // ReturningInto holds the Oracle RETURNING ... INTO :bind targets. ReturningInto []Expression + // TableNameQuote and AliasQuote record the delimiters of the statement's + // TableName and Alias when they must be kept (see NameQuote). + TableNameQuote NameQuote + AliasQuote NameQuote } // dmlTargetChildren appends the nodes of d to children. @@ -119,6 +123,9 @@ type InsertDialectFields struct { // MultiTable is the Oracle INSERT ALL / INSERT FIRST body; Query holds the // source subquery and TableName is empty. MultiTable *MultiTableInsert + // TableNameQuote records the delimiters of TableName when they must be + // kept (see NameQuote). + TableNameQuote NameQuote } // insertDialectChildren appends the nodes of d to children. diff --git a/pkg/sql/ast/ast_expressions.go b/pkg/sql/ast/ast_expressions.go index bf820f56..ab63c7f4 100644 --- a/pkg/sql/ast/ast_expressions.go +++ b/pkg/sql/ast/ast_expressions.go @@ -150,6 +150,10 @@ type FunctionCall struct { Pos models.Location // Source position of the function name (1-based line and column) Separator *LiteralValue // MySQL/MariaDB GROUP_CONCAT(... SEPARATOR 'str'); nil when absent Keep *KeepClause // Oracle KEEP (DENSE_RANK FIRST|LAST ORDER BY ...); nil when absent + // NameQuote records the parts of Name the source delimited and that + // must stay delimited, such as s."select" or "MySchema"."MyFunc" (see + // NameQuote). + NameQuote NameQuote } func (f *FunctionCall) expressionNode() {} @@ -709,6 +713,9 @@ func (c CastExpression) Children() []Node { type AliasedExpression struct { Expr Expression Alias string + // AliasQuote records the delimiters the source wrote around Alias when + // they must be kept (see NameQuote). + AliasQuote NameQuote } func (a *AliasedExpression) expressionNode() {} diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index 33d32a84..7427c71a 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -432,6 +432,9 @@ type CreateTableStatement struct { // PeriodDefinitions holds PERIOD FOR clauses for application-time or system-time periods. // Example: PERIOD FOR app_time (start_col, end_col) PeriodDefinitions []*PeriodDefinition + // NameQuote records the delimiters of Name when they must be kept + // (see NameQuote). + NameQuote NameQuote } func (c *CreateTableStatement) statementNode() {} @@ -584,6 +587,10 @@ type MergeStatement struct { WhenClauses []*MergeWhenClause // List of WHEN clauses Output []Expression // SQL Server OUTPUT clause columns Returning []Expression // PostgreSQL 17 RETURNING list + // TargetAliasQuote and SourceAliasQuote record the delimiters of + // TargetAlias and SourceAlias when they must be kept (see NameQuote). + TargetAliasQuote NameQuote + SourceAliasQuote NameQuote } func (m *MergeStatement) statementNode() {} @@ -653,6 +660,9 @@ type MergeAction struct { Where Expression // DeleteWhere is the Oracle UPDATE SET ... DELETE WHERE cond predicate. DeleteWhere Expression + // ColumnQuotes records the delimiters of each Columns entry by index + // (nil when no column keeps delimiters); see NameQuote. + ColumnQuotes []NameQuote } func (a *MergeAction) expressionNode() {} @@ -678,6 +688,9 @@ func (a MergeAction) Children() []Node { type SetClause struct { Column string Value Expression + // ColumnQuote records the delimiters of Column's parts when they must + // be kept (see NameQuote). + ColumnQuote NameQuote } func (s *SetClause) expressionNode() {} @@ -699,6 +712,11 @@ type CreateViewStatement struct { Columns []string // Optional column list Query Statement // The SELECT statement WithOption string // PostgreSQL: WITH (CHECK OPTION | CASCADED | LOCAL) + // NameQuote records the delimiters of Name when they must be kept, and + // ColumnQuotes those of each Columns entry by index (nil when no column + // keeps delimiters); see NameQuote. + NameQuote NameQuote + ColumnQuotes []NameQuote } func (c *CreateViewStatement) statementNode() {} diff --git a/pkg/sql/ast/name_quote.go b/pkg/sql/ast/name_quote.go new file mode 100644 index 00000000..3fa78227 --- /dev/null +++ b/pkg/sql/ast/name_quote.go @@ -0,0 +1,439 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +import ( + "bytes" + "strings" + "unicode" +) + +// NameQuote records how a name was delimited in the source, for the names +// the AST keeps as plain strings (aliases, table, function and CTE names) and +// for the parts of an Identifier. It packs three facts into one byte: +// +// - which dot-separated parts of the name were written as delimited +// identifiers that must stay delimited to keep their meaning (a name +// quoted for its letter case, such as "CreatedAt", or a keyword such as +// "table"). Part 0 is the last part of the name, part 1 the one before +// it, and so on; up to five parts are recorded. +// - the delimiter the source used: ANSI double quotes, MySQL backticks or +// SQL Server brackets. Renderers use it both for the recorded parts and +// for parts that need delimiters because of their content (a space, a +// reserved word), so a MySQL `exists` alias is written back with +// backticks rather than in double quotes, which MySQL reads as a string. +// - BareHash: the name was written bare in Oracle, where '#' may appear in +// an identifier after its first character (s.serial#). Such a part is +// written bare again; delimiting it would make it case-sensitive. +// +// The zero value means "nothing recorded": names are then quoted only when +// their content requires it, in ANSI double quotes. Parts that were +// delimited in the source but read the same bare (a lowercase "users" in +// PostgreSQL) are not recorded, so formatting still drops those quotes. +type NameQuote uint8 + +const ( + nameQuoteMaxParts = 5 + nameQuotePartsMask NameQuote = 1<= nameQuoteMaxParts { + return q + } + return q | 1<= nameQuoteMaxParts { + return false + } + return q&(1<= 'A' && c <= 'Z': + if !upper { + return true + } + case c >= 'a' && c <= 'z': + if upper { + return true + } + case c >= 0x80: + for _, r := range name[i:] { + if (!upper && unicode.IsUpper(r)) || (upper && unicode.IsLower(r)) { + return true + } + } + return false + } + } + return false +} + +// isOracleBareName reports whether name can be written bare in Oracle: a +// letter followed by letters, digits, '_', '$' and '#'. +func isOracleBareName(name string) bool { + if name == "" { + return false + } + for i, r := range name { + switch { + case unicode.IsLetter(r): + case i > 0 && (r == '_' || r == '$' || r == '#' || unicode.IsDigit(r)): + case i > 0 && r >= 0x80 && (unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Mc, r) || unicode.Is(unicode.Pc, r)): + default: + return false + } + } + return true +} + +// partDelimited reports whether one part of a name is written delimited: +// it is recorded as kept, or its content needs delimiters (need) and it is +// not an Oracle bare name with '#' recorded as such. +func partDelimited(part string, keep, need bool, q NameQuote) bool { + if keep { + return true + } + if need && q.BareHash() && strings.IndexByte(part, '#') > 0 && isOracleBareName(part) && !isReservedName(part, true) { + return false + } + return need +} + +// writeNamePart writes part delimited with style when delimited is true and +// bare otherwise. +func writeNamePart(buf *bytes.Buffer, part string, delimited bool, style rune) { + if delimited { + writeDelimitedIdentifier(buf, part, style) + return + } + buf.WriteString(part) +} + +// writeNameQ writes a CTE name, a column-list entry or a select-item alias +// like writeName, honouring the delimiters recorded in q. +func writeNameQ(buf *bytes.Buffer, name string, q NameQuote) { + if q == 0 { + writeName(buf, name) + return + } + writeNamePart(buf, name, partDelimited(name, q.Part(0), nameNeedsQuoting(name, false), q), q.Style()) +} + +// writeTableAliasQ writes a table alias like writeTableAlias, honouring the +// delimiters recorded in q. +func writeTableAliasQ(buf *bytes.Buffer, name string, q NameQuote) { + if q == 0 { + writeTableAlias(buf, name) + return + } + writeNamePart(buf, name, partDelimited(name, q.Part(0), tableAliasNeedsQuoting(name), q), q.Style()) +} + +// writeNameListQ writes names separated by ", " like writeNameList; qs, when +// not nil, holds the NameQuote of each name by index. +func writeNameListQ(buf *bytes.Buffer, names []string, qs []NameQuote) { + for i, n := range names { + if i > 0 { + buf.WriteString(", ") + } + writeNameQ(buf, n, nameQuoteAt(qs, i)) + } +} + +// writeColumnNameListQ writes names separated by ", " like +// writeColumnNameList; qs, when not nil, holds the NameQuote of each name. +func writeColumnNameListQ(buf *bytes.Buffer, names []string, qs []NameQuote) { + for i, n := range names { + if i > 0 { + buf.WriteString(", ") + } + writeTableAliasQ(buf, n, nameQuoteAt(qs, i)) + } +} + +// nameQuoteAt returns qs[i], or the zero NameQuote when qs is too short. +func nameQuoteAt(qs []NameQuote, i int) NameQuote { + if i < len(qs) { + return qs[i] + } + return 0 +} + +// countNameParts returns the number of dot-separated parts of name. +func countNameParts(name string) int { + return strings.Count(name, ".") + 1 +} + +// writeQualifiedNameQ writes a possibly dotted table or view name like +// writeQualifiedName, honouring the delimiters recorded in q. +func writeQualifiedNameQ(buf *bytes.Buffer, name string, q NameQuote) { + if q == 0 || name == "" || isStageReference(name) { + writeQualifiedName(buf, name) + return + } + style := q.Style() + idx := countNameParts(name) - 1 + for { + dot := strings.IndexByte(name, '.') + part := name + if dot >= 0 { + part = name[:dot] + } + need := part == "" || nameNeedsQuoting(part, true) + writeNamePart(buf, part, partDelimited(part, q.Part(idx), need, q), style) + if dot < 0 { + return + } + buf.WriteByte('.') + name = name[dot+1:] + idx-- + } +} + +// writeFunctionNameQ writes a possibly schema-qualified function name like +// writeFunctionName, honouring the delimiters recorded in q: a part written +// delimited in the source (s."select", "MySchema"."MyFunc") keeps its +// delimiters, while bare keyword parts (DBMS_RANDOM.VALUE) stay bare. +func writeFunctionNameQ(buf *bytes.Buffer, name string, q NameQuote) { + if q == 0 || name == "" { + writeFunctionName(buf, name) + return + } + style := q.Style() + multi := strings.IndexByte(name, '.') >= 0 + idx := countNameParts(name) - 1 + for { + dot := strings.IndexByte(name, '.') + part := name + if dot >= 0 { + part = name[:dot] + } + need := functionNamePartNeedsQuoting(part, !multi) + writeNamePart(buf, part, partDelimited(part, q.Part(idx), need, q), style) + if dot < 0 { + return + } + buf.WriteByte('.') + name = name[dot+1:] + idx-- + } +} + +// writeIdentifierQualifier writes the Table qualifier of an Identifier, +// quoting each part like writeDottedIdentifierStyle and additionally every +// part recorded in q. The qualifier's last part is part 1 of the whole +// reference (the column name is part 0). +func writeIdentifierQualifier(buf *bytes.Buffer, table string, style rune, q NameQuote) { + idx := countNameParts(table) + for { + dot := strings.IndexByte(table, '.') + part := table + if dot >= 0 { + part = table[:dot] + } + need := isReservedName(part, true) || identifierNeedsQuoting(part) + writeNamePart(buf, part, partDelimited(part, q.Part(idx), need, q), style) + if dot < 0 { + return + } + buf.WriteByte('.') + table = table[dot+1:] + idx-- + } +} + +// quoteWith renders name through write into a pooled buffer. +func quoteWith(name string, q NameQuote, write func(*bytes.Buffer, string, NameQuote)) string { + buf := getBuf() + defer putBuf(buf) + write(buf, name, q) + return buf.String() +} + +// QuoteNameWith returns a single-part name (a CTE name, a CTE/view +// column-list entry, a select-item alias) as QuoteName does, honouring the +// delimiters recorded in q: a part recorded as delimited keeps its +// delimiters, and any delimiters are written in q's style. +func QuoteNameWith(name string, q NameQuote) string { + if q == 0 || (!q.HasParts() && !nameNeedsQuoting(name, false)) { + return QuoteName(name) + } + return quoteWith(name, q, writeNameQ) +} + +// QuoteTableAliasWith returns a table alias as QuoteTableAlias does, +// honouring the delimiters recorded in q. +func QuoteTableAliasWith(name string, q NameQuote) string { + if q == 0 || (!q.HasParts() && !tableAliasNeedsQuoting(name)) { + return QuoteTableAlias(name) + } + return quoteWith(name, q, writeTableAliasQ) +} + +// QuoteColumnNameWith returns a single-part DDL column name or column-list +// entry as QuoteColumnName does, honouring the delimiters recorded in q. +func QuoteColumnNameWith(name string, q NameQuote) string { + return QuoteTableAliasWith(name, q) +} + +// QuoteQualifiedNameWith returns a dotted table or view name as +// QuoteQualifiedName does, honouring the delimiters recorded in q. +func QuoteQualifiedNameWith(name string, q NameQuote) string { + if q == 0 || name == "" || (!q.HasParts() && !qualifiedNameNeedsQuoting(name)) { + return QuoteQualifiedName(name) + } + return quoteWith(name, q, writeQualifiedNameQ) +} + +// QuoteFunctionNameWith returns a possibly qualified function name as the +// SQL() of a FunctionCall writes it, honouring the delimiters recorded in q. +func QuoteFunctionNameWith(name string, q NameQuote) string { + return quoteWith(name, q, writeFunctionNameQ) +} diff --git a/pkg/sql/ast/name_quote_restyle.go b/pkg/sql/ast/name_quote_restyle.go new file mode 100644 index 00000000..30aef7fa --- /dev/null +++ b/pkg/sql/ast/name_quote_restyle.go @@ -0,0 +1,100 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast + +// SetNameQuoteStyle rewrites, in place, the identifier delimiter recorded +// for every name under node to style ('"', '`' or '['), so the tree renders +// its delimited names with a target dialect's delimiters instead of the +// source's: a MySQL `my col` becomes "my col" for PostgreSQL and a SQL Server +// [Group] becomes `Group` for MySQL. Names that need delimiters only because +// of their content (a reserved word, a space) are written with style too. +// The parts recorded as delimited are kept; only the delimiter changes. +// +// Oracle bare names containing '#' (NameQuote.BareHash) stay bare only when +// keepBareHash is true; otherwise they are delimited like any other name with +// that character, since '#' starts a comment in MySQL and is not an +// identifier character elsewhere. +// +// It is used by the transpiler after parsing in the source dialect. Names the +// AST does not record (MERGE aliases, most DDL object names other than +// CREATE TABLE / CREATE VIEW names and columns) are not affected. +func SetNameQuoteStyle(node Node, style rune, keepBareHash bool) { + if node == nil { + return + } + restyle := func(q NameQuote) NameQuote { + return q.WithStyle(style).WithBareHash(keepBareHash && q.BareHash()) + } + Inspect(node, func(n Node) bool { + switch v := n.(type) { + case *Identifier: + if v.QuoteStyle != 0 { + v.QuoteStyle = style + v.Quoted = style == '"' + } + v.Quotes = restyle(v.Quotes) + case *AliasedExpression: + v.AliasQuote = restyle(v.AliasQuote) + case *FunctionCall: + v.NameQuote = restyle(v.NameQuote) + case *TableReference: + v.NameQuote = restyle(v.NameQuote) + v.AliasQuote = restyle(v.AliasQuote) + if v.ColumnAliases != nil { + for i := range v.ColumnAliases.Quotes { + v.ColumnAliases.Quotes[i] = restyle(v.ColumnAliases.Quotes[i]) + } + } + case *CommonTableExpr: + v.NameQuote = restyle(v.NameQuote) + for i := range v.ColumnQuotes { + v.ColumnQuotes[i] = restyle(v.ColumnQuotes[i]) + } + case *InsertStatement: + v.TableNameQuote = restyle(v.TableNameQuote) + case *UpdateStatement: + v.TableNameQuote = restyle(v.TableNameQuote) + v.AliasQuote = restyle(v.AliasQuote) + case *DeleteStatement: + v.TableNameQuote = restyle(v.TableNameQuote) + v.AliasQuote = restyle(v.AliasQuote) + case *MergeStatement: + v.TargetAliasQuote = restyle(v.TargetAliasQuote) + v.SourceAliasQuote = restyle(v.SourceAliasQuote) + for _, w := range v.WhenClauses { + if w == nil || w.Action == nil { + continue + } + for i := range w.Action.SetClauses { + w.Action.SetClauses[i].ColumnQuote = restyle(w.Action.SetClauses[i].ColumnQuote) + } + for i := range w.Action.ColumnQuotes { + w.Action.ColumnQuotes[i] = restyle(w.Action.ColumnQuotes[i]) + } + } + case *CreateTableStatement: + v.NameQuote = restyle(v.NameQuote) + for i := range v.Columns { + v.Columns[i].NameQuote = restyle(v.Columns[i].NameQuote) + } + case *CreateViewStatement: + v.NameQuote = restyle(v.NameQuote) + for i := range v.ColumnQuotes { + v.ColumnQuotes[i] = restyle(v.ColumnQuotes[i]) + } + } + return true + }) +} diff --git a/pkg/sql/ast/name_quote_test.go b/pkg/sql/ast/name_quote_test.go new file mode 100644 index 00000000..d499ef7d --- /dev/null +++ b/pkg/sql/ast/name_quote_test.go @@ -0,0 +1,205 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ast_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func TestNameQuote_Bits(t *testing.T) { + q := ast.NameQuote(0).AppendPart(true).AppendPart(false).AppendPart(true) + if !q.Part(0) || q.Part(1) || !q.Part(2) || q.Part(3) { + t.Fatalf("AppendPart parts = %08b, want parts 0 and 2", q) + } + if q.HasStyle() || q.Style() != '"' { + t.Fatalf("no style recorded: HasStyle=%v Style=%q", q.HasStyle(), q.Style()) + } + for _, style := range []rune{'"', '`', '['} { + s := q.WithStyle(style) + if s.Style() != style || !s.HasStyle() || !s.Part(0) || !s.Part(2) { + t.Errorf("WithStyle(%q) = %08b", style, s) + } + if got := s.WithDefaultStyle('`').Style(); got != style { + t.Errorf("WithDefaultStyle kept %q, want %q", got, style) + } + } + if got := q.WithDefaultStyle('`').Style(); got != '`' { + t.Errorf("WithDefaultStyle on unstyled = %q, want '`'", got) + } + if ast.NameQuoteOf('x').HasStyle() { + t.Error("NameQuoteOf('x') recorded a style") + } + if q.WithPart(-1) != q || q.WithPart(5) != q || q.Part(7) { + t.Error("out-of-range parts must be ignored") + } + // A sixth part pushes the first one out. + six := ast.NameQuote(0).AppendPart(true) + for i := 0; i < 5; i++ { + six = six.AppendPart(false) + } + if six.HasParts() { + t.Errorf("parts beyond five not dropped: %08b", six) + } + h := q.WithBareHash(true) + if !h.BareHash() || h.WithBareHash(false).BareHash() || h.WithStyle('`').BareHash() != true { + t.Errorf("BareHash flag not independent of style/parts: %08b", h) + } + if got := ast.QuotedName('`'); !got.Part(0) || got.Style() != '`' { + t.Errorf("QuotedName('`') = %08b", got) + } + merged := ast.NameQuote(0).WithParts(ast.QuotedName('[')) + if !merged.Part(0) || merged.Style() != '[' { + t.Errorf("WithParts = %08b", merged) + } +} + +func TestDelimitedNameMustStayQuoted(t *testing.T) { + tests := []struct { + name string + fold ast.IdentifierCaseFolding + want bool + }{ + {"users", ast.FoldLower, false}, + {"Users", ast.FoldLower, true}, + {"USERS", ast.FoldLower, true}, + {"USERS", ast.FoldUpper, false}, + {"users", ast.FoldUpper, true}, + {"Users", ast.FoldNone, false}, + {"select", ast.FoldNone, true}, + {"a b", ast.FoldNone, true}, + {"1abc", ast.FoldNone, true}, + {"", ast.FoldNone, true}, + {"Ünïcode", ast.FoldLower, true}, + {"ünïcode", ast.FoldLower, false}, + {"ünïcode", ast.FoldUpper, true}, + {"_x1", ast.FoldUpper, true}, + {"_X1", ast.FoldUpper, false}, + } + for _, tc := range tests { + if got := ast.DelimitedNameMustStayQuoted(tc.name, tc.fold); got != tc.want { + t.Errorf("DelimitedNameMustStayQuoted(%q, %d) = %v, want %v", tc.name, tc.fold, got, tc.want) + } + } +} + +func TestQuoteWith(t *testing.T) { + kept := ast.QuotedName('"') + tests := []struct { + label string + got string + want string + }{ + {"name zero quote", ast.QuoteNameWith("Total", 0), "Total"}, + {"name kept", ast.QuoteNameWith("Total", kept), `"Total"`}, + {"name backtick reserved", ast.QuoteNameWith("exists", ast.NameQuoteOf('`')), "`exists`"}, + {"name bracket space", ast.QuoteNameWith("a b", ast.NameQuoteOf('[')), "[a b]"}, + {"name bracket escape", ast.QuoteNameWith("a]b", ast.QuotedName('[')), "[a]]b]"}, + {"name backtick escape", ast.QuoteNameWith("a`b", ast.QuotedName('`')), "`a``b`"}, + {"name ansi escape", ast.QuoteNameWith(`a"b`, kept), `"a""b"`}, + {"alias kept", ast.QuoteTableAliasWith("T", kept), `"T"`}, + {"alias plain", ast.QuoteTableAliasWith("t", ast.NameQuoteOf('`')), "t"}, + {"column kept", ast.QuoteColumnNameWith("ColA", kept), `"ColA"`}, + {"qualified last part", ast.QuoteQualifiedNameWith("s.Orders", kept), `s."Orders"`}, + {"qualified first part", ast.QuoteQualifiedNameWith("Sales.orders", ast.NameQuote(0).AppendPart(true).AppendPart(false).WithStyle('"')), `"Sales".orders`}, + {"qualified reserved backtick", ast.QuoteQualifiedNameWith("db.order", ast.NameQuoteOf('`')), "db.`order`"}, + {"qualified zero quote", ast.QuoteQualifiedNameWith("Sales.Orders", 0), "Sales.Orders"}, + {"stage reference verbatim", ast.QuoteQualifiedNameWith("@stage/path", kept), "@stage/path"}, + {"function qualified reserved", ast.QuoteFunctionNameWith("s.select", ast.NameQuote(0).AppendPart(false).AppendPart(true)), `s."select"`}, + {"function bare keyword member", ast.QuoteFunctionNameWith("DBMS_RANDOM.VALUE", 0), "DBMS_RANDOM.VALUE"}, + {"function mixed case", ast.QuoteFunctionNameWith("MyFunc", kept), `"MyFunc"`}, + {"oracle bare hash", ast.QuoteNameWith("emp#", ast.NameQuote(0).WithBareHash(true)), "emp#"}, + {"hash without flag", ast.QuoteNameWith("emp#", 0), `"emp#"`}, + {"bare hash leading", ast.QuoteNameWith("#emp", ast.NameQuote(0).WithBareHash(true)), `"#emp"`}, + {"bare hash reserved", ast.QuoteTableAliasWith("select", ast.NameQuote(0).WithBareHash(true)), `"select"`}, + {"qualified bare hash", ast.QuoteQualifiedNameWith("s.tab#1", ast.NameQuote(0).WithBareHash(true)), "s.tab#1"}, + } + for _, tc := range tests { + if tc.got != tc.want { + t.Errorf("%s: got %s, want %s", tc.label, tc.got, tc.want) + } + } +} + +func TestIdentifierSQL_Quotes(t *testing.T) { + tests := []struct { + label string + id ast.Identifier + want string + }{ + {"bare", ast.Identifier{Name: "Mixed"}, "Mixed"}, + {"name kept", ast.Identifier{Name: "Mixed", Quoted: true, QuoteStyle: '"', Quotes: ast.QuotedName('"')}, `"Mixed"`}, + {"qualifier kept", ast.Identifier{Table: "T", Name: "id", Quotes: ast.NameQuote(0).AppendPart(true).AppendPart(false).WithStyle('"')}, `"T".id`}, + {"three parts", ast.Identifier{Table: "Db.s", Name: "c", Quotes: ast.NameQuote(0).AppendPart(true).AppendPart(false).AppendPart(true).WithStyle('`')}, "`Db`.s.`c`"}, + {"style overrides QuoteStyle", ast.Identifier{Name: "select", QuoteStyle: '"', Quotes: ast.NameQuoteOf('[')}, "[select]"}, + {"content quoting uses style", ast.Identifier{Name: "a b", Quotes: ast.NameQuoteOf('`')}, "`a b`"}, + {"bare hash", ast.Identifier{Table: "s", Name: "serial#", Quotes: ast.NameQuote(0).WithBareHash(true)}, "s.serial#"}, + {"hash without flag", ast.Identifier{Name: "serial#"}, `"serial#"`}, + } + for _, tc := range tests { + id := tc.id + if got := id.SQL(); got != tc.want { + t.Errorf("%s: SQL() = %s, want %s", tc.label, got, tc.want) + } + } +} + +// TestColumnRefSQL_UsesSourceDelimiter checks that a write-target column the +// source delimited keeps its delimiter: a MySQL `select` must not turn into +// "select", which MySQL reads as a string. +func TestColumnRefSQL_UsesSourceDelimiter(t *testing.T) { + tests := []struct { + label string + id *ast.Identifier + want string + }{ + {"backtick reserved", &ast.Identifier{Name: "select", QuoteStyle: '`', Quotes: ast.QuotedName('`')}, "`select`"}, + {"unrecorded reserved", &ast.Identifier{Name: "select"}, `"select"`}, + {"qualified unrecorded reserved", &ast.Identifier{Table: "t", Name: "order"}, `t."order"`}, + {"plain", &ast.Identifier{Name: "a"}, "a"}, + } + for _, tc := range tests { + if got := ast.ColumnRefSQL(tc.id); got != tc.want { + t.Errorf("%s: ColumnRefSQL = %s, want %s", tc.label, got, tc.want) + } + } +} + +func TestSetNameQuoteStyle(t *testing.T) { + sel := &ast.SelectStatement{ + Columns: []ast.Expression{ + &ast.AliasedExpression{ + Expr: &ast.Identifier{Name: "my col", QuoteStyle: '`', Quotes: ast.QuotedName('`')}, + Alias: "My Alias", + AliasQuote: ast.QuotedName('`'), + }, + &ast.Identifier{Table: "s", Name: "serial#", Quotes: ast.NameQuote(0).WithBareHash(true)}, + &ast.FunctionCall{Name: "MyFunc", NameQuote: ast.QuotedName('`')}, + }, + From: []ast.TableReference{{Name: "My Table", NameQuote: ast.QuotedName('`'), Alias: "t"}}, + } + ast.SetNameQuoteStyle(sel, '"', false) + want := `SELECT "my col" AS "My Alias", s."serial#", "MyFunc"() FROM "My Table" t` + if got := sel.SQL(); got != want { + t.Fatalf("after SetNameQuoteStyle('\"'):\n got: %s\nwant: %s", got, want) + } + ast.SetNameQuoteStyle(sel, '[', false) + want = `SELECT [my col] AS [My Alias], s.[serial#], [MyFunc]() FROM [My Table] t` + if got := sel.SQL(); got != want { + t.Fatalf("after SetNameQuoteStyle('['):\n got: %s\nwant: %s", got, want) + } + ast.SetNameQuoteStyle(nil, '"', false) // must not panic +} diff --git a/pkg/sql/ast/pool.go b/pkg/sql/ast/pool.go index 0594cfbd..1ca40549 100644 --- a/pkg/sql/ast/pool.go +++ b/pkg/sql/ast/pool.go @@ -707,6 +707,8 @@ func PutCreateViewStatement(stmt *CreateViewStatement) { stmt.Columns = stmt.Columns[:0] stmt.Query = nil stmt.WithOption = "" + stmt.NameQuote = 0 + stmt.ColumnQuotes = nil createViewStmtPool.Put(stmt) } diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index 804ee94b..b7f83437 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -591,6 +591,8 @@ func releaseTableReference(tr *TableReference, mode releaseMode) { } tr.Name = "" tr.Alias = "" + tr.NameQuote = 0 + tr.AliasQuote = 0 tr.Lateral = false tr.Final = false tr.TableHints = nil @@ -657,6 +659,7 @@ func PutCreateTableStatement(stmt *CreateTableStatement) { stmt.Columns[i].Constraints = stmt.Columns[i].Constraints[:0] stmt.Columns[i].Name = "" stmt.Columns[i].Type = "" + stmt.Columns[i].NameQuote = 0 } stmt.Columns = stmt.Columns[:0] @@ -719,6 +722,7 @@ func PutCreateTableStatement(stmt *CreateTableStatement) { stmt.IfNotExists = false stmt.Temporary = false stmt.Name = "" + stmt.NameQuote = 0 createTableStmtPool.Put(stmt) } @@ -796,6 +800,7 @@ func PutMergeStatement(stmt *MergeStatement) { PutExpression(stmt.WhenClauses[i].Action.SetClauses[j].Value) stmt.WhenClauses[i].Action.SetClauses[j].Value = nil stmt.WhenClauses[i].Action.SetClauses[j].Column = "" + stmt.WhenClauses[i].Action.SetClauses[j].ColumnQuote = 0 } stmt.WhenClauses[i].Action.SetClauses = stmt.WhenClauses[i].Action.SetClauses[:0] for j, expr := range stmt.WhenClauses[i].Action.Values { @@ -804,6 +809,7 @@ func PutMergeStatement(stmt *MergeStatement) { } stmt.WhenClauses[i].Action.Values = stmt.WhenClauses[i].Action.Values[:0] stmt.WhenClauses[i].Action.Columns = stmt.WhenClauses[i].Action.Columns[:0] + stmt.WhenClauses[i].Action.ColumnQuotes = nil stmt.WhenClauses[i].Action.ActionType = "" stmt.WhenClauses[i].Action.DefaultValues = false PutExpression(stmt.WhenClauses[i].Action.Where) @@ -834,6 +840,8 @@ func PutMergeStatement(stmt *MergeStatement) { stmt.SourceTable = TableReference{} stmt.TargetAlias = "" stmt.SourceAlias = "" + stmt.TargetAliasQuote = 0 + stmt.SourceAliasQuote = 0 mergeStmtPool.Put(stmt) } diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index ca3a633a..d7ea70b4 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -17,10 +17,12 @@ // references and INSERT/UPDATE/DELETE targets, select-item, table and // ARRAY JOIN aliases, window names, DDL object, column and constraint names, // function names) and for the free-text positions rendered next to them -// (column data types, table option and PRAGMA values). Those positions record -// no quote information, so a name is quoted whenever a bare rendering could -// not re-parse as the same name, and a free-text value that is not plainly -// shaped is quoted as a whole. +// (column data types, table option and PRAGMA values). A name is quoted +// whenever a bare rendering could not re-parse as the same name, and a +// free-text value that is not plainly shaped is quoted as a whole. Where the +// parser records how a name was delimited (NameQuote, see name_quote.go), the +// *Q / *With variants also keep the quotes the source needed for letter case +// or a keyword, in the source's delimiters. package ast import ( @@ -419,9 +421,9 @@ var constraintNameWords = map[string]bool{ // written double-quoted: it is not plain (ASCII letters, digits and '_', not // starting with a digit), or it is a keyword (KEY, INDEX, DEFAULT, TABLE, ...) // the parser does not accept bare in that position. Case alone does not force -// quotes: the AST does not record whether a name was quoted, and quoting an -// unquoted PK_USERS would change the name in case-folding dialects. This -// matches Identifier.SQL(), which renders names quoted only for case bare. +// quotes: constraint names do not record whether they were quoted, and +// quoting an unquoted PK_USERS would change the name in case-folding +// dialects. func constraintNameNeedsQuoting(name string) bool { if name == "" || (name[0] >= '0' && name[0] <= '9') { return true @@ -873,13 +875,12 @@ func QuoteOperator(op string) string { // columnRefNeedsQuoting reports whether e is an identifier whose name is a // word the parser rejects bare in every name position but which Identifier -// rendering would leave bare (it was not recorded as Quoted, for example a -// MySQL `select` or SQL Server [select]). Such a name is quoted where it is -// a write target: an INSERT column, a SET or DO UPDATE SET target, an ON -// CONFLICT target. +// rendering would leave bare (it was not recorded as delimited in the +// source). Such a name is quoted where it is a write target: an INSERT +// column, a SET or DO UPDATE SET target, an ON CONFLICT target. func columnRefNeedsQuoting(e Expression) (*Identifier, bool) { id, ok := e.(*Identifier) - if !ok || id == nil || id.Quoted || !isReservedName(id.Name, true) { + if !ok || id == nil || id.Quoted || id.QuoteStyle != 0 || id.Quotes.Part(0) || !isReservedName(id.Name, true) { return nil, false } return id, true @@ -893,11 +894,9 @@ func writeColumnRef(buf *bytes.Buffer, e Expression) { writeExpr(buf, e) return } - if id.Table != "" { - writeDottedIdentifier(buf, id.Table) - buf.WriteByte('.') - } - writeQuotedIdentifier(buf, id.Name) + forced := *id + forced.Quotes = forced.Quotes.WithPart(0) + forced.writeSQL(buf) } // writeColumnRefList writes exprs separated by ", ", each via writeColumnRef. diff --git a/pkg/sql/ast/quoted_identifier_render_test.go b/pkg/sql/ast/quoted_identifier_render_test.go index ef6e7fb9..7917759a 100644 --- a/pkg/sql/ast/quoted_identifier_render_test.go +++ b/pkg/sql/ast/quoted_identifier_render_test.go @@ -96,43 +96,59 @@ func assertQuotedRoundTrip(t *testing.T, sql, want string, d keywords.SQLDialect // TestQuotedIdentifierRender_Issue611 covers quoted identifiers that are // reserved words or contain spaces: SQL() must keep the quotes so the output -// re-parses as the same names in every dialect. A name quoted only for its -// case renders bare, like the aliases and column lists it may refer to. +// re-parses as the same names in every dialect. A name quoted for its case +// keeps its quotes too where the dialect folds unquoted names (the parser +// records it, see ast.NameQuote): upper-case letters in PostgreSQL and +// generic SQL, lower-case letters in Oracle and Snowflake. Dialects that do +// not fold drop quotes needed only for case, as before. func TestQuotedIdentifierRender_Issue611(t *testing.T) { cases := []struct { - name string - sql string - want string // "" means identical to sql + name string + sql string + want string // "" means identical to sql + upperWant string // want in Oracle and Snowflake; "" means want + noneWant string // want in dialects that do not fold; "" means want }{ - {"cte_column_reserved", `WITH c ("select") AS (SELECT 1) SELECT * FROM c`, ""}, - {"cte_column_space", `WITH c ("a b") AS (SELECT 1) SELECT * FROM c`, ""}, - {"view_column_reserved", `CREATE VIEW v ("select") AS SELECT 1`, ""}, - {"select_reserved", `SELECT "select" FROM t`, ""}, - {"select_space_case_table_space", `SELECT "a b", "Mixed" FROM "my table"`, `SELECT "a b", Mixed FROM "my table"`}, - {"cte_name_reserved", `WITH "select" AS (SELECT 1) SELECT * FROM "select"`, ""}, - {"mview_column_reserved", `CREATE MATERIALIZED VIEW mv ("select") AS SELECT 1`, ""}, - {"cte_column_key", `WITH c ("key") AS (SELECT 1) SELECT * FROM c`, ""}, - {"select_null_true", `SELECT "null", "true" FROM t`, ""}, - {"join_reserved_table", `SELECT * FROM a JOIN "order" o ON a.id = o.id`, `SELECT * FROM a INNER JOIN "order" o ON a.id = o.id`}, - {"qualified_reserved", `SELECT "T"."select" FROM t`, `SELECT T."select" FROM t`}, - {"qualified_table_space", `SELECT * FROM "db"."My T"`, `SELECT * FROM db."My T"`}, + {"cte_column_reserved", `WITH c ("select") AS (SELECT 1) SELECT * FROM c`, "", "", ""}, + {"cte_column_space", `WITH c ("a b") AS (SELECT 1) SELECT * FROM c`, "", "", ""}, + {"view_column_reserved", `CREATE VIEW v ("select") AS SELECT 1`, "", "", ""}, + {"select_reserved", `SELECT "select" FROM t`, "", "", ""}, + {"select_space_case_table_space", `SELECT "a b", "Mixed" FROM "my table"`, "", "", `SELECT "a b", Mixed FROM "my table"`}, + {"cte_name_reserved", `WITH "select" AS (SELECT 1) SELECT * FROM "select"`, "", "", ""}, + {"mview_column_reserved", `CREATE MATERIALIZED VIEW mv ("select") AS SELECT 1`, "", "", ""}, + {"cte_column_key", `WITH c ("key") AS (SELECT 1) SELECT * FROM c`, "", "", ""}, + {"select_null_true", `SELECT "null", "true" FROM t`, "", "", ""}, + {"join_reserved_table", `SELECT * FROM a JOIN "order" o ON a.id = o.id`, `SELECT * FROM a INNER JOIN "order" o ON a.id = o.id`, "", ""}, + {"qualified_reserved", `SELECT "T"."select" FROM t`, "", `SELECT T."select" FROM t`, `SELECT T."select" FROM t`}, + {"qualified_table_space", `SELECT * FROM "db"."My T"`, `SELECT * FROM db."My T"`, `SELECT * FROM "db"."My T"`, ""}, // No-change guards: unquoted input renders as before. - {"guard_mixed_case_bare", `SELECT Mixed, id FROM Users`, ""}, - {"guard_from_key", `SELECT * FROM key`, ""}, - {"guard_cte_columns_bare", `WITH c (a, b) AS (SELECT 1, 2) SELECT * FROM c`, ""}, + {"guard_mixed_case_bare", `SELECT Mixed, id FROM Users`, "", "", ""}, + {"guard_from_key", `SELECT * FROM key`, "", "", ""}, + {"guard_cte_columns_bare", `WITH c (a, b) AS (SELECT 1, 2) SELECT * FROM c`, "", "", ""}, // Bare names with combining marks (Devanagari vowel signs, a // decomposed accent) are valid bare identifiers and stay unquoted. - {"guard_devanagari_table", `SELECT * FROM उपयोगकर्ता`, ""}, - {"guard_devanagari_cte", `WITH उपयोगकर्ता AS (SELECT 1) SELECT * FROM उपयोगकर्ता`, ""}, - {"guard_decomposed_table", "SELECT * FROM Cafe\u0301", ""}, - {"guard_decomposed_join", "SELECT * FROM a LEFT JOIN cafe\u0301_tbl c ON a.id = c.id", ""}, + {"guard_devanagari_table", `SELECT * FROM उपयोगकर्ता`, "", "", ""}, + {"guard_devanagari_cte", `WITH उपयोगकर्ता AS (SELECT 1) SELECT * FROM उपयोगकर्ता`, "", "", ""}, + {"guard_decomposed_table", "SELECT * FROM Cafe\u0301", "", "", ""}, + {"guard_decomposed_join", "SELECT * FROM a LEFT JOIN cafe\u0301_tbl c ON a.id = c.id", "", "", ""}, } for _, tc := range cases { - want := tc.want - if want == "" { - want = tc.sql - } for _, d := range quotedRenderDialects { + want := tc.want + if want == "" { + want = tc.sql + } + switch d { + case keywords.DialectOracle, keywords.DialectSnowflake: + if tc.upperWant != "" { + want = tc.upperWant + } + case keywords.DialectGeneric, keywords.DialectPostgreSQL: + default: + if tc.noneWant != "" { + want = tc.noneWant + } + } t.Run(tc.name+"/"+string(d), func(t *testing.T) { tree := assertQuotedRoundTrip(t, tc.sql, want, d) defer ast.ReleaseAST(tree) diff --git a/pkg/sql/ast/render_alias_quoting_632_test.go b/pkg/sql/ast/render_alias_quoting_632_test.go index 18cc1753..9456fb2d 100644 --- a/pkg/sql/ast/render_alias_quoting_632_test.go +++ b/pkg/sql/ast/render_alias_quoting_632_test.go @@ -210,7 +210,7 @@ func TestAliasRendering_BenignAndReserved_PR632(t *testing.T) { {"", `SELECT amount AS total FROM t`, `SELECT amount AS total FROM t`}, {"", `SELECT * FROM t AS u`, `SELECT * FROM t u`}, {"", `SELECT * FROM t u JOIN v AS w ON u.id = w.id`, `SELECT * FROM t u INNER JOIN v w ON u.id = w.id`}, - {"", `SELECT amount AS "Total" FROM t ORDER BY "Total"`, `SELECT amount AS Total FROM t ORDER BY Total`}, + {"", `SELECT amount AS "Total" FROM t ORDER BY "Total"`, `SELECT amount AS "Total" FROM t ORDER BY "Total"`}, {"", `SELECT amount AS "select" FROM t ORDER BY "select"`, `SELECT amount AS "select" FROM t ORDER BY "select"`}, {"", `SELECT * FROM t AS "from"`, `SELECT * FROM t "from"`}, {"", `SELECT SUM(x) OVER w FROM t WINDOW w AS (ORDER BY x)`, `SELECT SUM(x) OVER w FROM t WINDOW w AS (ORDER BY x)`}, diff --git a/pkg/sql/ast/render_ddl_name_quoting_632_test.go b/pkg/sql/ast/render_ddl_name_quoting_632_test.go index f5815dfe..9c3f5f15 100644 --- a/pkg/sql/ast/render_ddl_name_quoting_632_test.go +++ b/pkg/sql/ast/render_ddl_name_quoting_632_test.go @@ -350,7 +350,7 @@ func TestDDLNameRendering_Benign_PR632(t *testing.T) { `CREATE TABLE s.t (id INT PRIMARY KEY, name VARCHAR(100) NOT NULL, owner_id INT REFERENCES users (id))`}, {"", `CREATE TABLE t (a INT, b INT, PRIMARY KEY (a, b), FOREIGN KEY (b) REFERENCES r (id))`, `CREATE TABLE t (a INT, b INT, PRIMARY KEY (a, b), FOREIGN KEY (b) REFERENCES r (id))`}, - {"", `CREATE TABLE "Mixed" ("Col" INT)`, `CREATE TABLE Mixed (Col INT)`}, + {"", `CREATE TABLE "Mixed" ("Col" INT)`, `CREATE TABLE "Mixed" ("Col" INT)`}, {"", `CREATE TABLE t ("select" INT)`, `CREATE TABLE t ("select" INT)`}, {"", `CREATE TABLE t (a Nullable(String), c Enum8('a' = 1, 'b' = -2), d DateTime64(3, 'UTC'))`, `CREATE TABLE t (a Nullable(String), c Enum8('a'= 1, 'b'= - 2), d DateTime64(3, 'UTC'))`}, @@ -368,7 +368,7 @@ func TestDDLNameRendering_Benign_PR632(t *testing.T) { {"", `MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.a = s.a WHEN NOT MATCHED THEN INSERT (id, a) VALUES (s.id, s.a)`, `MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.a = s.a WHEN NOT MATCHED THEN INSERT (id, a) VALUES (s.id, s.a)`}, {"", `SELECT COUNT(*), COALESCE(a, 1), NOW(), IF(a, 1, 2), "Fn"(1) FROM t`, - `SELECT COUNT(*), COALESCE(a, 1), NOW(), IF(a, 1, 2), Fn(1) FROM t`}, + `SELECT COUNT(*), COALESCE(a, 1), NOW(), IF(a, 1, 2), "Fn"(1) FROM t`}, {keywords.DialectClickHouse, `SELECT quantile(0.5)(x) FROM t`, `SELECT quantile(0.5)(x) FROM t`}, {"", `SELECT * FROM t FOR UPDATE OF t`, `SELECT * FROM t FOR UPDATE OF t`}, {"", `SELECT INTERVAL '1 day', INTERVAL 5 DAY FROM t`, `SELECT INTERVAL '1 day', INTERVAL 5 DAY FROM t`}, diff --git a/pkg/sql/ast/render_injection_632_test.go b/pkg/sql/ast/render_injection_632_test.go index 9fa15a1b..d3aa12a0 100644 --- a/pkg/sql/ast/render_injection_632_test.go +++ b/pkg/sql/ast/render_injection_632_test.go @@ -198,20 +198,23 @@ func TestPivot_HandBuiltUnsafeItems_PR632(t *testing.T) { // TestQuotedIdentifier_ConsistentWithDefinitions_PR632 checks that a quoted // reference renders with the same quoting as the alias or CTE column it -// refers to. Those definitions are stored as plain strings and render bare -// (case folded by the database), so a case-only quoted reference must render -// bare too or it no longer matches its definition. +// refers to. Both record the quotes a name needs for its case (see +// ast.NameQuote), so the quotes are kept on both sides and they keep matching +// after the database folds unquoted names. func TestQuotedIdentifier_ConsistentWithDefinitions_PR632(t *testing.T) { cases := []struct { name string sql string want string }{ - {"order_by_alias", `SELECT amount AS "Total" FROM t ORDER BY "Total"`, `SELECT amount AS Total FROM t ORDER BY Total`}, - {"cte_column", `WITH cte (a, "B") AS (SELECT 1, 2) SELECT "B" FROM cte`, `WITH cte (a, B) AS (SELECT 1, 2) SELECT B FROM cte`}, - {"derived_alias", `SELECT s."Total" FROM (SELECT amount AS "Total" FROM t) AS s`, `SELECT s.Total FROM (SELECT amount AS Total FROM t) s`}, + {"order_by_alias", `SELECT amount AS "Total" FROM t ORDER BY "Total"`, `SELECT amount AS "Total" FROM t ORDER BY "Total"`}, + {"cte_column", `WITH cte (a, "B") AS (SELECT 1, 2) SELECT "B" FROM cte`, `WITH cte (a, "B") AS (SELECT 1, 2) SELECT "B" FROM cte`}, + {"derived_alias", `SELECT s."Total" FROM (SELECT amount AS "Total" FROM t) AS s`, `SELECT s."Total" FROM (SELECT amount AS "Total" FROM t) s`}, + // "source" is a keyword, so it keeps its quotes as alias and qualifier. {"metabase_style", `SELECT "source"."CATEGORY" AS "CATEGORY" FROM (SELECT category AS "CATEGORY" FROM p) AS "source" GROUP BY "source"."CATEGORY"`, - `SELECT source.CATEGORY AS CATEGORY FROM (SELECT category AS CATEGORY FROM p) source GROUP BY source.CATEGORY`}, + `SELECT "source"."CATEGORY" AS "CATEGORY" FROM (SELECT category AS "CATEGORY" FROM p) "source" GROUP BY "source"."CATEGORY"`}, + // Quotes a lower-case name does not need are still dropped on both sides. + {"lower_case_dropped", `SELECT s."total" FROM (SELECT amount AS "total" FROM t) AS "s"`, `SELECT s.total FROM (SELECT amount AS total FROM t) s`}, // Reserved words still keep their quotes. {"reserved_kept", `SELECT "select", "key" FROM t`, `SELECT "select", "key" FROM t`}, } diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 5087e6e6..bcd19c68 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -92,10 +92,10 @@ func (a AST) SQL() string { // a reserved word ("select", "key", "null") is re-quoted so it re-parses as a // name. Names, and each part of the Table qualifier, are otherwise // double-quoted only when they contain characters other than letters, digits, -// '_', '*' and '.'. Case alone does not trigger quoting: aliases and CTE/view -// column lists record no quoting and render bare, so a quoted "Total" that -// refers to one must render bare as well to keep matching it after the -// database folds case. +// '_', '*' and '.', or when Quotes records that the source delimited them for +// a reason a bare rendering would lose (letter case, a keyword). The +// delimiters are those of Quotes' style, else QuoteStyle, else ANSI double +// quotes. func (i *Identifier) SQL() string { if i == nil { return "" @@ -103,7 +103,7 @@ func (i *Identifier) SQL() string { if i.Table == "" && !i.Quoted && i.QuoteStyle == 0 && isVariableName(i.Name) { return i.Name } - if !i.quotedReserved() && !identifierNeedsQuoting(i.Name) && (i.Table == "" || !qualifierNeedsQuoting(i.Table)) { + if !i.Quotes.HasParts() && !i.quotedReserved() && !identifierNeedsQuoting(i.Name) && (i.Table == "" || !qualifierNeedsQuoting(i.Table)) { if i.Table == "" { return i.Name } @@ -123,15 +123,23 @@ func (i *Identifier) writeSQL(buf *bytes.Buffer) { buf.WriteString(i.Name) return } + style := i.QuoteStyle + if i.Quotes.HasStyle() { + style = i.Quotes.Style() + } if i.Table != "" { - writeDottedIdentifierStyle(buf, i.Table, i.QuoteStyle) + if i.Quotes == 0 { + writeDottedIdentifierStyle(buf, i.Table, style) + } else { + writeIdentifierQualifier(buf, i.Table, style, i.Quotes) + } buf.WriteByte('.') } - if i.quotedReserved() || identifierNeedsQuoting(i.Name) { - writeDelimitedIdentifier(buf, i.Name, i.QuoteStyle) - return + need := i.quotedReserved() || identifierNeedsQuoting(i.Name) + if i.Quotes != 0 { + need = partDelimited(i.Name, i.Quotes.Part(0), need, i.Quotes) } - buf.WriteString(i.Name) + writeNamePart(buf, i.Name, need, style) } // quotedReserved reports whether i is a Quoted name that is a reserved word, @@ -519,7 +527,7 @@ func (a *AliasedExpression) writeSQL(buf *bytes.Buffer) { } writeExpr(buf, a.Expr) buf.WriteString(" AS ") - writeName(buf, a.Alias) + writeNameQ(buf, a.Alias, a.AliasQuote) } // SQL returns the SQL representation of this CAST expression in the form @@ -793,7 +801,7 @@ func (f *FunctionCall) writeSQL(buf *bytes.Buffer) { if f == nil { return } - writeFunctionName(buf, f.Name) + writeFunctionNameQ(buf, f.Name, f.NameQuote) if len(f.Parameters) > 0 { // ClickHouse parametric aggregates: quantile(0.5)(x). // Parameters are rendered in their own parenthesis group before args. @@ -1540,7 +1548,7 @@ func (c *CreateTableStatement) SQL() string { if c.IfNotExists { sb.WriteString("IF NOT EXISTS ") } - writeQualifiedName(sb, c.Name) + writeQualifiedNameQ(sb, c.Name, c.NameQuote) sb.WriteString(" (") parts := make([]string, 0, len(c.Columns)+len(c.Constraints)) @@ -1811,10 +1819,10 @@ func (c *CreateViewStatement) SQL() string { if c.IfNotExists { sb.WriteString("IF NOT EXISTS ") } - writeQualifiedName(sb, c.Name) + writeQualifiedNameQ(sb, c.Name, c.NameQuote) if len(c.Columns) > 0 { sb.WriteString(" (") - writeNameList(sb, c.Columns) + writeNameListQ(sb, c.Columns, c.ColumnQuotes) sb.WriteString(")") } sb.WriteString(" AS ") @@ -1891,13 +1899,13 @@ func (m *MergeStatement) SQL() string { sb.WriteString(tableRefSQL(&m.TargetTable)) if m.TargetAlias != "" { sb.WriteString(" ") - writeTableAlias(sb, m.TargetAlias) + writeTableAliasQ(sb, m.TargetAlias, m.TargetAliasQuote) } sb.WriteString(" USING ") sb.WriteString(tableRefSQL(&m.SourceTable)) if m.SourceAlias != "" { sb.WriteString(" ") - writeTableAlias(sb, m.SourceAlias) + writeTableAliasQ(sb, m.SourceAlias, m.SourceAliasQuote) } sb.WriteString(" ON ") sb.WriteString(exprSQL(m.OnCondition)) @@ -2249,11 +2257,11 @@ func writeTableRef(buf *bytes.Buffer, t *TableReference) { t.SetSubquery.writeSQL(buf) buf.WriteByte(')') } else { - writeQualifiedName(buf, t.Name) + writeQualifiedNameQ(buf, t.Name, t.NameQuote) } if t.Alias != "" && t.Pivot == nil && t.Unpivot == nil { buf.WriteByte(' ') - writeTableAlias(buf, t.Alias) + writeTableAliasQ(buf, t.Alias, t.AliasQuote) writeTableColumnAliases(buf, t) } if t.Final { @@ -2269,7 +2277,7 @@ func writeTableRef(buf *bytes.Buffer, t *TableReference) { writeUnpivotClause(buf, t.Unpivot) if t.Alias != "" && (t.Pivot != nil || t.Unpivot != nil) { buf.WriteString(" AS ") - writeTableAlias(buf, t.Alias) + writeTableAliasQ(buf, t.Alias, t.AliasQuote) } } @@ -2281,7 +2289,7 @@ func writeTableColumnAliases(buf *bytes.Buffer, t *TableReference) { return } buf.WriteByte('(') - writeColumnNameList(buf, t.ColumnAliases.Names) + writeColumnNameListQ(buf, t.ColumnAliases.Names, t.ColumnAliases.Quotes) buf.WriteByte(')') } @@ -2440,13 +2448,13 @@ func writeCTE(buf *bytes.Buffer, cte *CommonTableExpr) { if cte.ScalarExpr != nil { writeExpr(buf, cte.ScalarExpr) buf.WriteString(" AS ") - writeName(buf, cte.Name) + writeNameQ(buf, cte.Name, cte.NameQuote) return } - writeName(buf, cte.Name) + writeNameQ(buf, cte.Name, cte.NameQuote) if len(cte.Columns) > 0 { buf.WriteString(" (") - writeNameList(buf, cte.Columns) + writeNameListQ(buf, cte.Columns, cte.ColumnQuotes) buf.WriteByte(')') } buf.WriteString(" AS ") @@ -2520,7 +2528,7 @@ func writeQuerySettings(buf *bytes.Buffer, settings []QuerySetting) { func columnDefSQL(c *ColumnDef) string { sb := getBuf() defer putBuf(sb) - writeColumnName(sb, c.Name) + writeTableAliasQ(sb, c.Name, c.NameQuote) sb.WriteString(" ") writeDataType(sb, c.Type) for _, con := range c.Constraints { @@ -2636,7 +2644,7 @@ func mergeActionSQL(a *MergeAction) string { case "UPDATE": sets := make([]string, len(a.SetClauses)) for i, s := range a.SetClauses { - sets[i] = QuoteQualifiedName(s.Column) + " = " + exprSQL(s.Value) + sets[i] = QuoteQualifiedNameWith(s.Column, s.ColumnQuote) + " = " + exprSQL(s.Value) } return "UPDATE SET " + strings.Join(sets, ", ") case "INSERT": @@ -2651,7 +2659,7 @@ func mergeActionSQL(a *MergeAction) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(QuoteColumnName(c)) + sb.WriteString(QuoteColumnNameWith(c, nameQuoteAt(a.ColumnQuotes, i))) } sb.WriteString(")") } diff --git a/pkg/sql/ast/sql_dml.go b/pkg/sql/ast/sql_dml.go index 8513fb00..5126e607 100644 --- a/pkg/sql/ast/sql_dml.go +++ b/pkg/sql/ast/sql_dml.go @@ -66,10 +66,10 @@ func (d *DMLTargetFields) writeDMLTargets(buf *bytes.Buffer, name, alias string) } first := true if name != "" { - writeQualifiedName(buf, name) + writeQualifiedNameQ(buf, name, d.TableNameQuote) if alias != "" { buf.WriteByte(' ') - writeTableAlias(buf, alias) + writeTableAliasQ(buf, alias, d.AliasQuote) } first = false } @@ -144,7 +144,7 @@ func (i *InsertStatement) writeInsertHead(buf *bytes.Buffer) { i.TableFunction.writeSQL(buf) return } - writeQualifiedName(buf, i.TableName) + writeQualifiedNameQ(buf, i.TableName, i.TableNameQuote) } // writeInsertBeforeSource writes the clauses between the column list and the diff --git a/pkg/sql/parser/connect_by_expressions_test.go b/pkg/sql/parser/connect_by_expressions_test.go index b50828fd..00ac8a76 100644 --- a/pkg/sql/parser/connect_by_expressions_test.go +++ b/pkg/sql/parser/connect_by_expressions_test.go @@ -120,8 +120,8 @@ func TestParser_ConnectByPriorColumnName(t *testing.T) { } // TestParser_ConnectByQuotedPriorIsIdentifier verifies a double-quoted -// "PRIOR" is a plain identifier operand, not the PRIOR operator. Only the -// tree is checked: SQL() does not re-quote identifiers. +// "PRIOR" is a plain identifier operand, not the PRIOR operator. SQL() keeps +// its quotes, so the rendering does not turn it into the operator. func TestParser_ConnectByQuotedPriorIsIdentifier(t *testing.T) { for _, d := range []keywords.SQLDialect{keywords.DialectOracle, keywords.DialectMariaDB} { t.Run(string(d), func(t *testing.T) { @@ -135,7 +135,7 @@ func TestParser_ConnectByQuotedPriorIsIdentifier(t *testing.T) { t.Fatalf("expected *ast.SelectStatement, got %T", tree.Statements[0]) } got := rhs596Shape(t, stmt.ConnectBy.Condition) - want := "(AND (= (PRIOR id) parent_id) (= (+ PRIOR 1) b))" + want := `(AND (= (PRIOR id) parent_id) (= (+ "PRIOR" 1) b))` if got != want { t.Fatalf("shape = %s, want %s", got, want) } diff --git a/pkg/sql/parser/cte.go b/pkg/sql/parser/cte.go index d67ef851..996c1a71 100644 --- a/pkg/sql/parser/cte.go +++ b/pkg/sql/parser/cte.go @@ -152,11 +152,13 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { } scalarName := p.currentToken.Token.Value scalarPos := p.currentLocation() + scalarQuote := p.currentNameQuote() p.advance() return &ast.CommonTableExpr{ Name: scalarName, ScalarExpr: scalarExpr, Pos: scalarPos, + NameQuote: scalarQuote, }, nil } @@ -166,10 +168,12 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { } cteNamePos := p.currentLocation() name := p.currentToken.Token.Value + nameQuote := p.currentNameQuote() p.advance() // Parse optional column list var columns []string + var columnQuotes []ast.NameQuote if p.isType(models.TokenTypeLParen) { // "name (SELECT ...)" is a CTE missing its AS keyword, not a column // list; report the missing AS instead of "expected column name" (#537). @@ -182,6 +186,7 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { if !p.isIdentifier() { return nil, p.expectedError("column name") } + columnQuotes = nameQuoteList(columnQuotes, len(columns), p.currentNameQuote()) columns = append(columns, p.currentToken.Token.Value) p.advance() @@ -265,6 +270,8 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) { Statement: stmt, Materialized: materialized, Pos: cteNamePos, + NameQuote: nameQuote, + ColumnQuotes: columnQuotes, }, nil } diff --git a/pkg/sql/parser/ddl.go b/pkg/sql/parser/ddl.go index 8b1ac435..a6bcf63b 100644 --- a/pkg/sql/parser/ddl.go +++ b/pkg/sql/parser/ddl.go @@ -190,11 +190,12 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er } // Parse table name (supports schema.table qualification and double-quoted identifiers) - createTableName, err := p.parseQualifiedName() + createTableName, createTableQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "table name") } stmt.Name = createTableName + stmt.NameQuote = createTableQuote // Snowflake: COPY GRANTS modifier before the column list or AS SELECT. // Consumed but not modeled on the AST. diff --git a/pkg/sql/parser/ddl_columns.go b/pkg/sql/parser/ddl_columns.go index 50e8507b..b70f716c 100644 --- a/pkg/sql/parser/ddl_columns.go +++ b/pkg/sql/parser/ddl_columns.go @@ -43,7 +43,7 @@ func (p *Parser) parseColumnName() *ast.Identifier { return nil } pos := p.currentLocation() - ident := &ast.Identifier{Name: val, Pos: pos} + ident := &ast.Identifier{Name: val, Pos: pos, Quotes: p.currentNameQuote()} p.advance() return ident } @@ -89,8 +89,9 @@ func (p *Parser) parseColumnDef() (*ast.ColumnDef, error) { } colDef := &ast.ColumnDef{ - Name: name.Name, - Type: dataTypeStr, + Name: name.Name, + Type: dataTypeStr, + NameQuote: name.Quotes, } // ClickHouse column options that may appear between the type and the diff --git a/pkg/sql/parser/ddl_view.go b/pkg/sql/parser/ddl_view.go index c84676f0..31c222f6 100644 --- a/pkg/sql/parser/ddl_view.go +++ b/pkg/sql/parser/ddl_view.go @@ -46,11 +46,12 @@ func (p *Parser) parseCreateView(orReplace, temporary bool) (*ast.CreateViewStat } // Parse view name (supports schema.view qualification and double-quoted identifiers) - viewName, err := p.parseQualifiedName() + viewName, viewQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "view name") } stmt.Name = viewName + stmt.NameQuote = viewQuote // Parse optional column list if p.isType(models.TokenTypeLParen) { @@ -59,6 +60,7 @@ func (p *Parser) parseCreateView(orReplace, temporary bool) (*ast.CreateViewStat if !p.isIdentifier() { return nil, p.expectedError("column name") } + stmt.ColumnQuotes = nameQuoteList(stmt.ColumnQuotes, len(stmt.Columns), p.currentNameQuote()) stmt.Columns = append(stmt.Columns, p.currentToken.Token.Value) p.advance() diff --git a/pkg/sql/parser/dml_common.go b/pkg/sql/parser/dml_common.go index b45062b0..6bc7eb2e 100644 --- a/pkg/sql/parser/dml_common.go +++ b/pkg/sql/parser/dml_common.go @@ -153,20 +153,22 @@ func (p *Parser) parseDMLTargetRef() (ast.TableReference, error) { if p.isType(models.TokenTypeLParen) { return p.parseFromTableReference() } - name, err := p.parseQualifiedName() + name, nameQuote, err := p.parseQualifiedNameQuoted() if err != nil { return ast.TableReference{}, p.qualifiedNameError(err, "table name") } - ref := ast.TableReference{Name: name} + ref := ast.TableReference{Name: name, NameQuote: nameQuote} if p.isType(models.TokenTypeAs) { p.advance() // Consume AS if !p.isIdentifier() { return ref, p.expectedError("alias after AS") } ref.Alias = p.currentToken.Token.Value + ref.AliasQuote = p.currentNameQuote() p.advance() } else if p.isDMLTargetAlias() { ref.Alias = p.currentToken.Token.Value + ref.AliasQuote = p.currentNameQuote() p.advance() } return ref, nil @@ -183,6 +185,7 @@ func (p *Parser) parseDMLTargets(name, alias *string, d *ast.DMLTargetFields, mu } if first.Subquery == nil && first.SetSubquery == nil && first.TableFunc == nil { *name, *alias = first.Name, first.Alias + d.TableNameQuote, d.AliasQuote = first.NameQuote, first.AliasQuote } else { d.Tables = append(d.Tables, first) } @@ -251,10 +254,13 @@ func (p *Parser) parseAssignmentColumn() (ast.Expression, error) { if !p.isWordToken() { return nil, p.expectedError("column name") } + q := p.currentIdentQuote() ident := &ast.Identifier{ - Name: p.currentToken.Token.Value, - Pos: p.currentLocation(), - Quoted: p.isType(models.TokenTypeDoubleQuotedString), + Name: p.currentToken.Token.Value, + Pos: p.currentLocation(), + Quoted: q == '"', + QuoteStyle: q, + Quotes: p.appendNamePartQuoted(0, q), } p.advance() for p.isType(models.TokenTypePeriod) { @@ -268,7 +274,12 @@ func (p *Parser) parseAssignmentColumn() (ast.Expression, error) { ident.Table += "." + ident.Name } ident.Name = p.currentToken.Token.Value - ident.Quoted = p.isType(models.TokenTypeDoubleQuotedString) + q = p.currentIdentQuote() + ident.Quoted = q == '"' + if q != 0 { + ident.QuoteStyle = q + } + ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) p.advance() } return ident, nil diff --git a/pkg/sql/parser/dml_insert.go b/pkg/sql/parser/dml_insert.go index 7c3b541d..cdde9c95 100644 --- a/pkg/sql/parser/dml_insert.go +++ b/pkg/sql/parser/dml_insert.go @@ -65,11 +65,12 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { stmt.TableFunction = fn } else { // Parse table name (supports schema.table qualification and double-quoted identifiers) - tableName, err := p.parseQualifiedName() + tableName, tableQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "table name") } stmt.TableName = tableName + stmt.TableNameQuote = tableQuote } // Parse column list if present @@ -307,7 +308,7 @@ func (p *Parser) parseInsertColumnList() ([]ast.Expression, error) { if !p.isWordToken() { return nil, p.expectedError("column name") } - columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) + columns = append(columns, p.currentColumnIdent()) p.advance() if !p.isType(models.TokenTypeComma) { break @@ -383,7 +384,7 @@ func (p *Parser) parseInsertSetList() ([]ast.Expression, []ast.Expression, error if !p.isWordToken() { return nil, nil, p.expectedError("column name") } - columns = append(columns, &ast.Identifier{Name: p.currentToken.Token.Value}) + columns = append(columns, p.currentColumnIdent()) p.advance() if !p.isType(models.TokenTypeEq) { return nil, nil, p.expectedError("=") @@ -490,7 +491,7 @@ func (p *Parser) parseOnConflictClause() (*ast.OnConflict, error) { switch p.peekToken().Token.Type { case models.TokenTypeComma, models.TokenTypeRParen: // A plain column name (which may be a keyword: key, value). - target = &ast.Identifier{Name: p.currentToken.Token.Value} + target = p.currentColumnIdent() p.advance() } } diff --git a/pkg/sql/parser/dml_merge.go b/pkg/sql/parser/dml_merge.go index c3969255..6f67e0f2 100644 --- a/pkg/sql/parser/dml_merge.go +++ b/pkg/sql/parser/dml_merge.go @@ -54,9 +54,11 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) { return nil, p.expectedError("target alias after AS") } stmt.TargetAlias = p.currentToken.Token.Value + stmt.TargetAliasQuote = p.currentNameQuote() p.advance() } else if p.canBeAlias() && !p.isType(models.TokenTypeUsing) && p.currentToken.Token.Value != "USING" { stmt.TargetAlias = p.currentToken.Token.Value + stmt.TargetAliasQuote = p.currentNameQuote() p.advance() } @@ -74,6 +76,7 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) { } // The renderers write the alias from SourceAlias. stmt.SourceAlias, sourceRef.Alias = sourceRef.Alias, "" + stmt.SourceAliasQuote, sourceRef.AliasQuote = sourceRef.AliasQuote, 0 stmt.SourceTable = sourceRef } else { sourceRef, err := p.parseTableReference() @@ -90,9 +93,11 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) { return nil, p.expectedError("source alias after AS") } stmt.SourceAlias = p.currentToken.Token.Value + stmt.SourceAliasQuote = p.currentNameQuote() p.advance() } else if stmt.SourceAlias == "" && p.canBeAlias() && !p.isType(models.TokenTypeOn) && p.currentToken.Token.Value != "ON" { stmt.SourceAlias = p.currentToken.Token.Value + stmt.SourceAliasQuote = p.currentNameQuote() p.advance() } @@ -229,6 +234,7 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { } // Handle qualified column names (e.g., t.name) columnName := p.currentToken.Token.Value + columnQuote := p.currentNameQuote() p.advance() // Check for qualified name (table.column) @@ -238,10 +244,11 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { return nil, p.expectedError("column name after .") } columnName = fmt.Sprintf("%s.%s", columnName, p.currentToken.Token.Value) + columnQuote = p.appendNamePart(columnQuote) p.advance() } - setClause := ast.SetClause{Column: columnName} + setClause := ast.SetClause{Column: columnName, ColumnQuote: columnQuote} if !p.isType(models.TokenTypeEq) { return nil, p.expectedError("=") @@ -277,6 +284,7 @@ func (p *Parser) parseMergeAction(clauseType string) (*ast.MergeAction, error) { if !p.isIdentifier() { return nil, p.expectedError("column name") } + action.ColumnQuotes = nameQuoteList(action.ColumnQuotes, len(action.Columns), p.currentNameQuote()) action.Columns = append(action.Columns, p.currentToken.Token.Value) p.advance() diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 446bca83..5c1bd604 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -199,19 +199,21 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { identPos := p.currentLocation() identName := p.currentToken.Token.Value identQuote := p.currentIdentQuote() + identQuotes := p.appendNamePartQuoted(0, identQuote) p.advance() // Check for function call (identifier followed by parentheses) if p.isType(models.TokenTypeLParen) { // Oracle outer-join marker: col(+) if p.isOuterJoinMarker() { - return p.parseOuterJoinMarker(&ast.Identifier{Name: identName, Pos: identPos, Quoted: identQuote == '"', QuoteStyle: identQuote}), nil + return p.parseOuterJoinMarker(&ast.Identifier{Name: identName, Pos: identPos, Quoted: identQuote == '"', QuoteStyle: identQuote, Quotes: identQuotes}), nil } // This is a function call funcCall, err := p.parseFunctionCall(identName) if err != nil { return nil, err } + funcCall.NameQuote = identQuotes // Assign position of function name if funcCall.Pos.IsZero() { funcCall.Pos = identPos @@ -234,7 +236,7 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { } // Handle regular identifier or qualified identifier (table.column or table.*) - ident := &ast.Identifier{Name: identName, Pos: identPos, Quoted: identQuote == '"', QuoteStyle: identQuote} + ident := &ast.Identifier{Name: identName, Pos: identPos, Quoted: identQuote == '"', QuoteStyle: identQuote, Quotes: identQuotes} // Check for qualified identifier (table.column) or qualified asterisk (table.*) if p.isType(models.TokenTypePeriod) { @@ -247,6 +249,7 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { ident.Table = ident.Name ident.Name = "*" ident.Quoted = false + ident.Quotes = ident.Quotes.AppendPart(false) p.advance() } else if p.isQualifiedNamePart() { // Handle table.column (qualified identifier). @@ -262,6 +265,7 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { if q != 0 { ident.QuoteStyle = q } + ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) p.advance() // Multi-part reference: db.table.column (MySQL/MariaDB), @@ -610,6 +614,7 @@ func (p *Parser) parseMultiPartColumnRef(ident *ast.Identifier) error { case p.isType(models.TokenTypeAsterisk) || p.isType(models.TokenTypeMul): parts[n] = "*" lastQuoted = false + ident.Quotes = ident.Quotes.AppendPart(false) n++ p.advance() if p.isType(models.TokenTypePeriod) { @@ -622,6 +627,7 @@ func (p *Parser) parseMultiPartColumnRef(ident *ast.Identifier) error { if q != 0 { ident.QuoteStyle = q } + ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) n++ p.advance() default: diff --git a/pkg/sql/parser/expressions_primary_ext.go b/pkg/sql/parser/expressions_primary_ext.go index 3a36b374..befb8af5 100644 --- a/pkg/sql/parser/expressions_primary_ext.go +++ b/pkg/sql/parser/expressions_primary_ext.go @@ -92,6 +92,7 @@ func (p *Parser) parseQualifiedFunctionCall(ident *ast.Identifier) (ast.Expressi if err != nil { return nil, err } + fc.NameQuote = ident.Quotes if fc.Pos.IsZero() { fc.Pos = ident.Pos } diff --git a/pkg/sql/parser/name_quote.go b/pkg/sql/parser/name_quote.go new file mode 100644 index 00000000..dd5d18a5 --- /dev/null +++ b/pkg/sql/parser/name_quote.go @@ -0,0 +1,194 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "fmt" + "strings" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" +) + +// This file records how names were delimited in the source (ast.NameQuote), +// so renderers can keep the quotes a name needs: "CreatedAt" quoted for its +// case, "table" quoted because it is a keyword, a MySQL `exists` alias with +// its backticks, and an Oracle bare emp# without any. + +// identifierCaseFolding returns how the dialect matches the case of unquoted +// identifiers (see ast.IdentifierCaseFolding). Generic SQL folds to lower +// case like PostgreSQL. +func (p *Parser) identifierCaseFolding() ast.IdentifierCaseFolding { + switch keywords.SQLDialect(p.dialect) { + case keywords.DialectOracle, keywords.DialectSnowflake: + return ast.FoldUpper + case keywords.DialectMySQL, keywords.DialectMariaDB, keywords.DialectSQLServer, + keywords.DialectSQLite, keywords.DialectClickHouse, keywords.DialectBigQuery: + return ast.FoldNone + } + return ast.FoldLower +} + +// delimitedNameMustStayQuoted reports whether name, written delimited with +// quote in the source, must keep delimiters to mean the same name: see +// ast.DelimitedNameMustStayQuoted, plus any word the tokenizer reads as a +// keyword when bare. +func (p *Parser) delimitedNameMustStayQuoted(name string, quote rune) bool { + if quote == 0 { + return false + } + return ast.DelimitedNameMustStayQuoted(name, p.identifierCaseFolding()) || tokenizer.IsKeyword(name) || + isContextualKeywordName(name) +} + +// contextualKeywordNames lists, in upper case, words the tokenizer reads as +// plain identifiers but that the databases (or the parser, in context) treat +// as niladic functions, pseudo-columns or operators when written bare: +// "user" is a column, user is the current user; "PRIOR" is a column, PRIOR +// in CONNECT BY is the operator. A delimited name equal to one of them keeps +// its delimiters. +var contextualKeywordNames = map[string]bool{ + "PRIOR": true, "CONNECT_BY_ROOT": true, + "USER": true, "CURRENT_USER": true, "SESSION_USER": true, "SYSTEM_USER": true, + "CURRENT_ROLE": true, "CURRENT_CATALOG": true, "CURRENT_SCHEMA": true, + "CURRENT_DATE": true, "CURRENT_TIME": true, "CURRENT_TIMESTAMP": true, + "LOCALTIME": true, "LOCALTIMESTAMP": true, + "SYSDATE": true, "SYSTIMESTAMP": true, "ROWNUM": true, "ROWID": true, "LEVEL": true, +} + +// maxContextualKeywordLen is the length of the longest +// contextualKeywordNames word. +const maxContextualKeywordLen = len("CURRENT_TIMESTAMP") + +// isContextualKeywordName reports whether name is in contextualKeywordNames, +// ignoring ASCII case, without allocating. +func isContextualKeywordName(name string) bool { + if len(name) < 4 || len(name) > maxContextualKeywordLen { + return false + } + var up [maxContextualKeywordLen]byte + for i := 0; i < len(name); i++ { + c := name[i] + if c >= 'a' && c <= 'z' { + c -= 'a' - 'A' + } + up[i] = c + } + return contextualKeywordNames[string(up[:len(name)])] +} + +// isOracleBareHashName reports whether the current token is a bare Oracle +// identifier containing '#' after its first character (emp#, s.serial#). +func (p *Parser) isOracleBareHashName() bool { + return p.dialect == string(keywords.DialectOracle) && + strings.IndexByte(p.currentToken.Token.Value, '#') > 0 +} + +// currentNameQuote returns the NameQuote of the current token read as a +// single-part name: the part is marked when the token is a delimited +// identifier that must stay delimited (see delimitedNameMustStayQuoted). +func (p *Parser) currentNameQuote() ast.NameQuote { + return p.appendNamePart(0) +} + +// appendNamePart returns q extended by the current token as the next part of +// a dotted name (see ast.NameQuote.AppendPart). +func (p *Parser) appendNamePart(q ast.NameQuote) ast.NameQuote { + return p.appendNamePartQuoted(q, p.currentIdentQuote()) +} + +// appendNamePartQuoted is appendNamePart for a caller that already has the +// current token's delimiter (p.currentIdentQuote()). +func (p *Parser) appendNamePartQuoted(q ast.NameQuote, quote rune) ast.NameQuote { + if quote == 0 { + q = q.AppendPart(false) + if p.isOracleBareHashName() { + q = q.WithBareHash(true) + } + return q + } + keep := p.delimitedNameMustStayQuoted(p.currentToken.Token.Value, quote) + q = q.AppendPart(keep) + if keep && !q.HasStyle() { + q = q.WithStyle(quote) + } + return q +} + +// parseQualifiedNameQuoted parses a possibly qualified object name like +// parseQualifiedName and also returns how its parts were delimited. +func (p *Parser) parseQualifiedNameQuoted() (string, ast.NameQuote, error) { + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return "", 0, p.expectedError("identifier") + } + var parts [maxQualifiedNameParts]string + parts[0] = p.currentToken.Token.Value + q := p.currentNameQuote() + n := 1 + p.advance() + + // Check for schema.table or db.schema.table + for p.isType(models.TokenTypePeriod) { + p.advance() // Consume . + if n == maxQualifiedNameParts { + return "", 0, goerrors.InvalidSyntaxError( + fmt.Sprintf("qualified name has more than %d parts", maxQualifiedNameParts), + p.currentLocation(), + "", + ).WithCause(errQualifiedNameTooLong) + } + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return "", 0, p.expectedError("identifier after .") + } + parts[n] = p.currentToken.Token.Value + q = p.appendNamePart(q) + n++ + p.advance() + } + + if n == 1 { + return parts[0], q, nil + } + return strings.Join(parts[:n], "."), q, nil +} + +// nameQuoteList returns qs with q appended at index i, allocating the slice +// only once some name in the list records a NameQuote. +func nameQuoteList(qs []ast.NameQuote, i int, q ast.NameQuote) []ast.NameQuote { + if q == 0 && qs == nil { + return nil + } + for len(qs) < i { + qs = append(qs, 0) + } + return append(qs, q) +} + +// currentColumnIdent returns the current token as a single-part column name +// (an INSERT column, a JOIN USING column, an ON CONFLICT target), recording +// how it was delimited. It does not advance. +func (p *Parser) currentColumnIdent() *ast.Identifier { + q := p.currentIdentQuote() + return &ast.Identifier{ + Name: p.currentToken.Token.Value, + Pos: p.currentLocation(), + Quoted: q == '"', + QuoteStyle: q, + Quotes: p.appendNamePartQuoted(0, q), + } +} diff --git a/pkg/sql/parser/name_quote_test.go b/pkg/sql/parser/name_quote_test.go new file mode 100644 index 00000000..001d49c3 --- /dev/null +++ b/pkg/sql/parser/name_quote_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestNameQuote_SQLKeepsNeededQuotes checks that SQL() keeps the delimiters +// a name needs (letter case in a folding dialect, a keyword, a niladic word +// such as user) in every clause the parser records them for, writes them +// with the source's delimiter, and still drops quotes a name does not need. +// The output must also re-parse to the same SQL(). +func TestNameQuote_SQLKeepsNeededQuotes(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + in string + want string + }{ + // Letter case in PostgreSQL / generic SQL (fold to lower case). + {"pg qualified and aliases", keywords.DialectPostgreSQL, + `SELECT "users"."id", "Company"."name" AS "CompanyName" FROM "users" JOIN "companies" AS "Company" ON "Company"."id" = "users"."company_id"`, + `SELECT users.id, "Company".name AS "CompanyName" FROM users INNER JOIN companies "Company" ON "Company".id = users.company_id`}, + {"generic mixed case column", "", `SELECT "Mixed" FROM t`, `SELECT "Mixed" FROM t`}, + {"pg upper case table", keywords.DialectPostgreSQL, `SELECT * FROM "USERS"`, `SELECT * FROM "USERS"`}, + {"pg schema qualified table", keywords.DialectPostgreSQL, `SELECT * FROM "Sales"."Orders" o`, `SELECT * FROM "Sales"."Orders" o`}, + {"pg mixed parts", keywords.DialectPostgreSQL, `SELECT "s"."Orders"."Id" FROM "s"."Orders"`, `SELECT s."Orders"."Id" FROM s."Orders"`}, + {"pg qualified star", keywords.DialectPostgreSQL, `SELECT "T".* FROM t AS "T"`, `SELECT "T".* FROM t "T"`}, + {"pg cte name and columns", keywords.DialectPostgreSQL, + `WITH "Cte"("Total", n) AS (SELECT 1, 2) SELECT "Total" FROM "Cte"`, + `WITH "Cte" ("Total", n) AS (SELECT 1, 2) SELECT "Total" FROM "Cte"`}, + {"pg derived alias", keywords.DialectPostgreSQL, + `SELECT s."Total" FROM (SELECT amount AS "Total" FROM t) AS s`, + `SELECT s."Total" FROM (SELECT amount AS "Total" FROM t) s`}, + {"pg function names", keywords.DialectPostgreSQL, + `SELECT s."select"(1), "MySchema"."MyFunc"(1), "MyFunc"(2), pg_catalog.now()`, + `SELECT s."select"(1), "MySchema"."MyFunc"(1), "MyFunc"(2), pg_catalog.now()`}, + {"pg table function and column aliases", keywords.DialectPostgreSQL, + `SELECT "G"."Elem" FROM unnest(arr) AS "G"("Elem")`, + `SELECT "G"."Elem" FROM unnest(arr) "G"("Elem")`}, + {"pg join using", keywords.DialectPostgreSQL, `SELECT * FROM a JOIN b USING ("Id", id2)`, `SELECT * FROM a INNER JOIN b USING ("Id", id2)`}, + {"pg insert", keywords.DialectPostgreSQL, `INSERT INTO "MyTable" ("ColA", colb) VALUES (1, 2)`, `INSERT INTO "MyTable" ("ColA", colb) VALUES (1, 2)`}, + {"pg on conflict target", keywords.DialectPostgreSQL, + `INSERT INTO t (a) VALUES (1) ON CONFLICT ("Key") DO NOTHING`, + `INSERT INTO t (a) VALUES (1) ON CONFLICT ("Key") DO NOTHING`}, + {"pg update", keywords.DialectPostgreSQL, + `UPDATE "MyTable" AS "T" SET "ColA" = 1, "T"."ColB" = 2 WHERE "T"."ColC" = 3`, + `UPDATE "MyTable" "T" SET "ColA" = 1, "T"."ColB" = 2 WHERE "T"."ColC" = 3`}, + {"pg delete", keywords.DialectPostgreSQL, `DELETE FROM "MyTable" AS "T" WHERE "T".id = 1`, `DELETE FROM "MyTable" "T" WHERE "T".id = 1`}, + {"pg merge", keywords.DialectPostgreSQL, + `MERGE INTO "T" AS "Tgt" USING "S" "Src" ON "Tgt".id = "Src".id WHEN MATCHED THEN UPDATE SET "Val" = "Src"."Val" WHEN NOT MATCHED THEN INSERT ("Id", b) VALUES (1, 2)`, + `MERGE INTO "T" "Tgt" USING "S" "Src" ON "Tgt".id = "Src".id WHEN MATCHED THEN UPDATE SET "Val" = "Src"."Val" WHEN NOT MATCHED THEN INSERT ("Id", b) VALUES (1, 2)`}, + {"pg create table", keywords.DialectPostgreSQL, `CREATE TABLE "MyTable" ("ColA" int, "order" text, b int)`, `CREATE TABLE "MyTable" ("ColA" int, "order" text, b int)`}, + {"pg create view", keywords.DialectPostgreSQL, `CREATE VIEW "V" ("A", b) AS SELECT 1, 2`, `CREATE VIEW "V" ("A", b) AS SELECT 1, 2`}, + // Keywords and niladic words keep their quotes in every dialect. + {"pg keyword names", keywords.DialectPostgreSQL, + `SELECT 1 AS "select", 2 AS "order", "user", "group" FROM "table"`, + `SELECT 1 AS "select", 2 AS "order", "user", "group" FROM "table"`}, + {"pg lower case quotes dropped", keywords.DialectPostgreSQL, `SELECT "a"."b" AS "c" FROM "t" AS "a"`, `SELECT a.b AS c FROM t a`}, + // Oracle and Snowflake fold to upper case. + {"oracle mixed and lower case", keywords.DialectOracle, + `SELECT "ID", "NAME", "CreatedAt", "lower" FROM "AppUsers" WHERE "ID" = :1`, + `SELECT ID, NAME, "CreatedAt", "lower" FROM "AppUsers" WHERE ID = :1`}, + {"snowflake lower case alias", keywords.DialectSnowflake, `SELECT a AS "total", b AS "TOTAL" FROM t`, `SELECT a AS "total", b AS TOTAL FROM t`}, + {"oracle quoted prior", keywords.DialectOracle, + `SELECT id FROM t CONNECT BY PRIOR id = parent_id AND "PRIOR" = 1`, + `SELECT id FROM t CONNECT BY PRIOR id = parent_id AND "PRIOR" = 1`}, + // Oracle bare names with '#' stay bare. + {"oracle bare hash", keywords.DialectOracle, `SELECT s.serial#, emp# AS no# FROM v$session s`, `SELECT s.serial#, emp# AS no# FROM v$session s`}, + {"oracle quoted hash", keywords.DialectOracle, `SELECT "a#b" FROM t`, `SELECT "a#b" FROM t`}, + // Dialects that do not fold drop quotes needed only for case, and + // keep the source delimiter for the quotes they need. + {"mysql backtick alias", keywords.DialectMySQL, + "SELECT EXISTS(SELECT * FROM `users` WHERE `email` = 'a') AS `exists`", + "SELECT EXISTS (SELECT * FROM users WHERE email = 'a') AS `exists`"}, + {"mysql backtick keywords", keywords.DialectMySQL, + "SELECT 1 AS `select`, `Company`.`x` FROM `order` `group`, `Company`", + "SELECT 1 AS `select`, Company.x FROM `order` `group`, Company"}, + {"mysql insert reserved column", keywords.DialectMySQL, "INSERT INTO t (`select`) VALUES (1)", "INSERT INTO t (`select`) VALUES (1)"}, + {"sqlserver brackets", keywords.DialectSQLServer, + "SELECT [a b], [Group], [Name] FROM [dbo].[My Table] AS [t]", + "SELECT [a b], [Group], Name FROM dbo.[My Table] t"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := ParseWithDialect(tc.in, tc.dialect) + if err != nil { + t.Fatalf("parse %q: %v", tc.in, err) + } + defer ast.ReleaseAST(tree) + got := tree.SQL() + if got != tc.want { + t.Fatalf("SQL() mismatch\n got: %s\nwant: %s", got, tc.want) + } + again, err := ParseWithDialect(got, tc.dialect) + if err != nil { + t.Fatalf("re-parse %q: %v", got, err) + } + defer ast.ReleaseAST(again) + if again.SQL() != got { + t.Fatalf("re-parse changed SQL()\nfirst: %s\nsecond: %s", got, again.SQL()) + } + }) + } +} + +// TestNameQuote_RecordedOnAST checks the NameQuote values the parser stores. +func TestNameQuote_RecordedOnAST(t *testing.T) { + tree, err := ParseWithDialect(`SELECT "S"."T"."Col" AS "A", x."y" FROM "S"."Tbl" AS "B"`, keywords.DialectPostgreSQL) + if err != nil { + t.Fatalf("parse: %v", err) + } + defer ast.ReleaseAST(tree) + sel, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected *ast.SelectStatement, got %T", tree.Statements[0]) + } + ae, ok := sel.Columns[0].(*ast.AliasedExpression) + if !ok { + t.Fatalf("expected *ast.AliasedExpression, got %T", sel.Columns[0]) + } + if !ae.AliasQuote.Part(0) || ae.AliasQuote.Style() != '"' { + t.Errorf("AliasQuote = %08b, want part 0 with '\"'", ae.AliasQuote) + } + id, ok := ae.Expr.(*ast.Identifier) + if !ok { + t.Fatalf("expected *ast.Identifier, got %T", ae.Expr) + } + for i := 0; i < 3; i++ { + if !id.Quotes.Part(i) { + t.Errorf("identifier part %d not recorded (Quotes = %08b)", i, id.Quotes) + } + } + plain, ok := sel.Columns[1].(*ast.Identifier) + if !ok { + t.Fatalf("expected *ast.Identifier, got %T", sel.Columns[1]) + } + if plain.Quotes != 0 { + t.Errorf("x.\"y\" Quotes = %08b, want 0 (lower-case quotes are not needed)", plain.Quotes) + } + ref := sel.From[0] + if ref.NameQuote.Part(0) != true || ref.NameQuote.Part(1) != true { + t.Errorf("table NameQuote = %08b, want parts 0 and 1", ref.NameQuote) + } + if !ref.AliasQuote.Part(0) { + t.Errorf("table AliasQuote = %08b, want part 0", ref.AliasQuote) + } +} + +// TestNameQuote_PoolReuse checks that quote marks do not leak through the +// object pools into a later parse of unquoted names. +func TestNameQuote_PoolReuse(t *testing.T) { + quoted := `SELECT "A"."B" AS "C", "F"(1) FROM "T" AS "U" JOIN "V" "W" ON "U".x = "W".x` + plain := `SELECT A.B AS C, F(1) FROM T AS U JOIN V W ON U.x = W.x` + want := `SELECT A.B AS C, F(1) FROM T U INNER JOIN V W ON U.x = W.x` + for i := 0; i < 50; i++ { + tree, err := ParseWithDialect(quoted, keywords.DialectPostgreSQL) + if err != nil { + t.Fatalf("parse quoted: %v", err) + } + ast.ReleaseAST(tree) + tree, err = ParseWithDialect(plain, keywords.DialectPostgreSQL) + if err != nil { + t.Fatalf("parse plain: %v", err) + } + got := tree.SQL() + ast.ReleaseAST(tree) + if got != want { + t.Fatalf("iteration %d: SQL() = %s, want %s", i, got, want) + } + } +} diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 58de851c..78fa3303 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -17,7 +17,6 @@ package parser import ( "context" "errors" - "fmt" "strings" "sync" @@ -1075,7 +1074,7 @@ func (p *Parser) parseIdent() *ast.Identifier { } pos := p.currentLocation() q := p.currentIdentQuote() - ident := &ast.Identifier{Name: p.currentToken.Token.Value, Pos: pos, Quoted: q == '"', QuoteStyle: q} + ident := &ast.Identifier{Name: p.currentToken.Token.Value, Pos: pos, Quoted: q == '"', QuoteStyle: q, Quotes: p.appendNamePartQuoted(0, q)} p.advance() return ident } @@ -1166,42 +1165,17 @@ func (p *Parser) qualifiedNameError(err error, expected string) error { // linear in the input, and names with more than maxQualifiedNameParts parts // are rejected with E2004. func (p *Parser) parseQualifiedName() (string, error) { - if !p.isIdentifier() && !p.isNonReservedKeyword() { - return "", p.expectedError("identifier") - } - var parts [maxQualifiedNameParts]string - parts[0] = p.currentToken.Token.Value - n := 1 - p.advance() - - // Check for schema.table or db.schema.table - for p.isType(models.TokenTypePeriod) { - p.advance() // Consume . - if n == maxQualifiedNameParts { - return "", goerrors.InvalidSyntaxError( - fmt.Sprintf("qualified name has more than %d parts", maxQualifiedNameParts), - p.currentLocation(), - "", - ).WithCause(errQualifiedNameTooLong) - } - if !p.isIdentifier() && !p.isNonReservedKeyword() { - return "", p.expectedError("identifier after .") - } - parts[n] = p.currentToken.Token.Value - n++ - p.advance() - } - - return strings.Join(parts[:n], "."), nil + name, _, err := p.parseQualifiedNameQuoted() + return name, err } // Accepts IDENT or non-reserved keywords that can be used as table names func (p *Parser) parseTableReference() (*ast.TableReference, error) { - name, err := p.parseQualifiedName() + name, q, err := p.parseQualifiedNameQuoted() if err != nil { return nil, err } - return &ast.TableReference{Name: name}, nil + return &ast.TableReference{Name: name, NameQuote: q}, nil } // isNonReservedKeyword checks if current token is a non-reserved keyword diff --git a/pkg/sql/parser/pivot.go b/pkg/sql/parser/pivot.go index bc9ed2fd..7bab100b 100644 --- a/pkg/sql/parser/pivot.go +++ b/pkg/sql/parser/pivot.go @@ -66,12 +66,14 @@ func (p *Parser) parsePivotAlias(ref *ast.TableReference) { p.advance() // consume AS if p.isIdentifier() { ref.Alias = p.currentToken.Token.Value + ref.AliasQuote = p.currentNameQuote() p.advance() } return } if p.isIdentifier() { ref.Alias = p.currentToken.Token.Value + ref.AliasQuote = p.currentNameQuote() p.advance() } } diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index 3a61e477..22915ad6 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -500,8 +500,9 @@ func (p *Parser) parseSelectColumnList() ([]ast.Expression, error) { return nil, p.expectedError("alias name after AS") } alias := p.currentToken.Token.Value + aliasQuote := p.currentNameQuote() p.advance() - expr = &ast.AliasedExpression{Expr: expr, Alias: alias} + expr = &ast.AliasedExpression{Expr: expr, Alias: alias, AliasQuote: aliasQuote} } else if p.canBeAlias() { // Implicit aliasing (SELECT expr alias) is allowed for non-identifier // expressions (functions, literals, casts, etc.) and for bare identifiers @@ -511,8 +512,9 @@ func (p *Parser) parseSelectColumnList() ([]ast.Expression, error) { allowAlias := !isIdent || (isIdent && ident.Table == "" && p.isOraclePseudoColumn2(ident.Name)) || p.isQuotedIdentifierToken() if allowAlias { alias := p.currentToken.Token.Value + aliasQuote := p.currentNameQuote() p.advance() - expr = &ast.AliasedExpression{Expr: expr, Alias: alias} + expr = &ast.AliasedExpression{Expr: expr, Alias: alias, AliasQuote: aliasQuote} } } } diff --git a/pkg/sql/parser/select_clauses.go b/pkg/sql/parser/select_clauses.go index a888b703..08d10097 100644 --- a/pkg/sql/parser/select_clauses.go +++ b/pkg/sql/parser/select_clauses.go @@ -605,7 +605,7 @@ func (p *Parser) parseJoinCondition(joinType string, isNatural, isApply bool) (a if !p.isIdentifier() { return nil, nil, p.expectedError("column name in USING") } - usingColumns = append(usingColumns, &ast.Identifier{Name: p.currentToken.Token.Value, Pos: p.currentLocation()}) + usingColumns = append(usingColumns, p.currentColumnIdent()) p.advance() if !p.isType(models.TokenTypeComma) { break diff --git a/pkg/sql/parser/select_ext.go b/pkg/sql/parser/select_ext.go index fa95eb1d..6661665f 100644 --- a/pkg/sql/parser/select_ext.go +++ b/pkg/sql/parser/select_ext.go @@ -995,6 +995,7 @@ func (p *Parser) parseApplyFunctionCall(joinType string, ref *ast.TableReference if err != nil { return err } + call.NameQuote = ref.NameQuote ref.TableFunc = call return nil } diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index f04babff..30864faf 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -113,14 +113,15 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } } else { // Parse regular table name (supports schema.table qualification) - qualifiedName, err := p.parseQualifiedName() + qualifiedName, nameQuote, err := p.parseQualifiedNameQuoted() if err != nil { return tableRef, err } tableRef = ast.TableReference{ - Name: qualifiedName, - Lateral: isLateral, + Name: qualifiedName, + NameQuote: nameQuote, + Lateral: isLateral, } // Function-call table reference (Snowflake FLATTEN, TABLE(...), @@ -197,6 +198,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } if p.isIdentifier() { tableRef.Alias = p.currentToken.Token.Value + tableRef.AliasQuote = p.currentNameQuote() p.advance() if err := p.parseTableFuncColumnAliases(&tableRef); err != nil { return tableRef, err @@ -262,6 +264,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } if p.isIdentifier() { tableRef.Alias = p.currentToken.Token.Value + tableRef.AliasQuote = p.currentNameQuote() p.advance() } } @@ -335,7 +338,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error return ref, err } } else { - joinedName, err := p.parseQualifiedName() + joinedName, joinedQuote, err := p.parseQualifiedNameQuoted() if isQualifiedNameTooLong(err) { return ref, err } @@ -347,7 +350,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error "", ) } - ref = ast.TableReference{Name: joinedName, Lateral: isLateral} + ref = ast.TableReference{Name: joinedName, NameQuote: joinedQuote, Lateral: isLateral} if err := p.parseApplyFunctionCall(joinType, &ref); err != nil { return ref, err } @@ -370,6 +373,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error } if p.isIdentifier() { ref.Alias = p.currentToken.Token.Value + ref.AliasQuote = p.currentNameQuote() p.advance() if err := p.parseTableFuncColumnAliases(&ref); err != nil { return ref, err @@ -435,6 +439,7 @@ func (p *Parser) parseTableFunctionCall(ref *ast.TableReference) error { if err != nil { return err } + call.NameQuote = ref.NameQuote ref.TableFunc = call // PostgreSQL: unnest(arr) WITH ORDINALITY. The other table-function // dialects have no such modifier (BigQuery spells it WITH OFFSET after @@ -464,6 +469,7 @@ func (p *Parser) parseTableFuncColumnAliases(ref *ast.TableReference) error { if !p.isIdentifier() && !p.isNonReservedKeyword() { return p.expectedError("column alias in table function alias list") } + list.Quotes = nameQuoteList(list.Quotes, len(list.Names), p.currentNameQuote()) list.Names = append(list.Names, p.currentToken.Token.Value) p.advance() if p.isType(models.TokenTypeComma) { diff --git a/pkg/sql/tokenizer/keyword_lookup.go b/pkg/sql/tokenizer/keyword_lookup.go new file mode 100644 index 00000000..356581b4 --- /dev/null +++ b/pkg/sql/tokenizer/keyword_lookup.go @@ -0,0 +1,39 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import "strings" + +// IsKeyword reports whether word, written bare, is read by the tokenizer as a +// keyword token (SELECT, TABLE, ORDER, KEY, ...) rather than as a plain +// identifier. Case is ignored. The parser uses it to decide whether a name +// that was written as a delimited identifier ("table", `order`) must keep its +// delimiters when it is rendered back. Pure-ASCII words are looked up without +// allocating. +func IsKeyword(word string) bool { + if word == "" { + return false + } + var kbuf [keywordKeyBufLen]byte + if len(word) <= keywordKeyBufLen && asciiUpper(kbuf[:len(word)], word) { + _, ok := keywordTokenTypes[string(kbuf[:len(word)])] + return ok + } + if isASCII(word) { + return false // longer than any keyword + } + _, ok := keywordTokenTypes[strings.ToUpper(word)] + return ok +} diff --git a/pkg/sql/tokenizer/keyword_lookup_test.go b/pkg/sql/tokenizer/keyword_lookup_test.go new file mode 100644 index 00000000..6351cc9e --- /dev/null +++ b/pkg/sql/tokenizer/keyword_lookup_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import ( + "strings" + "testing" +) + +func TestIsKeyword(t *testing.T) { + tests := []struct { + word string + want bool + }{ + {"select", true}, + {"SELECT", true}, + {"Table", true}, + {"order", true}, + {"users", false}, + {"", false}, + {"émoji", false}, + {strings.Repeat("a", keywordKeyBufLen+1), false}, + } + for _, tc := range tests { + if got := IsKeyword(tc.word); got != tc.want { + t.Errorf("IsKeyword(%q) = %v, want %v", tc.word, got, tc.want) + } + } + if n := testing.AllocsPerRun(100, func() { IsKeyword("order") }); n != 0 { + t.Errorf("IsKeyword allocates %v times per call, want 0", n) + } +} diff --git a/pkg/transpiler/quoting.go b/pkg/transpiler/quoting.go new file mode 100644 index 00000000..c2ad19b2 --- /dev/null +++ b/pkg/transpiler/quoting.go @@ -0,0 +1,46 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transpiler + +import ( + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// identifierDelimiter returns the delimiter the target dialect uses for a +// quoted identifier: backticks for MySQL and MariaDB (which read "..." as a +// string unless ANSI_QUOTES is set), brackets for SQL Server (valid whatever +// QUOTED_IDENTIFIER is set to), and ANSI double quotes for PostgreSQL, +// Oracle, SQLite, Snowflake, ClickHouse and generic SQL. +func identifierDelimiter(d keywords.SQLDialect) rune { + switch d { + case keywords.DialectMySQL, keywords.DialectMariaDB: + return '`' + case keywords.DialectSQLServer: + return '[' + } + return '"' +} + +// requoteIdentifiers rewrites the delimiters of the quoted names in stmt to +// the target dialect's (see identifierDelimiter), so a MySQL `my col` is not +// carried into PostgreSQL output where backticks are invalid. Oracle bare +// names with '#' (emp#) stay bare only when the target is Oracle too. +func requoteIdentifiers(stmt ast.Statement, from, to keywords.SQLDialect) { + if from == to { + return + } + ast.SetNameQuoteStyle(stmt, identifierDelimiter(to), to == keywords.DialectOracle) +} diff --git a/pkg/transpiler/quoting_test.go b/pkg/transpiler/quoting_test.go new file mode 100644 index 00000000..6be66ab3 --- /dev/null +++ b/pkg/transpiler/quoting_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transpiler_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" + "github.com/ajitpratap0/GoSQLX/pkg/transpiler" +) + +// TestTranspile_IdentifierDelimiters checks that quoted names are written +// with the target dialect's delimiters, not the source's: MySQL backticks +// and SQL Server brackets are invalid in PostgreSQL, and ANSI double quotes +// are strings in MySQL. +func TestTranspile_IdentifierDelimiters(t *testing.T) { + tests := []struct { + name string + from, to keywords.SQLDialect + in string + want string + }{ + {"mysql to pg column", keywords.DialectMySQL, keywords.DialectPostgreSQL, + "SELECT `my col` FROM t", `SELECT "my col" FROM t`}, + {"sqlserver to pg", keywords.DialectSQLServer, keywords.DialectPostgreSQL, + "SELECT [Group], [a b] FROM t", `SELECT "Group", "a b" FROM t`}, + {"mysql to sqlserver reserved", keywords.DialectMySQL, keywords.DialectSQLServer, + "SELECT `order` FROM t", "SELECT [order] FROM t"}, + {"mysql to pg aliases and tables", keywords.DialectMySQL, keywords.DialectPostgreSQL, + "SELECT `a b` AS `my alias` FROM `my table` AS `x y` JOIN `u v` ON `x y`.id = `u v`.id", + `SELECT "a b" AS "my alias" FROM "my table" "x y" INNER JOIN "u v" ON "x y".id = "u v".id`}, + {"pg to mysql case and reserved", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `SELECT "Total", "a b" AS "select" FROM "MyTable" AS "T" WHERE "T"."order" = 1`, + "SELECT `Total`, `a b` AS `select` FROM `MyTable` `T` WHERE `T`.`order` = 1"}, + {"pg to mysql cte", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `WITH "Cte"("A b") AS (SELECT 1) SELECT "A b" FROM "Cte"`, + "WITH `Cte` (`A b`) AS (SELECT 1) SELECT `A b` FROM `Cte`"}, + {"pg to mysql insert", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `INSERT INTO "MyTable" ("ColA", "order") VALUES (1, 2)`, + "INSERT INTO `MyTable` (`ColA`, `order`) VALUES (1, 2)"}, + {"pg to mysql update", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `UPDATE "MyTable" AS "T" SET "ColA" = 1 WHERE "T"."ColB" = 2`, + "UPDATE `MyTable` `T` SET `ColA` = 1 WHERE `T`.`ColB` = 2"}, + {"pg to mysql delete", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `DELETE FROM "MyTable" WHERE "order" = 1`, "DELETE FROM `MyTable` WHERE `order` = 1"}, + {"pg to mysql functions", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `SELECT s."select"(1), "MyFunc"(2)`, "SELECT s.`select`(1), `MyFunc`(2)"}, + {"pg to mysql create table", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `CREATE TABLE "MyTable" ("ColA" INT)`, "CREATE TABLE `MyTable` (`ColA` INT)"}, + {"sqlserver to mysql qualified", keywords.DialectSQLServer, keywords.DialectMySQL, + "SELECT [a b] FROM [dbo].[My Table]", "SELECT `a b` FROM dbo.`My Table`"}, + {"oracle hash to pg", keywords.DialectOracle, keywords.DialectPostgreSQL, + "SELECT s.serial#, emp# FROM v$session s", `SELECT s."serial#", "emp#" FROM v$session s`}, + {"oracle hash kept in oracle", keywords.DialectOracle, keywords.DialectOracle, + "SELECT s.serial#, emp# FROM v$session s", "SELECT s.serial#, emp# FROM v$session s"}, + {"same dialect keeps source delimiters", keywords.DialectMySQL, keywords.DialectMySQL, + "SELECT `a b` FROM t", "SELECT `a b` FROM t"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := transpiler.Transpile(tc.in, tc.from, tc.to) + if err != nil { + t.Fatalf("Transpile: %v", err) + } + if got != tc.want { + t.Fatalf("Transpile mismatch\n got: %s\nwant: %s", got, tc.want) + } + tree, err := parser.ParseWithDialect(got, tc.to) + if err != nil { + t.Fatalf("output does not parse as %s: %v\n%s", tc.to, err, got) + } + ast.ReleaseAST(tree) + }) + } +} diff --git a/pkg/transpiler/transpiler.go b/pkg/transpiler/transpiler.go index 42c3ab0c..855e10f3 100644 --- a/pkg/transpiler/transpiler.go +++ b/pkg/transpiler/transpiler.go @@ -73,6 +73,7 @@ func Transpile(sql string, from, to keywords.SQLDialect) (string, error) { return "", fmt.Errorf("rewrite: %w", err) } } + requoteIdentifiers(stmt, from, to) } // Format each statement and join with ";\n". From 4c1fffd1388fe83e2e9de7a9456f63c79e5f3728 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:37:47 +0530 Subject: [PATCH 35/95] fix(gosqlx,lsp,cbinding): keep comments in dialect formatting; splitter BOM The LSP and C-binding formatted non-default dialects through FormatAST on a tree parsed without comments, so format-on-save deleted every comment and hint. gosqlx.FormatWithDialect parses with the dialect, renders dialect-specific constructs for it and preserves comments exactly like Format; both callers use it. SplitStatements ignores a leading UTF-8 byte order mark when looking for GO and other directive lines, and the splitter tests and fuzz target accept that directive lines are consumed (segments are then in-order substrings of the source instead of an exact tiling). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 5 ++-- pkg/cbinding/cbinding_ext.go | 29 +++------------------ pkg/gosqlx/format_comments_test.go | 28 ++++++++++++++++++++ pkg/gosqlx/gosqlx.go | 25 ++++++++++++++++++ pkg/gosqlx/splitter.go | 12 +++++++-- pkg/gosqlx/splitter_fuzz_test.go | 7 ++++- pkg/gosqlx/splitter_proc.go | 4 +++ pkg/gosqlx/splitter_proc_test.go | 1 + pkg/gosqlx/splitter_test.go | 21 ++++++++++++++- pkg/lsp/dialect.go | 16 +++--------- pkg/lsp/format_comments_test.go | 42 ++++++++++++++++++++++++++++++ 11 files changed, 146 insertions(+), 44 deletions(-) create mode 100644 pkg/lsp/format_comments_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f8e2fc..67d71045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 keyword stay in place. Input whose hints or MySQL `/*! ... */` comments cannot be placed faithfully is refused (`formatter.ErrUnplaceableComment`) instead of being rewritten without them, and `format -i` verifies that the - output holds exactly the input's comments. New `formatter.JoinWithComments` - and `gosqlx.WithComments()`. + output holds exactly the input's comments. LSP and C-binding dialect + formatting no longer drop comments either. New `formatter.JoinWithComments`, + `gosqlx.FormatWithDialect` and `gosqlx.WithComments()`. - **CLI**: `lint` exits 1 on syntax errors and `--auto-fix` judges the fixed text; `format --no-uppercase` lower-cases keywords (it used to upper-case them); `postgres`, `pg`, `mssql`, `tsql` and `sqlite3` are accepted by every diff --git a/pkg/cbinding/cbinding_ext.go b/pkg/cbinding/cbinding_ext.go index f1a59f73..bcf4f337 100644 --- a/pkg/cbinding/cbinding_ext.go +++ b/pkg/cbinding/cbinding_ext.go @@ -30,7 +30,6 @@ import ( "strings" goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" - "github.com/ajitpratap0/GoSQLX/pkg/formatter" "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" "github.com/ajitpratap0/GoSQLX/pkg/linter" lintrules "github.com/ajitpratap0/GoSQLX/pkg/linter/rules" @@ -48,10 +47,6 @@ var errCodeUnsupportedDialect = string(goerrors.ErrCodeUnsupportedDialect) // options_json argument that is not a valid options object. const errCodeInvalidOptions = "INVALID_OPTIONS" -// formatLineWidth is the line width gosqlx.DefaultFormatOptions sets. It is -// spelled out because the FormatOptions field that carries it is deprecated. -const formatLineWidth = 80 - // defaultLintMaxLineLength mirrors the default of `gosqlx lint --max-length`. const defaultLintMaxLineLength = 100 @@ -157,9 +152,9 @@ func validationResultFor(sql, dialect string) ValidationResult { } // formatFor formats sql with gosqlx.DefaultFormatOptions. An empty dialect goes -// through gosqlx.Format; otherwise the SQL is parsed with the dialect and -// rendered with the same option mapping gosqlx.Format uses, plus the dialect so -// dialect-specific constructs are rendered for that dialect. +// through gosqlx.Format; otherwise gosqlx.FormatWithDialect parses the SQL with +// the dialect and renders dialect-specific constructs for it. Either way +// comments and optimizer hints are preserved. func formatFor(sql, dialect string) (string, error) { if err := dialectError(dialect); err != nil { return "", err @@ -168,23 +163,7 @@ func formatFor(sql, dialect string) (string, error) { if dialect == "" { return gosqlx.Format(sql, opts) } - tree, err := gosqlx.ParseWithDialect(sql, keywords.SQLDialect(dialect)) - if err != nil { - return "", fmt.Errorf("cannot format invalid SQL: %w", err) - } - kwCase := ast.KeywordPreserve - if opts.UppercaseKeywords { - kwCase = ast.KeywordUpper - } - return formatter.FormatAST(tree, ast.FormatOptions{ - IndentStyle: ast.IndentSpaces, - IndentWidth: opts.IndentSize, - KeywordCase: kwCase, - LineWidth: formatLineWidth, - NewlinePerClause: opts.IndentSize > 0, - AddSemicolon: opts.AddSemicolon, - Dialect: dialect, - }), nil + return gosqlx.FormatWithDialect(sql, opts, keywords.SQLDialect(dialect)) } // formatResultFor builds the gosqlx_format / gosqlx_format_dialect result. diff --git a/pkg/gosqlx/format_comments_test.go b/pkg/gosqlx/format_comments_test.go index 8d930bc3..d33d95dc 100644 --- a/pkg/gosqlx/format_comments_test.go +++ b/pkg/gosqlx/format_comments_test.go @@ -100,3 +100,31 @@ func TestParseTree_WithCommentsKeepsThemForFormatTree(t *testing.T) { } } } + +func TestFormatWithDialect(t *testing.T) { + opts := DefaultFormatOptions() + opts.IndentSize = 0 + opts.UppercaseKeywords = true + + got, err := FormatWithDialect("select /*+ MAX_EXECUTION_TIME(1000) */ a from t -- why", opts, "mysql") + if err != nil { + t.Fatalf("FormatWithDialect: %v", err) + } + if want := "SELECT /*+ MAX_EXECUTION_TIME(1000) */ a FROM t -- why"; got != want { + t.Fatalf("got %q, want %q", got, want) + } + + // An empty dialect is Format. + a, errA := FormatWithDialect("select 1 -- x", opts, "") + b, errB := Format("select 1 -- x", opts) + if errA != nil || errB != nil || a != b { + t.Fatalf("empty dialect: %q (%v) vs Format %q (%v)", a, errA, b, errB) + } + + if _, err := FormatWithDialect("select 1", opts, "nosuchsql"); !errors.Is(err, ErrUnsupportedDialect) { + t.Fatalf("unknown dialect: err = %v, want ErrUnsupportedDialect", err) + } + if _, err := FormatWithDialect("selec 1", opts, "mysql"); !errors.Is(err, ErrSyntax) { + t.Fatalf("invalid SQL: err = %v, want ErrSyntax", err) + } +} diff --git a/pkg/gosqlx/gosqlx.go b/pkg/gosqlx/gosqlx.go index 6e95318a..a8f48ec2 100644 --- a/pkg/gosqlx/gosqlx.go +++ b/pkg/gosqlx/gosqlx.go @@ -618,8 +618,29 @@ func DefaultFormatOptions() FormatOptions { // // Returns the formatted SQL string or an error if SQL is invalid. func Format(sql string, options FormatOptions) (string, error) { + return formatSQL(sql, options, "") +} + +// FormatWithDialect is Format for SQL in a specific dialect: the SQL is +// tokenized and parsed with the dialect's rules, and dialect-specific +// constructs are rendered for that dialect (for example TOP versus LIMIT). +// Comments and optimizer hints are preserved exactly as in Format. +// +// An empty dialect behaves like Format. An unknown dialect returns an error +// wrapping ErrUnsupportedDialect. +func FormatWithDialect(sql string, options FormatOptions, dialect keywords.SQLDialect) (string, error) { + if dialect != "" && !keywords.IsValidDialect(string(dialect)) { + return "", fmt.Errorf("%w: %q", ErrUnsupportedDialect, dialect) + } + return formatSQL(sql, options, dialect) +} + +func formatSQL(sql string, options FormatOptions, dialect keywords.SQLDialect) (string, error) { tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) + if dialect != "" { + tkz.SetDialect(dialect) + } tokens, err := tkz.TokenizeString(sql) if err != nil { @@ -629,6 +650,9 @@ func Format(sql string, options FormatOptions) (string, error) { p := parser.GetParser() defer parser.PutParser(p) + if dialect != "" { + p.ApplyOptions(parser.WithDialect(string(dialect))) + } parsedAST, err := p.ParseFromModelTokens(tokens) if err != nil { return "", fmt.Errorf("cannot format invalid SQL: %w: %w", ErrSyntax, err) @@ -647,6 +671,7 @@ func Format(sql string, options FormatOptions) (string, error) { LineWidth: options.SingleLineLimit, NewlinePerClause: options.IndentSize > 0, AddSemicolon: options.AddSemicolon, + Dialect: string(dialect), } if len(comments) == 0 { diff --git a/pkg/gosqlx/splitter.go b/pkg/gosqlx/splitter.go index 89243ba3..11552f9a 100644 --- a/pkg/gosqlx/splitter.go +++ b/pkg/gosqlx/splitter.go @@ -29,11 +29,16 @@ import "strings" // "postgresql", "postgres", "pg" — dollar-quotes, E-strings, nested /*…*/ // "mysql", "mariadb" — backtick identifiers // "clickhouse" — backtick identifiers -// "sqlserver", "mssql", "tsql" — [bracketed identifiers] +// "sqlserver", "mssql", "tsql" — [bracketed identifiers], GO batch separator +// "oracle" — DECLARE / IS|AS sections, "/" line terminator // "" — ANSI only (single/double quotes, line/block comments) // +// Compound statements stay in one piece in every dialect (see the notes on +// procedural scripts below): a BEGIN … END body is not cut at its inner ';'. +// // The returned slice preserves the original byte ranges between semicolons -// (the terminating ';' itself is stripped). Empty or whitespace-only segments +// (the terminating ';' itself is stripped). Client directive lines (GO, +// DELIMITER, a lone "/") are consumed and appear in no segment. Empty or whitespace-only segments // are NOT filtered out here; callers such as ParseReaderMultiple trim and // skip them. This mirrors the long-standing behaviour of the splitter that // this function replaces. @@ -167,6 +172,9 @@ func splitCore(src string, p dialectProfile) (out []string, open bool) { delim := "" i := 0 + if strings.HasPrefix(src, "\xef\xbb\xbf") { + i = 3 // a UTF-8 byte order mark is not part of the first word + } for i < len(src) { c := src[i] diff --git a/pkg/gosqlx/splitter_fuzz_test.go b/pkg/gosqlx/splitter_fuzz_test.go index d6487219..1f4d4303 100644 --- a/pkg/gosqlx/splitter_fuzz_test.go +++ b/pkg/gosqlx/splitter_fuzz_test.go @@ -47,6 +47,11 @@ func FuzzSplitStatements(f *testing.F) { {"E'", "postgresql"}, {"'", ""}, {"--", ""}, + {"SELECT 1\nGO\nSELECT 2", "sqlserver"}, + {"DELIMITER //\nSELECT 1;SELECT 2//\nDELIMITER ;\n", "mysql"}, + {"BEGIN SELECT 1; END; BEGIN; COMMIT", "sqlite"}, + {"DECLARE x NUMBER; BEGIN NULL; END;\n/\n", "oracle"}, + {"CREATE PROCEDURE p() BEGIN IF a THEN SELECT 1; END IF; END", "mysql"}, } for _, s := range seeds { f.Add(s.sql, s.dialect) @@ -54,7 +59,7 @@ func FuzzSplitStatements(f *testing.F) { f.Fuzz(func(t *testing.T, src, dialect string) { segs := SplitStatements(src, dialect) - if msg, ok := checkTiling(src, segs); !ok { + if msg, ok := checkTilingFor(src, segs, dialect); !ok { t.Fatalf("SplitStatements(%q, %q) = %q: %s", src, dialect, segs, msg) } }) diff --git a/pkg/gosqlx/splitter_proc.go b/pkg/gosqlx/splitter_proc.go index 7111335c..76d9981c 100644 --- a/pkg/gosqlx/splitter_proc.go +++ b/pkg/gosqlx/splitter_proc.go @@ -440,6 +440,10 @@ func lineStartOK(src string, i int) (ls int, ok bool) { return ls, true } if c != ' ' && c != '\t' && c != '\r' { + // A byte order mark in front of the first line is not content. + if ls == 3 && src[:3] == "\xef\xbb\xbf" { + return 0, true + } return 0, false } ls-- diff --git a/pkg/gosqlx/splitter_proc_test.go b/pkg/gosqlx/splitter_proc_test.go index aa0186e9..b0776b70 100644 --- a/pkg/gosqlx/splitter_proc_test.go +++ b/pkg/gosqlx/splitter_proc_test.go @@ -74,6 +74,7 @@ END` {"go inside string", "SELECT '\nGO\n'; SELECT 2", "sqlserver", []string{"SELECT '\nGO\n'", " SELECT 2"}}, {"go inside comment", "/*\nGO\n*/ SELECT 1", "sqlserver", []string{"/*\nGO\n*/ SELECT 1"}}, {"go identifier prefix", "SELECT 1\nGOTO x", "sqlserver", []string{"SELECT 1\nGOTO x"}}, + {"go after byte order mark", "\uFEFFGO\nSELECT 2", "sqlserver", []string{"SELECT 2"}}, {"go ignored for other dialects", "SELECT 1\nGO\nSELECT 2", "postgresql", []string{"SELECT 1\nGO\nSELECT 2"}}, // --- Triggers and transactions elsewhere --- diff --git a/pkg/gosqlx/splitter_test.go b/pkg/gosqlx/splitter_test.go index 47ab7694..ecc7aa45 100644 --- a/pkg/gosqlx/splitter_test.go +++ b/pkg/gosqlx/splitter_test.go @@ -211,6 +211,25 @@ var splitterDialects = []string{ // represented), and the segment count never exceeds the number of ';' bytes // plus one. func checkTiling(src string, segs []string) (string, bool) { + return checkTilingFor(src, segs, "") +} + +// checkTilingFor is checkTiling for a dialect. The dialects whose scripts can +// hold client directives (GO, DELIMITER, a lone "/") consume those lines, so +// for them the segments must only be ordered substrings of src. +func checkTilingFor(src string, segs []string, dialect string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(dialect)) { + case "sqlserver", "mssql", "tsql", "mysql", "mariadb", "oracle": + pos := 0 + for _, seg := range segs { + at := strings.Index(src[pos:], seg) + if at < 0 { + return "segment is not an in-order substring of the source", false + } + pos += at + len(seg) + } + return "", true + } joined := strings.Join(segs, ";") if joined != src && !(strings.HasSuffix(src, ";") && joined == src[:len(src)-1]) { return "segments do not tile the source", false @@ -295,7 +314,7 @@ func TestSplitStatements_TilesSource(t *testing.T) { src := string(data) for _, dialect := range splitterDialects { segs := SplitStatements(src, dialect) - if msg, ok := checkTiling(src, segs); !ok { + if msg, ok := checkTilingFor(src, segs, dialect); !ok { t.Errorf("%s (dialect %q): %s", path, dialect, msg) } } diff --git a/pkg/lsp/dialect.go b/pkg/lsp/dialect.go index c3ff78f7..ba17d088 100644 --- a/pkg/lsp/dialect.go +++ b/pkg/lsp/dialect.go @@ -93,23 +93,13 @@ func parseForDialect(sql string, d keywords.SQLDialect) (*ast.AST, error) { } // formatForDialect formats sql like gosqlx.Format, but parses it with the -// dialect-aware API for d. The FormatAST options reproduce the -// ast.FormatOptions that gosqlx.Format derives from opts (LineWidth, the only -// field that differs, is not used by the formatter). opts.AddSemicolon is not -// honoured on this path; the LSP formatter never sets it. +// dialect-aware API for d and renders dialect-specific constructs for d. +// Comments and optimizer hints are preserved. opts.AddSemicolon is honoured. func formatForDialect(sql string, opts gosqlx.FormatOptions, d keywords.SQLDialect) (string, error) { if usesDefaultPipeline(d) { return gosqlx.Format(sql, opts) } - parsed, err := gosqlx.ParseWithDialect(sql, d) - if err != nil { - return "", fmt.Errorf("cannot format invalid SQL: %w", err) - } - formatted := gosqlx.FormatAST(parsed, - gosqlx.WithIndent(opts.IndentSize), - gosqlx.WithUppercaseKeywords(opts.UppercaseKeywords), - ) - return formatted, nil + return gosqlx.FormatWithDialect(sql, opts, d) } // dialectDisplayNames are the human-readable dialect names used in diff --git a/pkg/lsp/format_comments_test.go b/pkg/lsp/format_comments_test.go new file mode 100644 index 00000000..1caa7b75 --- /dev/null +++ b/pkg/lsp/format_comments_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lsp + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// Format-on-save must not delete comments or optimizer hints, in the default +// pipeline or in a dialect. +func TestFormatForDialect_KeepsComments(t *testing.T) { + opts := gosqlx.DefaultFormatOptions() + opts.UppercaseKeywords = true + const sql = "-- report\nselect /*+ FULL(t) */ a from t -- trailing\n" + for _, d := range []keywords.SQLDialect{"", keywords.DialectPostgreSQL, keywords.DialectMySQL, keywords.DialectOracle} { + got, err := formatForDialect(sql, opts, d) + if err != nil { + t.Fatalf("dialect %q: %v", d, err) + } + for _, want := range []string{"-- report", "/*+ FULL(t) */", "-- trailing"} { + if !strings.Contains(got, want) { + t.Errorf("dialect %q: output %q lost %q", d, got, want) + } + } + } +} From f1b319ce540842a6425bf7b7a8b4e282294dc13d Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:38:35 +0530 Subject: [PATCH 36/95] fix(parser,ast): qualified positional columns, db..t, OPENJSON schema - Snowflake t.$1 positional column references after a qualifier - SQL Server db..t (empty default-schema part), kept empty on output - SQL Server OPENJSON(...) WITH (col type ['path'] [AS JSON], ...) column schema instead of a table-hint parse error - Oracle PARTITION (p) partition-extended table names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/table_factor_render_test.go | 4 ++ pkg/sql/ast/quote_names.go | 24 ++++++- pkg/sql/ast/quote_names_test.go | 6 +- pkg/sql/ast/sql.go | 7 ++ pkg/sql/ast/table_factor.go | 63 +++++++++++++++++- pkg/sql/parser/expressions_literal.go | 3 +- pkg/sql/parser/parser.go | 5 ++ pkg/sql/parser/table_factor.go | 81 ++++++++++++++++++++++- pkg/sql/parser/table_factor_test.go | 24 +++++++ testdata/realworld/known_failures.txt | 4 -- 10 files changed, 208 insertions(+), 13 deletions(-) diff --git a/pkg/formatter/table_factor_render_test.go b/pkg/formatter/table_factor_render_test.go index d8b3928c..a2272a03 100644 --- a/pkg/formatter/table_factor_render_test.go +++ b/pkg/formatter/table_factor_render_test.go @@ -68,6 +68,7 @@ func TestRender_QueryExpressionsAndTableFactors(t *testing.T) { {"oracle versions", keywords.DialectOracle, "SELECT * FROM t VERSIONS BETWEEN SCN MINVALUE AND MAXVALUE", "SELECT * FROM t VERSIONS BETWEEN SCN MINVALUE AND MAXVALUE"}, {"oracle db link", keywords.DialectOracle, "SELECT * FROM t@lnk.example.com x", "SELECT * FROM t@lnk.example.com x"}, {"mysql partition", keywords.DialectMySQL, "SELECT * FROM t PARTITION (p0, p1) AS x", "SELECT * FROM t PARTITION (p0, p1) x"}, + {"oracle partition", keywords.DialectOracle, "SELECT * FROM sales PARTITION (sales_q1) s", "SELECT * FROM sales PARTITION (sales_q1) s"}, {"mysql index hints", keywords.DialectMySQL, "SELECT * FROM t x FORCE INDEX FOR JOIN (ix, PRIMARY) USE KEY ()", "SELECT * FROM t x FORCE INDEX FOR JOIN (ix, PRIMARY) USE KEY ()"}, {"sqlite indexed by", keywords.DialectSQLite, "SELECT * FROM t AS x INDEXED BY i WHERE a = 1", "SELECT * FROM t x INDEXED BY i WHERE a = 1"}, {"sqlite not indexed", keywords.DialectSQLite, "SELECT * FROM t NOT INDEXED", "SELECT * FROM t NOT INDEXED"}, @@ -90,6 +91,9 @@ func TestRender_QueryExpressionsAndTableFactors(t *testing.T) { // Snowflake FROM forms {"snowflake bare values", keywords.DialectSnowflake, "SELECT * FROM VALUES (1, 'a'), (2, 'b') AS v (id, name)", "SELECT * FROM (VALUES (1, 'a'), (2, 'b')) v(id, name)"}, {"snowflake stage file", keywords.DialectSnowflake, "SELECT $1, $2 FROM @my_stage/data.csv (FILE_FORMAT => 'my_csv')", "SELECT $1, $2 FROM @my_stage/data.csv (FILE_FORMAT => 'my_csv')"}, + {"snowflake qualified positional column", keywords.DialectSnowflake, "SELECT t.$1 AS id FROM @my_stage/users/ (FILE_FORMAT => 'my_csv') t", "SELECT t.$1 AS id FROM @my_stage/users/ (FILE_FORMAT => 'my_csv') t"}, + {"sqlserver default schema", keywords.DialectSQLServer, "SELECT x.a FROM srv.db..t AS x", "SELECT x.a FROM srv.db..t x"}, + {"sqlserver openjson schema", keywords.DialectSQLServer, "SELECT * FROM OPENJSON(@json) WITH (Id INT '$.id', Raw NVARCHAR(MAX) '$.raw' AS JSON) AS j", "SELECT * FROM OPENJSON(@json) WITH (Id INT '$.id', Raw NVARCHAR(MAX) '$.raw' AS JSON) j"}, // PIVOT / UNPIVOT {"oracle multi aggregate pivot", keywords.DialectOracle, "SELECT * FROM (SELECT a, b, c FROM t) PIVOT (SUM(c) AS total, COUNT(*) AS cnt FOR a IN (1 AS x, 2 AS y))", "SELECT * FROM (SELECT a, b, c FROM t) PIVOT (SUM(c) AS total, COUNT(*) AS cnt FOR a IN (1 AS x, 2 AS y))"}, {"pivot source alias", keywords.DialectSQLServer, "SELECT * FROM (SELECT a, q FROM t) s PIVOT (SUM(a) FOR q IN ([Q1], [Q2])) AS p", "SELECT * FROM (SELECT a, q FROM t) s PIVOT (SUM(a) FOR q IN ([Q1], [Q2])) AS p"}, diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index 26d80c6a..45d76a97 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -279,15 +279,20 @@ func writeQualifiedName(buf *bytes.Buffer, name string) { buf.WriteString(name) return } - for { + for prevEmpty := true; ; { dot := strings.IndexByte(name, '.') part := name if dot >= 0 { part = name[:dot] } - if part == "" || nameNeedsQuoting(part, true) { + if part == "" && !prevEmpty && isEmptyMiddlePart(name, dot) { + // SQL Server db..t: an empty schema part is written as nothing. + prevEmpty = true + } else if part == "" || nameNeedsQuoting(part, true) { + prevEmpty = part == "" writeQuotedIdentifier(buf, part) } else { + prevEmpty = false buf.WriteString(part) } if dot < 0 { @@ -298,18 +303,31 @@ func writeQualifiedName(buf *bytes.Buffer, name string) { } } +// isEmptyMiddlePart reports whether the empty part ending at dot (the index +// of the "." after it in the remaining name, -1 for the last part) lies +// between two non-empty parts, as the schema of SQL Server db..t does. +func isEmptyMiddlePart(rest string, dot int) bool { + return dot == 0 && len(rest) > 1 && rest[1] != '.' +} + // qualifiedNameNeedsQuoting reports whether writeQualifiedName would quote // some part of the non-empty name. func qualifiedNameNeedsQuoting(name string) bool { if isStageReference(name) { return false } - for { + for first := true; ; first = false { dot := strings.IndexByte(name, '.') part := name if dot >= 0 { part = name[:dot] } + if part == "" && !first && isEmptyMiddlePart(name, dot) { + // db..t: the empty part sits between two non-empty parts (an + // empty part before it was reported already). + name = name[dot+1:] + continue + } if part == "" || nameNeedsQuoting(part, true) { return true } diff --git a/pkg/sql/ast/quote_names_test.go b/pkg/sql/ast/quote_names_test.go index 541f6bb3..6f7566ad 100644 --- a/pkg/sql/ast/quote_names_test.go +++ b/pkg/sql/ast/quote_names_test.go @@ -60,7 +60,11 @@ func TestQuoteQualifiedName(t *testing.T) { {"@mystage/data", "@mystage/data"}, {"@db.sch.mystage/data", "@db.sch.mystage/data"}, {"DUAL", "DUAL"}, - {"db..t", `db."".t`}, + // SQL Server db..t: the empty default-schema part is kept empty. + {"db..t", "db..t"}, + {".t", `"".t`}, + {"t.", `t.""`}, + {"a...b", `a.""."".b`}, {"उपयोगकर्ता", "उपयोगकर्ता"}, {"db.उपयोगकर्ता", "db.उपयोगकर्ता"}, {"Cafe\u0301", "Cafe\u0301"}, diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 0d47ada5..cdb2ef11 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -103,6 +103,9 @@ func (i *Identifier) SQL() string { if i.Table == "" && !i.Quoted && i.QuoteStyle == 0 && isVariableName(i.Name) { return i.Name } + if i.Table != "" && isPositionalColumnName(i.Name) && !qualifierNeedsQuoting(i.Table) { + return i.Table + "." + i.Name // Snowflake t.$1 + } if !i.quotedReserved() && !identifierNeedsQuoting(i.Name) && (i.Table == "" || !qualifierNeedsQuoting(i.Table)) { if i.Table == "" { return i.Name @@ -126,6 +129,10 @@ func (i *Identifier) writeSQL(buf *bytes.Buffer) { if i.Table != "" { writeDottedIdentifierStyle(buf, i.Table, i.QuoteStyle) buf.WriteByte('.') + if isPositionalColumnName(i.Name) { + buf.WriteString(i.Name) // Snowflake t.$1 + return + } } if i.quotedReserved() || identifierNeedsQuoting(i.Name) { writeDelimitedIdentifier(buf, i.Name, i.QuoteStyle) diff --git a/pkg/sql/ast/table_factor.go b/pkg/sql/ast/table_factor.go index 8720f48a..ba861477 100644 --- a/pkg/sql/ast/table_factor.go +++ b/pkg/sql/ast/table_factor.go @@ -120,7 +120,8 @@ type IndexHint struct { type TableModifiers struct { // DBLink is the Oracle database link of t@dblink. DBLink string - // Partitions is the MySQL / MariaDB partition selection PARTITION (p0, p1). + // Partitions is the MySQL / MariaDB / Oracle partition selection + // PARTITION (p0, p1). Partitions []string // IndexHints are the MySQL / MariaDB index hints after the alias. IndexHints []IndexHint @@ -128,12 +129,24 @@ type TableModifiers struct { IndexedBy string // NotIndexed is the SQLite NOT INDEXED modifier. NotIndexed bool + // WithSchema is the column schema of SQL Server OPENJSON(...) WITH (...). + WithSchema []OpenJSONColumn +} + +// OpenJSONColumn is one column of a SQL Server OPENJSON WITH schema: +// +// name type ['$.path'] [AS JSON] +type OpenJSONColumn struct { + Name string + Type string + Path string // the JSON path without quotes; "" when omitted + AsJSON bool } // isEmpty reports whether m carries no modifier. func (m *TableModifiers) isEmpty() bool { return m == nil || (m.DBLink == "" && len(m.Partitions) == 0 && len(m.IndexHints) == 0 && - m.IndexedBy == "" && !m.NotIndexed) + m.IndexedBy == "" && !m.NotIndexed && len(m.WithSchema) == 0) } // textWriter is the writer interface shared by *bytes.Buffer and @@ -172,6 +185,9 @@ func writeTableRefNameSuffix(w textWriter, t *TableReference) { // MySQL PARTITION (...), FOR SYSTEM_TIME, Snowflake AT / BEFORE / CHANGES, // Oracle SAMPLE and flashback clauses. Each is preceded by a space. func writeTableRefPreAlias(w textWriter, t *TableReference, kw func(string) string) { + if m := t.Modifiers; m != nil && len(m.WithSchema) > 0 { + writeOpenJSONSchema(w, m.WithSchema, kw) + } if m := t.Modifiers; m != nil && len(m.Partitions) > 0 { w.WriteByte(' ') w.WriteString(kw("PARTITION")) @@ -235,7 +251,7 @@ func writeTableRefPostAlias(w textWriter, t *TableReference, kw func(string) str func TableRefPreAliasSQL(t *TableReference, kw func(string) string) string { if t == nil || (t.ForSystemTime == nil && t.TimeTravel == nil && t.Flashback == nil && (t.TableSample == nil || t.TableSample.AfterAlias) && - (t.Modifiers == nil || len(t.Modifiers.Partitions) == 0)) { + (t.Modifiers == nil || (len(t.Modifiers.Partitions) == 0 && len(t.Modifiers.WithSchema) == 0))) { return "" } if kw == nil { @@ -726,3 +742,44 @@ func TableHintSQL(h string, kw func(string) string) string { } return kw(h) } + +// isPositionalColumnName reports whether name is a Snowflake positional +// column reference: $ followed by digits ($1, $12). +func isPositionalColumnName(name string) bool { + if len(name) < 2 || name[0] != '$' { + return false + } + for i := 1; i < len(name); i++ { + if name[i] < '0' || name[i] > '9' { + return false + } + } + return true +} + +// writeOpenJSONSchema writes " WITH (name type ['path'] [AS JSON], ...)". +func writeOpenJSONSchema(w textWriter, cols []OpenJSONColumn, kw func(string) string) { + w.WriteByte(' ') + w.WriteString(kw("WITH")) + w.WriteString(" (") + for i, c := range cols { + if i > 0 { + w.WriteString(", ") + } + w.WriteString(QuoteColumnName(c.Name)) + w.WriteByte(' ') + w.WriteString(QuoteDataType(c.Type)) + if c.Path != "" { + buf := getBuf() + writeStringLiteral(buf, c.Path) + w.WriteByte(' ') + w.WriteString(buf.String()) + putBuf(buf) + } + if c.AsJSON { + w.WriteByte(' ') + w.WriteString(kw("AS JSON")) + } + } + w.WriteByte(')') +} diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 446bca83..bf07660d 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -643,5 +643,6 @@ func (p *Parser) parseMultiPartColumnRef(ident *ast.Identifier) error { // schema.INDEX), or - in ClickHouse - a numeric tuple element (d.1). func (p *Parser) isQualifiedNamePart() bool { return p.isIdentifier() || p.isNonReservedKeyword() || p.isKeywordColumnName() || - (p.dialect == string(keywords.DialectClickHouse) && p.isNumericLiteral()) + (p.dialect == string(keywords.DialectClickHouse) && p.isNumericLiteral()) || + p.isSnowflakePositionalColumn() } diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 0ea5de83..26040b1e 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -1177,6 +1177,11 @@ func (p *Parser) parseQualifiedName() (string, error) { // Check for schema.table or db.schema.table for p.isType(models.TokenTypePeriod) { p.advance() // Consume . + if p.isEmptyNamePart() && n < maxQualifiedNameParts { + // SQL Server db..t: the default schema, written as an empty part. + n++ + continue + } if n == maxQualifiedNameParts { return "", goerrors.InvalidSyntaxError( fmt.Sprintf("qualified name has more than %d parts", maxQualifiedNameParts), diff --git a/pkg/sql/parser/table_factor.go b/pkg/sql/parser/table_factor.go index b7eac84d..74252e58 100644 --- a/pkg/sql/parser/table_factor.go +++ b/pkg/sql/parser/table_factor.go @@ -226,7 +226,14 @@ func (p *Parser) parseTableSample() (*ast.TableSampleClause, error) { // SYSTEM_TIME, Snowflake time travel, SAMPLE / TABLESAMPLE and Oracle // flashback. func (p *Parser) parseTableModifiersBeforeAlias(ref *ast.TableReference) error { - if p.isMySQLFamily() && p.isType(models.TokenTypePartition) && + if p.isOpenJSONSchemaStart(ref) { + cols, err := p.parseOpenJSONSchema() + if err != nil { + return err + } + tableModifiers(ref).WithSchema = cols + } + if (p.isMySQLFamily() || p.IsOracle()) && p.isType(models.TokenTypePartition) && p.peekToken().Token.Type == models.TokenTypeLParen { p.advance() // Consume PARTITION names, err := p.parseParenthesizedWordList("partition name") @@ -881,3 +888,75 @@ func (p *Parser) parsePivotSuffix(ref *ast.TableReference) error { } return nil } + +// isSnowflakePositionalColumn reports whether the current token is a +// Snowflake positional column reference $1, $2, ... (after a qualifier, as in +// t.$1 for a staged file). +func (p *Parser) isSnowflakePositionalColumn() bool { + if !p.IsSnowflake() || !p.isType(models.TokenTypePlaceholder) { + return false + } + v := p.currentToken.Token.Value + if len(v) < 2 || v[0] != '$' { + return false + } + for i := 1; i < len(v); i++ { + if v[i] < '0' || v[i] > '9' { + return false + } + } + return true +} + +// isEmptyNamePart reports whether the current token, right after a ".", is +// another "." that leaves an empty part in a SQL Server qualified name: +// db..t names table t in the default schema of database db. +func (p *Parser) isEmptyNamePart() bool { + return p.IsSQLServer() && p.isType(models.TokenTypePeriod) +} + +// isOpenJSONSchemaStart reports whether the current WITH ( starts the column +// schema of a SQL Server OPENJSON(...) table function rather than table +// hints. +func (p *Parser) isOpenJSONSchemaStart(ref *ast.TableReference) bool { + return p.IsSQLServer() && ref.TableFunc != nil && strings.EqualFold(ref.TableFunc.Name, "OPENJSON") && + p.isType(models.TokenTypeWith) && p.peekToken().Token.Type == models.TokenTypeLParen +} + +// parseOpenJSONSchema parses WITH (name type ['$.path'] [AS JSON], ...). +func (p *Parser) parseOpenJSONSchema() ([]ast.OpenJSONColumn, error) { + p.advance() // Consume WITH + p.advance() // Consume ( + var cols []ast.OpenJSONColumn + for { + if !p.isIdentifier() && !p.isNonReservedKeyword() { + return nil, p.expectedError("column name in OPENJSON WITH") + } + col := ast.OpenJSONColumn{Name: p.currentToken.Token.Value} + p.advance() + typ, err := p.parseDataType() + if err != nil { + return nil, err + } + col.Type = typ + if p.isStringLiteral() { + col.Path = p.currentToken.Token.Value + p.advance() + } + if p.isType(models.TokenTypeAs) && equalsUpper(p.peekToken().Token.Value, "JSON") { + p.advance() // Consume AS + p.advance() // Consume JSON + col.AsJSON = true + } + cols = append(cols, col) + if !p.isType(models.TokenTypeComma) { + break + } + p.advance() // Consume , + } + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(") after OPENJSON WITH columns") + } + p.advance() // Consume ) + return cols, nil +} diff --git a/pkg/sql/parser/table_factor_test.go b/pkg/sql/parser/table_factor_test.go index 242242ef..8fd8fde1 100644 --- a/pkg/sql/parser/table_factor_test.go +++ b/pkg/sql/parser/table_factor_test.go @@ -466,3 +466,27 @@ func TestSnowflakeFromValuesAndStagePath(t *testing.T) { t.Errorf("got name %q func %v alias %q", ref.Name, ref.TableFunc, ref.Alias) } } + +func TestQualifiedNameForms(t *testing.T) { + ref := firstFrom(t, parseOne(t, keywords.DialectSQLServer, "SELECT * FROM db..t")) + if ref.Name != "db..t" { + t.Errorf("name = %q, want db..t", ref.Name) + } + if tree, err := gosqlx.ParseWithDialect("SELECT * FROM db..t", keywords.DialectPostgreSQL); err == nil { + ast.ReleaseAST(tree) + t.Error("db..t parsed outside SQL Server") + } + sel, ok := parseOne(t, keywords.DialectSnowflake, "SELECT t.$1 FROM @s t").(*ast.SelectStatement) + if !ok || len(sel.Columns) != 1 { + t.Fatal("want one select column") + } + id, ok := sel.Columns[0].(*ast.Identifier) + if !ok || id.Table != "t" || id.Name != "$1" { + t.Errorf("column = %#v", sel.Columns[0]) + } + ref = firstFrom(t, parseOne(t, keywords.DialectSQLServer, "SELECT * FROM OPENJSON(@j) WITH (Id INT '$.id', R NVARCHAR(MAX) AS JSON) AS x")) + want := []ast.OpenJSONColumn{{Name: "Id", Type: "INT", Path: "$.id"}, {Name: "R", Type: "NVARCHAR(MAX)", AsJSON: true}} + if ref.Modifiers == nil || !reflect.DeepEqual(ref.Modifiers.WithSchema, want) || ref.Alias != "x" || len(ref.TableHints) != 0 { + t.Errorf("got %+v alias %q hints %v", ref.Modifiers, ref.Alias, ref.TableHints) + } +} diff --git a/testdata/realworld/known_failures.txt b/testdata/realworld/known_failures.txt index 70c9214e..063b2baa 100644 --- a/testdata/realworld/known_failures.txt +++ b/testdata/realworld/known_failures.txt @@ -352,7 +352,6 @@ snowflake/03_ddl_migrations.sql#6 parse snowflake/03_ddl_migrations.sql#7 parse snowflake/03_ddl_migrations.sql#8 reparse snowflake/03_ddl_migrations.sql#9 reparse -snowflake/05_dialect_specific.sql#25 parse snowflake/05_dialect_specific.sql#42 drop snowflake/05_dialect_specific.sql#49 parse snowflake/06_etl_merge.sql#13 reparse @@ -437,10 +436,7 @@ sqlserver/03_ddl_migrations.sql#6 parse sqlserver/03_ddl_migrations.sql#7 parse sqlserver/03_ddl_migrations.sql#8 parse sqlserver/03_ddl_migrations.sql#9 parse -sqlserver/05_dialect_specific.sql#11 parse -sqlserver/05_dialect_specific.sql#12 parse sqlserver/05_dialect_specific.sql#28 parse -sqlserver/05_dialect_specific.sql#33 parse sqlserver/05_dialect_specific.sql#9 parse sqlserver/06_merge_etl.sql#16 parse sqlserver/07_ctes_deep.sql#15 parse From 759210e363f73d80bf2cc0f33f0d824f26d226b6 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:43:02 +0530 Subject: [PATCH 37/95] perf(ast): keep TableReference compact by grouping rare modifiers Move TableSample and Flashback into the TableModifiers pointer so the table-factor work adds one pointer (plus the VALUES body) to the TableReference copied by value on the parser hot path, and add a field-coverage test for every TableModifiers field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/table_factor_render_test.go | 62 +++++++++++++++++++---- pkg/sql/ast/ast_clauses.go | 23 +++++---- pkg/sql/ast/table_factor.go | 48 ++++++++++++------ pkg/sql/ast/table_factor_test.go | 22 ++++---- pkg/sql/parser/table_factor.go | 17 ++++--- pkg/sql/parser/table_factor_test.go | 17 +++++-- 6 files changed, 133 insertions(+), 56 deletions(-) diff --git a/pkg/formatter/table_factor_render_test.go b/pkg/formatter/table_factor_render_test.go index a2272a03..806f7e12 100644 --- a/pkg/formatter/table_factor_render_test.go +++ b/pkg/formatter/table_factor_render_test.go @@ -240,18 +240,14 @@ func tableRefFieldSamples() map[string]func(*ast.TableReference) { r.SetSubquery = &ast.SetOperation{Left: sub(), Operator: "UNION", Right: sub()} }, "Only": func(r *ast.TableReference) { r.Only = true }, - "TableSample": func(r *ast.TableReference) { - r.TableSample = &ast.TableSampleClause{Keyword: "TABLESAMPLE", Method: "SYSTEM", Args: []ast.Expression{num("5")}, AfterAlias: true} - }, - "Flashback": func(r *ast.TableReference) { - r.Flashback = &ast.FlashbackClause{Kind: "SCN", Point: num("9")} - }, "Modifiers": func(r *ast.TableReference) { r.Modifiers = &ast.TableModifiers{ - DBLink: "lnk", - Partitions: []string{"p0"}, - IndexHints: []ast.IndexHint{{Action: "USE", Keyword: "INDEX", Names: []string{"ix"}}}, - IndexedBy: "i", + TableSample: &ast.TableSampleClause{Keyword: "TABLESAMPLE", Method: "SYSTEM", Args: []ast.Expression{num("5")}, AfterAlias: true}, + Flashback: &ast.FlashbackClause{Kind: "SCN", Point: num("9")}, + DBLink: "lnk", + Partitions: []string{"p0"}, + IndexHints: []ast.IndexHint{{Action: "USE", Keyword: "INDEX", Names: []string{"ix"}}}, + IndexedBy: "i", } }, "Values": func(r *ast.TableReference) { @@ -314,3 +310,49 @@ func TestRender_TableReferenceRendersEveryField(t *testing.T) { }) } } + +// TestRender_TableModifiersRendersEveryField sets each exported +// TableModifiers field in turn and asserts that both renderers write it. +func TestRender_TableModifiersRendersEveryField(t *testing.T) { + num := func(v string) *ast.LiteralValue { return &ast.LiteralValue{Value: v, Type: "int"} } + samples := map[string]func(*ast.TableModifiers){ + "TableSample": func(m *ast.TableModifiers) { + m.TableSample = &ast.TableSampleClause{Keyword: "SAMPLE", Args: []ast.Expression{num("5")}} + }, + "Flashback": func(m *ast.TableModifiers) { m.Flashback = &ast.FlashbackClause{Kind: "SCN", Point: num("9")} }, + "DBLink": func(m *ast.TableModifiers) { m.DBLink = "lnk" }, + "Partitions": func(m *ast.TableModifiers) { m.Partitions = []string{"p0"} }, + "IndexHints": func(m *ast.TableModifiers) { + m.IndexHints = []ast.IndexHint{{Action: "FORCE", Keyword: "INDEX", Names: []string{"ix"}}} + }, + "IndexedBy": func(m *ast.TableModifiers) { m.IndexedBy = "i" }, + "NotIndexed": func(m *ast.TableModifiers) { m.NotIndexed = true }, + "WithSchema": func(m *ast.TableModifiers) { + m.WithSchema = []ast.OpenJSONColumn{{Name: "id", Type: "INT", Path: "$.id"}} + }, + } + typ := reflect.TypeOf(ast.TableModifiers{}) + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + set, ok := samples[name] + if !ok { + t.Errorf("TableModifiers.%s has no render sample", name) + continue + } + t.Run(name, func(t *testing.T) { + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "a"}}, + From: []ast.TableReference{{Name: "t", Alias: "x", Modifiers: &ast.TableModifiers{}}}, + } + before := formatter.FormatStatement(stmt, ast.CompactStyle()) + beforeSQL := stmt.SQL() + set(stmt.From[0].Modifiers) + if after := formatter.FormatStatement(stmt, ast.CompactStyle()); after == before { + t.Errorf("formatter ignores %s: %s", name, after) + } + if after := stmt.SQL(); after == beforeSQL { + t.Errorf("SQL() ignores %s: %s", name, after) + } + }) + } +} diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 92b31cd7..edf91ab1 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -239,12 +239,11 @@ type TableReference struct { // Only is the PostgreSQL ONLY modifier (FROM ONLY parent): the table's // inheritance children are excluded. Only bool - // TableSample is a row-sampling clause: TABLESAMPLE / SAMPLE. - TableSample *TableSampleClause - // Flashback is the Oracle AS OF / VERSIONS BETWEEN flashback clause. - Flashback *FlashbackClause - // Modifiers holds the rarer table modifiers: Oracle @dblink, MySQL - // PARTITION selection and index hints, SQLite INDEXED BY. + // Modifiers holds the rarer table modifiers: TABLESAMPLE / SAMPLE, + // Oracle flashback and @dblink, MySQL PARTITION selection and index + // hints, SQLite INDEXED BY, SQL Server OPENJSON WITH schema. It is one + // pointer because TableReference is copied by value on the parser hot + // path. Modifiers *TableModifiers // Values is a derived table whose body is a VALUES table constructor: // FROM (VALUES (1, 'a'), (2, 'b')) AS v(id, name). @@ -296,11 +295,13 @@ func (t TableReference) Children() []Node { if t.ForSystemTime != nil { nodes = append(nodes, t.ForSystemTime) } - if t.TableSample != nil { - nodes = append(nodes, t.TableSample) - } - if t.Flashback != nil { - nodes = append(nodes, t.Flashback) + if m := t.Modifiers; m != nil { + if m.TableSample != nil { + nodes = append(nodes, m.TableSample) + } + if m.Flashback != nil { + nodes = append(nodes, m.Flashback) + } } if t.Values != nil { nodes = append(nodes, t.Values) diff --git a/pkg/sql/ast/table_factor.go b/pkg/sql/ast/table_factor.go index ba861477..978d8e3e 100644 --- a/pkg/sql/ast/table_factor.go +++ b/pkg/sql/ast/table_factor.go @@ -118,6 +118,10 @@ type IndexHint struct { // pointer on TableReference so the many references without any of them pay // one word. type TableModifiers struct { + // TableSample is a row-sampling clause: TABLESAMPLE / SAMPLE. + TableSample *TableSampleClause + // Flashback is the Oracle AS OF / VERSIONS BETWEEN flashback clause. + Flashback *FlashbackClause // DBLink is the Oracle database link of t@dblink. DBLink string // Partitions is the MySQL / MariaDB / Oracle partition selection @@ -145,10 +149,26 @@ type OpenJSONColumn struct { // isEmpty reports whether m carries no modifier. func (m *TableModifiers) isEmpty() bool { - return m == nil || (m.DBLink == "" && len(m.Partitions) == 0 && len(m.IndexHints) == 0 && + return m == nil || (m.TableSample == nil && m.Flashback == nil && m.DBLink == "" && len(m.Partitions) == 0 && len(m.IndexHints) == 0 && m.IndexedBy == "" && !m.NotIndexed && len(m.WithSchema) == 0) } +// tableSample returns the sampling clause of t, or nil. +func (t *TableReference) tableSample() *TableSampleClause { + if t.Modifiers == nil { + return nil + } + return t.Modifiers.TableSample +} + +// flashback returns the Oracle flashback clause of t, or nil. +func (t *TableReference) flashback() *FlashbackClause { + if t.Modifiers == nil { + return nil + } + return t.Modifiers.Flashback +} + // textWriter is the writer interface shared by *bytes.Buffer and // *strings.Builder. type textWriter interface { @@ -207,11 +227,11 @@ func writeTableRefPreAlias(w textWriter, t *TableReference, kw func(string) stri if t.TimeTravel != nil { writeTimeTravel(w, t.TimeTravel, kw) } - if t.TableSample != nil && !t.TableSample.AfterAlias { - writeTableSample(w, t.TableSample, kw) + if s := t.tableSample(); s != nil && !s.AfterAlias { + writeTableSample(w, s, kw) } - if t.Flashback != nil { - writeFlashback(w, t.Flashback, kw) + if f := t.flashback(); f != nil { + writeFlashback(w, f, kw) } } @@ -219,8 +239,8 @@ func writeTableRefPreAlias(w textWriter, t *TableReference, kw func(string) stri // TABLESAMPLE, ClickHouse FINAL, MySQL index hints and SQLite INDEXED BY. // Each is preceded by a space. func writeTableRefPostAlias(w textWriter, t *TableReference, kw func(string) string) { - if t.TableSample != nil && t.TableSample.AfterAlias { - writeTableSample(w, t.TableSample, kw) + if s := t.tableSample(); s != nil && s.AfterAlias { + writeTableSample(w, s, kw) } if t.Final { w.WriteByte(' ') @@ -249,8 +269,8 @@ func writeTableRefPostAlias(w textWriter, t *TableReference, kw func(string) str // kw (nil keeps them upper case). It returns "" without allocating when // there are none. func TableRefPreAliasSQL(t *TableReference, kw func(string) string) string { - if t == nil || (t.ForSystemTime == nil && t.TimeTravel == nil && t.Flashback == nil && - (t.TableSample == nil || t.TableSample.AfterAlias) && + if t == nil || (t.ForSystemTime == nil && t.TimeTravel == nil && t.flashback() == nil && + (t.tableSample() == nil || t.tableSample().AfterAlias) && (t.Modifiers == nil || (len(t.Modifiers.Partitions) == 0 && len(t.Modifiers.WithSchema) == 0))) { return "" } @@ -266,7 +286,7 @@ func TableRefPreAliasSQL(t *TableReference, kw func(string) string) string { // alias (TABLESAMPLE, FINAL, index hints, INDEXED BY), each preceded by a // space; see TableRefPreAliasSQL. func TableRefPostAliasSQL(t *TableReference, kw func(string) string) string { - if t == nil || (!t.Final && (t.TableSample == nil || !t.TableSample.AfterAlias) && + if t == nil || (!t.Final && (t.tableSample() == nil || !t.tableSample().AfterAlias) && (t.Modifiers == nil || (len(t.Modifiers.IndexHints) == 0 && t.Modifiers.IndexedBy == "" && !t.Modifiers.NotIndexed))) { return "" } @@ -612,21 +632,19 @@ func writeMatchRecognize(w textWriter, mr *MatchRecognizeClause, kw func(string) // releaseTableFactorModifiers releases the expressions held by the // table-factor modifiers of tr and clears them. func releaseTableFactorModifiers(tr *TableReference, mode releaseMode) { - if s := tr.TableSample; s != nil { + if s := tr.tableSample(); s != nil { for i := range s.Args { releaseExpression(s.Args[i], mode) s.Args[i] = nil } releaseExpression(s.Seed, mode) s.Args, s.Seed = nil, nil - tr.TableSample = nil } - if f := tr.Flashback; f != nil { + if f := tr.flashback(); f != nil { releaseExpression(f.Point, mode) releaseExpression(f.Start, mode) releaseExpression(f.End, mode) f.Point, f.Start, f.End = nil, nil, nil - tr.Flashback = nil } if v := tr.Values; v != nil { for _, row := range v.Rows { @@ -638,7 +656,7 @@ func releaseTableFactorModifiers(tr *TableReference, mode releaseMode) { v.Rows = nil tr.Values = nil } - tr.Modifiers = nil // not pooled: strings only + tr.Modifiers = nil // not pooled; its expressions were released above tr.Only = false } diff --git a/pkg/sql/ast/table_factor_test.go b/pkg/sql/ast/table_factor_test.go index 250ee927..f044ace8 100644 --- a/pkg/sql/ast/table_factor_test.go +++ b/pkg/sql/ast/table_factor_test.go @@ -31,13 +31,15 @@ func TestReleaseTableReference_ClearsTableFactorModifiers(t *testing.T) { Only: true, Final: true, ColumnAliases: &ColumnAliasList{Names: []string{"a"}, Types: []string{"int"}}, - TableSample: &TableSampleClause{ - Keyword: "TABLESAMPLE", Method: "SYSTEM", Args: []Expression{num("1")}, - SeedKeyword: "REPEATABLE", Seed: num("2"), + Modifiers: &TableModifiers{ + TableSample: &TableSampleClause{ + Keyword: "TABLESAMPLE", Method: "SYSTEM", Args: []Expression{num("1")}, + SeedKeyword: "REPEATABLE", Seed: num("2"), + }, + Flashback: &FlashbackClause{Versions: true, Kind: "SCN", Start: num("1"), End: num("2")}, + DBLink: "l", Partitions: []string{"p"}, IndexedBy: "i", }, - Flashback: &FlashbackClause{Versions: true, Kind: "SCN", Start: num("1"), End: num("2")}, - Modifiers: &TableModifiers{DBLink: "l", Partitions: []string{"p"}, IndexedBy: "i"}, - Values: &Values{Rows: [][]Expression{{num("3")}}}, + Values: &Values{Rows: [][]Expression{{num("3")}}}, ForSystemTime: &ForSystemTimeClause{ Type: SystemTimeContainedIn, Start: num("4"), End: num("5"), }, @@ -57,10 +59,10 @@ func TestTableFactorRender_HandBuiltValuesAreSafe(t *testing.T) { name string ref TableReference }{ - {"sample method", TableReference{Name: "t", TableSample: &TableSampleClause{Keyword: "TABLESAMPLE", Method: evil, Args: []Expression{&LiteralValue{Value: "1", Type: "int"}}}}}, - {"sample keyword", TableReference{Name: "t", TableSample: &TableSampleClause{Keyword: evil, Args: []Expression{&LiteralValue{Value: "1", Type: "int"}}}}}, - {"sample unit", TableReference{Name: "t", TableSample: &TableSampleClause{Keyword: "SAMPLE", Unit: evil, Args: []Expression{&LiteralValue{Value: "1", Type: "int"}}}}}, - {"flashback kind", TableReference{Name: "t", Flashback: &FlashbackClause{Kind: evil, Point: &LiteralValue{Value: "1", Type: "int"}}}}, + {"sample method", TableReference{Name: "t", Modifiers: &TableModifiers{TableSample: &TableSampleClause{Keyword: "TABLESAMPLE", Method: evil, Args: []Expression{&LiteralValue{Value: "1", Type: "int"}}}}}}, + {"sample keyword", TableReference{Name: "t", Modifiers: &TableModifiers{TableSample: &TableSampleClause{Keyword: evil, Args: []Expression{&LiteralValue{Value: "1", Type: "int"}}}}}}, + {"sample unit", TableReference{Name: "t", Modifiers: &TableModifiers{TableSample: &TableSampleClause{Keyword: "SAMPLE", Unit: evil, Args: []Expression{&LiteralValue{Value: "1", Type: "int"}}}}}}, + {"flashback kind", TableReference{Name: "t", Modifiers: &TableModifiers{Flashback: &FlashbackClause{Kind: evil, Point: &LiteralValue{Value: "1", Type: "int"}}}}}, {"db link", TableReference{Name: "t", Modifiers: &TableModifiers{DBLink: evil}}}, {"partition", TableReference{Name: "t", Modifiers: &TableModifiers{Partitions: []string{evil}}}}, {"index hint name", TableReference{Name: "t", Modifiers: &TableModifiers{IndexHints: []IndexHint{{Action: "USE", Keyword: "INDEX", Names: []string{evil}}}}}}, diff --git a/pkg/sql/parser/table_factor.go b/pkg/sql/parser/table_factor.go index 74252e58..15b07133 100644 --- a/pkg/sql/parser/table_factor.go +++ b/pkg/sql/parser/table_factor.go @@ -35,6 +35,11 @@ func tableModifiers(ref *ast.TableReference) *ast.TableModifiers { return ref.Modifiers } +// hasTableSample reports whether ref already has a sampling clause. +func hasTableSample(ref *ast.TableReference) bool { + return ref.Modifiers != nil && ref.Modifiers.TableSample != nil +} + // parseTableOnly consumes the PostgreSQL ONLY modifier before a table name // (FROM ONLY parent) and reports whether it was present. func (p *Parser) parseTableOnly() bool { @@ -257,19 +262,19 @@ func (p *Parser) parseTableModifiersBeforeAlias(ref *ast.TableReference) error { } ref.TimeTravel = tt } - if ref.TableSample == nil && p.isTableSampleStart() { + if !hasTableSample(ref) && p.isTableSampleStart() { sample, err := p.parseTableSample() if err != nil { return err } - ref.TableSample = sample + tableModifiers(ref).TableSample = sample } - if ref.Flashback == nil && p.isOracleFlashbackStart() { + if (ref.Modifiers == nil || ref.Modifiers.Flashback == nil) && p.isOracleFlashbackStart() { fb, err := p.parseOracleFlashback() if err != nil { return err } - ref.Flashback = fb + tableModifiers(ref).Flashback = fb } return nil } @@ -286,12 +291,12 @@ func (p *Parser) isTableModifierAfterAliasStart() bool { // alias: TABLESAMPLE, ClickHouse FINAL, MySQL index hints, SQLite INDEXED BY // / NOT INDEXED and SQL Server bare table hints t (NOLOCK). func (p *Parser) parseTableModifiersAfterAlias(ref *ast.TableReference) error { - if ref.TableSample == nil && p.isTableSampleStart() { + if !hasTableSample(ref) && p.isTableSampleStart() { sample, err := p.parseTableSample() if err != nil { return err } - ref.TableSample = sample + tableModifiers(ref).TableSample = sample } if p.IsClickHouse() && p.isTokenMatch("FINAL") { ref.Final = true diff --git a/pkg/sql/parser/table_factor_test.go b/pkg/sql/parser/table_factor_test.go index 8fd8fde1..bee8d062 100644 --- a/pkg/sql/parser/table_factor_test.go +++ b/pkg/sql/parser/table_factor_test.go @@ -163,7 +163,10 @@ func TestTableSampleClause(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ref := firstFrom(t, parseOne(t, tc.dialect, tc.sql)) - s := ref.TableSample + if ref.Modifiers == nil { + t.Fatal("Modifiers not set") + } + s := ref.Modifiers.TableSample if s == nil { t.Fatal("TableSample not set") } @@ -205,13 +208,19 @@ func TestTableFactorModifiers(t *testing.T) { } }}, {"oracle as of scn", keywords.DialectOracle, "SELECT * FROM t AS OF SCN 123 x", func(t *testing.T, ref *ast.TableReference) { - f := ref.Flashback + if ref.Modifiers == nil { + t.Fatal("Modifiers not set") + } + f := ref.Modifiers.Flashback if f == nil || f.Versions || f.Kind != "SCN" || f.Point == nil || ref.Alias != "x" { t.Errorf("got %+v", f) } }}, {"oracle versions between", keywords.DialectOracle, "SELECT * FROM t VERSIONS BETWEEN SCN MINVALUE AND MAXVALUE", func(t *testing.T, ref *ast.TableReference) { - f := ref.Flashback + if ref.Modifiers == nil { + t.Fatal("Modifiers not set") + } + f := ref.Modifiers.Flashback if f == nil || !f.Versions || f.Kind != "SCN" || f.Start == nil || f.End == nil { t.Errorf("got %+v", f) } @@ -318,7 +327,7 @@ func TestTableFactorModifiersOnJoins(t *testing.T) { if !ok || len(sel.Joins) != 1 { t.Fatal("want a SELECT with one join") } - if sel.From[0].TableSample == nil { + if sel.From[0].Modifiers == nil || sel.From[0].Modifiers.TableSample == nil { t.Error("FROM sample dropped") } right := sel.Joins[0].Right From 56868e61e28457d8d3a629b672de6ab44302fdbb Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:44:22 +0530 Subject: [PATCH 38/95] fix(ast,parser,formatter): keep needed quotes on DDL object names Extends the NameQuote recording to the DDL positions that still wrote quoted names bare: CREATE INDEX name, table and columns, table and column constraint names, column lists and REFERENCES targets, DROP and TRUNCATE names, CREATE MATERIALIZED VIEW names and columns, and the ALTER TABLE target, column, constraint and RENAME TO names (Ident and ObjectName gain a Quote field). Prisma/EF-style PostgreSQL migrations such as CREATE UNIQUE INDEX "User_email_key" ON "User"("email") and ALTER TABLE "Post" ADD CONSTRAINT "Post_fk" FOREIGN KEY ("authorId") REFERENCES "User"("id") now format instead of being refused. The ALTER TABLE renderer wrote the table and column names verbatim, so `ALTER TABLE "my table" DROP COLUMN "a b"` came out unquoted; those names now go through the same quoting as other names. Transpile restyles the delimiters of all these names for the target dialect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_guard_test.go | 8 +- cmd/gosqlx/cmd/format_quoting_test.go | 6 ++ pkg/formatter/name_quote.go | 35 ++++++--- pkg/formatter/name_quote_test.go | 18 +++++ pkg/formatter/render.go | 26 +++---- pkg/formatter/render_alter.go | 23 +++--- .../render_keyword_fields_632_test.go | 6 +- pkg/sql/ast/alter.go | 3 + pkg/sql/ast/ast_clauses.go | 13 ++++ pkg/sql/ast/ast_statements.go | 15 ++++ pkg/sql/ast/name_quote.go | 30 +++++++ pkg/sql/ast/name_quote_restyle.go | 78 ++++++++++++++++++- pkg/sql/ast/object.go | 3 + pkg/sql/ast/pool.go | 8 ++ pkg/sql/ast/pool_statement_release.go | 2 + pkg/sql/ast/sql.go | 26 +++---- pkg/sql/ast/types.go | 3 + pkg/sql/parser/alter.go | 11 +-- pkg/sql/parser/ddl.go | 6 +- pkg/sql/parser/ddl_columns.go | 38 ++++++--- pkg/sql/parser/ddl_index.go | 7 +- pkg/sql/parser/ddl_view.go | 4 +- pkg/sql/parser/name_quote_test.go | 9 +++ pkg/sql/parser/parser.go | 2 +- pkg/transpiler/quoting_test.go | 7 ++ 25 files changed, 310 insertions(+), 77 deletions(-) diff --git a/cmd/gosqlx/cmd/format_guard_test.go b/cmd/gosqlx/cmd/format_guard_test.go index e9b6e32e..b2169460 100644 --- a/cmd/gosqlx/cmd/format_guard_test.go +++ b/cmd/gosqlx/cmd/format_guard_test.go @@ -33,10 +33,10 @@ func TestFormatSQL_RefusesLostQuotes(t *testing.T) { dialect string in string }{ - // Names the AST does not record quoting for (ALTER TABLE targets, - // index names) still render bare, so the CLI must refuse. - {name: "alter table mixed case", in: `ALTER TABLE "Mixed" ADD COLUMN a INT`}, - {name: "create index mixed case", in: `CREATE INDEX "Idx" ON t (a)`}, + // Names the AST does not record quoting for (window names, sequence + // names) still render bare, so the CLI must refuse. + {name: "window name mixed case", in: `SELECT SUM(x) OVER "W" FROM t WINDOW "W" AS (ORDER BY x)`}, + {name: "sequence mixed case", in: `CREATE SEQUENCE "Seq"`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/cmd/gosqlx/cmd/format_quoting_test.go b/cmd/gosqlx/cmd/format_quoting_test.go index 46bd1b6b..b153d121 100644 --- a/cmd/gosqlx/cmd/format_quoting_test.go +++ b/cmd/gosqlx/cmd/format_quoting_test.go @@ -40,6 +40,12 @@ func TestFormatSQL_KeepsNeededQuotes(t *testing.T) { want: `SELECT s.serial#, emp# FROM v$session s`}, {name: "create table quoted", in: `CREATE TABLE "Mixed" ("order" INT)`, want: `CREATE TABLE "Mixed" ("order" INT)`}, {name: "alter add mixed case column", in: `ALTER TABLE t ADD COLUMN "Mixed" INT`, want: `ALTER TABLE t ADD COLUMN "Mixed" INT`}, + {name: "prisma unique index", dialect: "postgresql", in: `CREATE UNIQUE INDEX "User_email_key" ON "User"("email")`, + want: `CREATE UNIQUE INDEX "User_email_key" ON "User" (email)`}, + {name: "prisma foreign key", dialect: "postgresql", + in: `ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE`, + want: `ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" (id) ON DELETE RESTRICT ON UPDATE CASCADE`}, + {name: "prisma drop table", dialect: "postgresql", in: `DROP TABLE "Post"`, want: `DROP TABLE "Post"`}, {name: "mysql backtick reserved alias", dialect: "mysql", in: "SELECT EXISTS(SELECT 1) AS `exists`", want: "SELECT EXISTS (SELECT 1) AS `exists`"}, {name: "mysql backtick keyword column", dialect: "mysql", in: "ALTER TABLE `Users` ADD COLUMN `select` INT", diff --git a/pkg/formatter/name_quote.go b/pkg/formatter/name_quote.go index d1750645..28448f61 100644 --- a/pkg/formatter/name_quote.go +++ b/pkg/formatter/name_quote.go @@ -46,28 +46,41 @@ func (f *nodeFormatter) nq(q ast.NameQuote) ast.NameQuote { // quoteNamesWith renders a CTE or view column list like quoteNames, // honouring each name's recorded delimiters (qs by index, may be nil). func (f *nodeFormatter) quoteNamesWith(names []string, qs []ast.NameQuote) string { - parts := make([]string, len(names)) - for i, n := range names { - var q ast.NameQuote - if i < len(qs) { - q = qs[i] - } - parts[i] = ast.QuoteNameWith(n, f.nq(q)) - } - return strings.Join(parts, ", ") + return joinQuoted(names, qs, ast.QuoteNameWith, f.nq) } // quoteColumnNamesWith joins single-part DDL column names like // quoteColumnNames, honouring each name's recorded delimiters (qs by index, -// may be nil). +// may be nil) and the target dialect's delimiter. func (f *nodeFormatter) quoteColumnNamesWith(names []string, qs []ast.NameQuote) string { + return joinQuoted(names, qs, ast.QuoteColumnNameWith, f.nq) +} + +// quoteColumnNamesQ is quoteColumnNamesWith for renderers that do not know +// the target dialect: only the recorded delimiters are honoured. +func quoteColumnNamesQ(names []string, qs []ast.NameQuote) string { + return joinQuoted(names, qs, ast.QuoteColumnNameWith, nil) +} + +// quoteQualifiedNamesWith joins table-like names like quoteQualifiedNames, +// honouring each name's recorded delimiters and the target dialect's. +func (f *nodeFormatter) quoteQualifiedNamesWith(names []string, qs []ast.NameQuote) string { + return joinQuoted(names, qs, ast.QuoteQualifiedNameWith, f.nq) +} + +// joinQuoted joins names with ", ", each rendered by quote with its +// NameQuote from qs (by index; may be nil), adjusted by nq when not nil. +func joinQuoted(names []string, qs []ast.NameQuote, quote func(string, ast.NameQuote) string, nq func(ast.NameQuote) ast.NameQuote) string { parts := make([]string, len(names)) for i, n := range names { var q ast.NameQuote if i < len(qs) { q = qs[i] } - parts[i] = ast.QuoteColumnNameWith(n, f.nq(q)) + if nq != nil { + q = nq(q) + } + parts[i] = quote(n, q) } return strings.Join(parts, ", ") } diff --git a/pkg/formatter/name_quote_test.go b/pkg/formatter/name_quote_test.go index 80dd6711..9d990083 100644 --- a/pkg/formatter/name_quote_test.go +++ b/pkg/formatter/name_quote_test.go @@ -50,6 +50,24 @@ func TestNameQuote_FormatKeepsCaseQuotes(t *testing.T) { `MERGE INTO t "Tgt" USING s "Src" ON "Tgt".id = "Src".id WHEN MATCHED THEN UPDATE SET "Val" = 1 WHEN NOT MATCHED THEN INSERT ("Id") VALUES (1)`}, {"pg create table", keywords.DialectPostgreSQL, `CREATE TABLE "MyTable" ("ColA" INT)`, `CREATE TABLE "MyTable" ("ColA" INT)`}, {"pg create view", keywords.DialectPostgreSQL, `CREATE VIEW "V" ("A") AS SELECT 1`, `CREATE VIEW "V" ("A") AS SELECT 1`}, + {"pg create index", keywords.DialectPostgreSQL, `CREATE INDEX "Idx" ON "T" ("Col", b)`, `CREATE INDEX "Idx" ON "T" ("Col", b)`}, + {"pg drop", keywords.DialectPostgreSQL, `DROP TABLE "Post", other`, `DROP TABLE "Post", other`}, + {"pg alter add constraint", keywords.DialectPostgreSQL, + `ALTER TABLE "Post" ADD CONSTRAINT "Post_fk" FOREIGN KEY ("authorId") REFERENCES "User"("Id")`, + `ALTER TABLE "Post" ADD CONSTRAINT "Post_fk" FOREIGN KEY ("authorId") REFERENCES "User" ("Id")`}, + {"pg alter rename column", keywords.DialectPostgreSQL, + `ALTER TABLE "Post" RENAME COLUMN "Old" TO "New"`, `ALTER TABLE "Post" RENAME COLUMN "Old" TO "New"`}, + {"pg alter rename table", keywords.DialectPostgreSQL, `ALTER TABLE "Post" RENAME TO "Posts"`, `ALTER TABLE "Post" RENAME TO "Posts"`}, + {"pg alter drop column and constraint", keywords.DialectPostgreSQL, + `ALTER TABLE t DROP COLUMN "Old"`, `ALTER TABLE t DROP COLUMN "Old"`}, + {"pg alter drop keyword constraint", keywords.DialectPostgreSQL, + `ALTER TABLE t DROP CONSTRAINT "key"`, `ALTER TABLE t DROP CONSTRAINT "key"`}, + // Names the ALTER renderer used to write verbatim now get quoted + // when their content needs it. + {"pg alter spaced names", keywords.DialectPostgreSQL, + `ALTER TABLE "my table" DROP COLUMN "a b"`, `ALTER TABLE "my table" DROP COLUMN "a b"`}, + {"mysql alter backticks", keywords.DialectMySQL, + "ALTER TABLE `my table` RENAME COLUMN `a b` TO `select`", "ALTER TABLE `my table` RENAME COLUMN `a b` TO `select`"}, {"oracle lower case", keywords.DialectOracle, `SELECT "ID", "id" FROM "T" "t"`, `SELECT ID, "id" FROM T "t"`}, {"mysql case not needed", keywords.DialectMySQL, "SELECT `Id` FROM `Users` AS `U`", "SELECT Id FROM Users U"}, {"mysql reserved alias", keywords.DialectMySQL, "SELECT 1 AS `exists` FROM `order` AS `group`", "SELECT 1 AS `exists` FROM `order` `group`"}, diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 80721b7f..c0e99fd0 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -777,11 +777,11 @@ func renderCreateIndex(c *ast.CreateIndexStatement, opts ast.FormatOptions) stri sb.WriteString(f.kw("IF NOT EXISTS")) sb.WriteString(" ") } - sb.WriteString(ast.QuoteQualifiedName(c.Name)) + sb.WriteString(ast.QuoteQualifiedNameWith(c.Name, f.nq(c.NameQuote))) sb.WriteString(" ") sb.WriteString(f.kw("ON")) sb.WriteString(" ") - sb.WriteString(ast.QuoteQualifiedName(c.Table)) + sb.WriteString(ast.QuoteQualifiedNameWith(c.Table, f.nq(c.TableQuote))) if c.Using != "" { sb.WriteString(" ") @@ -793,7 +793,7 @@ func renderCreateIndex(c *ast.CreateIndexStatement, opts ast.FormatOptions) stri sb.WriteString(" (") cols := make([]string, len(c.Columns)) for i, col := range c.Columns { - s := ast.QuoteColumnName(col.Column) + s := ast.QuoteColumnNameWith(col.Column, f.nq(col.ColumnQuote)) if col.Collate != "" { s += " " + f.kw("COLLATE") + " " + ast.CollationNameSQL(col.Collate, col.CollateQuoted) } @@ -888,11 +888,11 @@ func renderCreateMaterializedView(c *ast.CreateMaterializedViewStatement, opts a sb.WriteString(f.kw("IF NOT EXISTS")) sb.WriteString(" ") } - sb.WriteString(ast.QuoteQualifiedName(c.Name)) + sb.WriteString(ast.QuoteQualifiedNameWith(c.Name, f.nq(c.NameQuote))) if len(c.Columns) > 0 { sb.WriteString(" (") - sb.WriteString(quoteNames(c.Columns)) + sb.WriteString(f.quoteNamesWith(c.Columns, c.ColumnQuotes)) sb.WriteString(")") } @@ -971,7 +971,7 @@ func renderDrop(d *ast.DropStatement, opts ast.FormatOptions) string { sb.WriteString(f.kw("IF EXISTS")) sb.WriteString(" ") } - sb.WriteString(quoteQualifiedNames(d.Names)) + sb.WriteString(f.quoteQualifiedNamesWith(d.Names, d.NameQuotes)) if c := ast.CascadeText(d.CascadeType); c != "" { sb.WriteString(" ") @@ -996,7 +996,7 @@ func renderTruncate(t *ast.TruncateStatement, opts ast.FormatOptions) string { sb.WriteString(" ") sb.WriteString(f.kw("TABLE")) sb.WriteString(" ") - sb.WriteString(quoteQualifiedNames(t.Tables)) + sb.WriteString(f.quoteQualifiedNamesWith(t.Tables, t.TableQuotes)) if t.RestartIdentity { sb.WriteString(" ") @@ -1722,21 +1722,21 @@ func tableConstraintSQL(tc *ast.TableConstraint) string { var sb strings.Builder if tc.Name != "" { sb.WriteString("CONSTRAINT ") - sb.WriteString(ast.QuoteConstraintName(tc.Name)) + sb.WriteString(ast.QuoteConstraintNameWith(tc.Name, tc.NameQuote)) sb.WriteString(" ") } switch tc.Type { case "PRIMARY KEY": sb.WriteString("PRIMARY KEY (") - sb.WriteString(quoteColumnNames(tc.Columns)) + sb.WriteString(quoteColumnNamesQ(tc.Columns, tc.ColumnQuotes)) sb.WriteString(")") case "UNIQUE": sb.WriteString("UNIQUE (") - sb.WriteString(quoteColumnNames(tc.Columns)) + sb.WriteString(quoteColumnNamesQ(tc.Columns, tc.ColumnQuotes)) sb.WriteString(")") case "FOREIGN KEY": sb.WriteString("FOREIGN KEY (") - sb.WriteString(quoteColumnNames(tc.Columns)) + sb.WriteString(quoteColumnNamesQ(tc.Columns, tc.ColumnQuotes)) sb.WriteString(") ") if tc.References != nil { sb.WriteString(referenceSQL(tc.References)) @@ -1757,10 +1757,10 @@ func tableConstraintSQL(tc *ast.TableConstraint) string { func referenceSQL(r *ast.ReferenceDefinition) string { var sb strings.Builder sb.WriteString("REFERENCES ") - sb.WriteString(ast.QuoteQualifiedName(r.Table)) + sb.WriteString(ast.QuoteQualifiedNameWith(r.Table, r.TableQuote)) if len(r.Columns) > 0 { sb.WriteString(" (") - sb.WriteString(quoteColumnNames(r.Columns)) + sb.WriteString(quoteColumnNamesQ(r.Columns, r.ColumnQuotes)) sb.WriteString(")") } if a := ast.ReferentialActionText(r.OnDelete); a != "" { diff --git a/pkg/formatter/render_alter.go b/pkg/formatter/render_alter.go index 8226c873..ef87e884 100644 --- a/pkg/formatter/render_alter.go +++ b/pkg/formatter/render_alter.go @@ -30,7 +30,7 @@ func renderAlterStatement(a *ast.AlterStatement, opts ast.FormatOptions) string f := newNodeFormatter(opts) f.sb.WriteString(f.kw("ALTER TABLE")) f.sb.WriteString(" ") - f.sb.WriteString(a.Name) + f.sb.WriteString(ast.QuoteQualifiedNameWith(a.Name, f.nq(a.NameQuote))) if op, ok := a.Operation.(*ast.AlterTableOperation); ok && op != nil { f.sb.WriteString(f.clauseSep()) writeAlterTableOperation(f, op) @@ -41,12 +41,13 @@ func renderAlterStatement(a *ast.AlterStatement, opts ast.FormatOptions) string return f.result() } -// alterIdentName returns the name of i, or "" for nil. -func alterIdentName(i *ast.Ident) string { +// alterIdentName returns the name of i quoted as a column name when it needs +// delimiters (or the source's are recorded), or "" for nil. +func alterIdentName(f *nodeFormatter, i *ast.Ident) string { if i == nil { return "" } - return i.Name + return ast.QuoteColumnNameWith(i.Name, f.nq(i.Quote)) } // writeAlterTableOperation renders a single ALTER TABLE operation. Operation @@ -74,7 +75,7 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { case ast.DropColumn: sb.WriteString(f.kw("DROP COLUMN")) sb.WriteString(" ") - sb.WriteString(alterIdentName(op.ColumnName)) + sb.WriteString(alterIdentName(f, op.ColumnName)) if op.CascadeDrops { sb.WriteString(" ") sb.WriteString(f.kw("CASCADE")) @@ -82,7 +83,9 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { case ast.DropConstraint: sb.WriteString(f.kw("DROP CONSTRAINT")) sb.WriteString(" ") - sb.WriteString(alterIdentName(op.ConstraintName)) + if op.ConstraintName != nil { + sb.WriteString(ast.QuoteConstraintNameWith(op.ConstraintName.Name, f.nq(op.ConstraintName.Quote))) + } if op.CascadeDrops { sb.WriteString(" ") sb.WriteString(f.kw("CASCADE")) @@ -90,7 +93,7 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { case ast.RenameTable: sb.WriteString(f.kw("RENAME TO")) sb.WriteString(" ") - sb.WriteString(op.NewTableName.String()) + sb.WriteString(ast.QuoteQualifiedNameWith(op.NewTableName.Name, f.nq(op.NewTableName.Quote))) case ast.RenameColumn: oldName := op.ColumnName if oldName == nil { @@ -98,15 +101,15 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { } sb.WriteString(f.kw("RENAME COLUMN")) sb.WriteString(" ") - sb.WriteString(alterIdentName(oldName)) + sb.WriteString(alterIdentName(f, oldName)) sb.WriteString(" ") sb.WriteString(f.kw("TO")) sb.WriteString(" ") - sb.WriteString(alterIdentName(op.NewColumnName)) + sb.WriteString(alterIdentName(f, op.NewColumnName)) case ast.AlterColumn: sb.WriteString(f.kw("ALTER COLUMN")) sb.WriteString(" ") - sb.WriteString(alterIdentName(op.ColumnName)) + sb.WriteString(alterIdentName(f, op.ColumnName)) if op.ColumnDef != nil { sb.WriteString(" ") sb.WriteString(columnDefSQL(op.ColumnDef)) diff --git a/pkg/formatter/render_keyword_fields_632_test.go b/pkg/formatter/render_keyword_fields_632_test.go index 7b78ace5..3828d5f3 100644 --- a/pkg/formatter/render_keyword_fields_632_test.go +++ b/pkg/formatter/render_keyword_fields_632_test.go @@ -261,8 +261,10 @@ func TestQuotedConstraintNamesRoundTrip_PR632(t *testing.T) { {`CREATE TABLE t (a INT, CONSTRAINT "default" CHECK (a > 0))`, `CONSTRAINT "default" CHECK`}, {`CREATE TABLE t (a INT, CONSTRAINT "table" PRIMARY KEY (a))`, `CONSTRAINT "table" PRIMARY KEY`}, {`CREATE TABLE t (a INT, CONSTRAINT "owner" PRIMARY KEY (a))`, `CONSTRAINT "owner" PRIMARY KEY`}, - // Quoted only for case: rendered bare, like Identifier.SQL(). - {`CREATE TABLE t (a INT, CONSTRAINT "MixedCase" PRIMARY KEY (a))`, `CONSTRAINT MixedCase PRIMARY KEY`}, + // Quoted only for case: the quotes are kept where the dialect folds + // unquoted names (generic, PostgreSQL) and dropped in SQLite, so only + // the name is checked here; the re-parse check below pins the name. + {`CREATE TABLE t (a INT, CONSTRAINT "MixedCase" PRIMARY KEY (a))`, `MixedCase`}, {`CREATE TABLE t (a INT, CONSTRAINT PK_USERS PRIMARY KEY (a))`, `CONSTRAINT PK_USERS PRIMARY KEY`}, {`CREATE TABLE t (a INT, CONSTRAINT pk_t PRIMARY KEY (a))`, `CONSTRAINT pk_t PRIMARY KEY`}, } diff --git a/pkg/sql/ast/alter.go b/pkg/sql/ast/alter.go index 1754ea46..6dfaf430 100644 --- a/pkg/sql/ast/alter.go +++ b/pkg/sql/ast/alter.go @@ -95,6 +95,9 @@ type AlterStatement struct { Type AlterType Name string // Name of the object being altered Operation AlterOperation + // NameQuote records the delimiters of Name when they must be kept + // (see NameQuote). + NameQuote NameQuote } func (a *AlterStatement) statementNode() {} diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index f38e05e7..be271ba6 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -625,6 +625,11 @@ type TableConstraint struct { Columns []string References *ReferenceDefinition Check Expression + // NameQuote records the delimiters of Name, and ColumnQuotes those of + // each Columns entry by index (nil when none keeps delimiters); see + // NameQuote. + NameQuote NameQuote + ColumnQuotes []NameQuote } func (t *TableConstraint) expressionNode() {} @@ -647,6 +652,11 @@ type ReferenceDefinition struct { OnDelete string OnUpdate string Match string + // TableQuote records the delimiters of Table, and ColumnQuotes those of + // each Columns entry by index (nil when none keeps delimiters); see + // NameQuote. + TableQuote NameQuote + ColumnQuotes []NameQuote } func (r *ReferenceDefinition) expressionNode() {} @@ -684,6 +694,9 @@ type IndexColumn struct { CollateQuoted bool Direction string // ASC, DESC NullsLast bool + // ColumnQuote records the delimiters of Column when they must be kept + // (see NameQuote). + ColumnQuote NameQuote } func (i *IndexColumn) expressionNode() {} diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index 7427c71a..d62aaf70 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -557,6 +557,10 @@ type CreateIndexStatement struct { Columns []IndexColumn Using string Where Expression + // NameQuote and TableQuote record the delimiters of Name and Table + // when they must be kept (see NameQuote). + NameQuote NameQuote + TableQuote NameQuote } func (c *CreateIndexStatement) statementNode() {} @@ -737,6 +741,11 @@ type CreateMaterializedViewStatement struct { Query Statement // The SELECT statement WithData *bool // nil = default, true = WITH DATA, false = WITH NO DATA Tablespace string // Optional tablespace (PostgreSQL) + // NameQuote records the delimiters of Name, and ColumnQuotes those of + // each Columns entry by index (nil when none keeps delimiters); see + // NameQuote. + NameQuote NameQuote + ColumnQuotes []NameQuote } func (c *CreateMaterializedViewStatement) statementNode() {} @@ -767,6 +776,9 @@ type DropStatement struct { IfExists bool Names []string // Can drop multiple objects CascadeType string // CASCADE, RESTRICT, or empty + // NameQuotes records the delimiters of each Names entry by index (nil + // when none keeps delimiters); see NameQuote. + NameQuotes []NameQuote } func (d *DropStatement) statementNode() {} @@ -780,6 +792,9 @@ type TruncateStatement struct { RestartIdentity bool // RESTART IDENTITY - reset sequences ContinueIdentity bool // CONTINUE IDENTITY - keep sequences (default) CascadeType string // CASCADE, RESTRICT, or empty + // TableQuotes records the delimiters of each Tables entry by index (nil + // when none keeps delimiters); see NameQuote. + TableQuotes []NameQuote } func (t *TruncateStatement) statementNode() {} diff --git a/pkg/sql/ast/name_quote.go b/pkg/sql/ast/name_quote.go index 3fa78227..b906f842 100644 --- a/pkg/sql/ast/name_quote.go +++ b/pkg/sql/ast/name_quote.go @@ -437,3 +437,33 @@ func QuoteQualifiedNameWith(name string, q NameQuote) string { func QuoteFunctionNameWith(name string, q NameQuote) string { return quoteWith(name, q, writeFunctionNameQ) } + +// writeQualifiedNameListQ writes names separated by ", " like +// writeQualifiedNameList; qs, when not nil, holds each name's NameQuote. +func writeQualifiedNameListQ(buf *bytes.Buffer, names []string, qs []NameQuote) { + for i, n := range names { + if i > 0 { + buf.WriteString(", ") + } + writeQualifiedNameQ(buf, n, nameQuoteAt(qs, i)) + } +} + +// writeConstraintNameQ writes a table constraint name like +// writeConstraintName, honouring the delimiters recorded in q. +func writeConstraintNameQ(buf *bytes.Buffer, name string, q NameQuote) { + if q == 0 { + writeConstraintName(buf, name) + return + } + writeNamePart(buf, name, partDelimited(name, q.Part(0), constraintNameNeedsQuoting(name), q), q.Style()) +} + +// QuoteConstraintNameWith returns a table constraint name as +// QuoteConstraintName does, honouring the delimiters recorded in q. +func QuoteConstraintNameWith(name string, q NameQuote) string { + if q == 0 || (!q.HasParts() && !constraintNameNeedsQuoting(name)) { + return QuoteConstraintName(name) + } + return quoteWith(name, q, writeConstraintNameQ) +} diff --git a/pkg/sql/ast/name_quote_restyle.go b/pkg/sql/ast/name_quote_restyle.go index 30aef7fa..b2a4c5d8 100644 --- a/pkg/sql/ast/name_quote_restyle.go +++ b/pkg/sql/ast/name_quote_restyle.go @@ -87,14 +87,86 @@ func SetNameQuoteStyle(node Node, style rune, keepBareHash bool) { case *CreateTableStatement: v.NameQuote = restyle(v.NameQuote) for i := range v.Columns { - v.Columns[i].NameQuote = restyle(v.Columns[i].NameQuote) + restyleColumnDef(&v.Columns[i], restyle) + } + for i := range v.Constraints { + restyleTableConstraint(&v.Constraints[i], restyle) } case *CreateViewStatement: v.NameQuote = restyle(v.NameQuote) - for i := range v.ColumnQuotes { - v.ColumnQuotes[i] = restyle(v.ColumnQuotes[i]) + restyleList(v.ColumnQuotes, restyle) + case *CreateMaterializedViewStatement: + v.NameQuote = restyle(v.NameQuote) + restyleList(v.ColumnQuotes, restyle) + case *CreateIndexStatement: + v.NameQuote = restyle(v.NameQuote) + v.TableQuote = restyle(v.TableQuote) + for i := range v.Columns { + v.Columns[i].ColumnQuote = restyle(v.Columns[i].ColumnQuote) + } + case *DropStatement: + restyleList(v.NameQuotes, restyle) + case *TruncateStatement: + restyleList(v.TableQuotes, restyle) + case *AlterStatement: + v.NameQuote = restyle(v.NameQuote) + if op, ok := v.Operation.(*AlterTableOperation); ok && op != nil { + restyleAlterTableOperation(op, restyle) } } return true }) } + +// restyleList applies restyle to every entry of qs in place. +func restyleList(qs []NameQuote, restyle func(NameQuote) NameQuote) { + for i := range qs { + qs[i] = restyle(qs[i]) + } +} + +// restyleColumnDef applies restyle to a column definition's name and to the +// REFERENCES clauses of its constraints. +func restyleColumnDef(c *ColumnDef, restyle func(NameQuote) NameQuote) { + if c == nil { + return + } + c.NameQuote = restyle(c.NameQuote) + for i := range c.Constraints { + restyleReference(c.Constraints[i].References, restyle) + } +} + +// restyleTableConstraint applies restyle to a table constraint's name, +// columns and REFERENCES clause. +func restyleTableConstraint(tc *TableConstraint, restyle func(NameQuote) NameQuote) { + if tc == nil { + return + } + tc.NameQuote = restyle(tc.NameQuote) + restyleList(tc.ColumnQuotes, restyle) + restyleReference(tc.References, restyle) +} + +// restyleReference applies restyle to a REFERENCES clause's table and +// columns. +func restyleReference(r *ReferenceDefinition, restyle func(NameQuote) NameQuote) { + if r == nil { + return + } + r.TableQuote = restyle(r.TableQuote) + restyleList(r.ColumnQuotes, restyle) +} + +// restyleAlterTableOperation applies restyle to the names an ALTER TABLE +// operation carries. +func restyleAlterTableOperation(op *AlterTableOperation, restyle func(NameQuote) NameQuote) { + restyleColumnDef(op.ColumnDef, restyle) + restyleTableConstraint(op.Constraint, restyle) + for _, id := range []*Ident{op.ColumnName, op.ConstraintName, op.OldColumnName, op.NewColumnName} { + if id != nil { + id.Quote = restyle(id.Quote) + } + } + op.NewTableName.Quote = restyle(op.NewTableName.Quote) +} diff --git a/pkg/sql/ast/object.go b/pkg/sql/ast/object.go index 88302258..74fd87f7 100644 --- a/pkg/sql/ast/object.go +++ b/pkg/sql/ast/object.go @@ -17,6 +17,9 @@ package ast // ObjectName represents a qualified or unqualified object name type ObjectName struct { Name string + // Quote records the delimiters of Name's parts when they must be kept + // (see NameQuote). + Quote NameQuote } // TokenLiteral implements Node and returns the object name string. diff --git a/pkg/sql/ast/pool.go b/pkg/sql/ast/pool.go index 1ca40549..c15e4778 100644 --- a/pkg/sql/ast/pool.go +++ b/pkg/sql/ast/pool.go @@ -670,6 +670,7 @@ func PutCreateIndexStatement(stmt *CreateIndexStatement) { stmt.Columns[i].CollateQuoted = false stmt.Columns[i].Direction = "" stmt.Columns[i].NullsLast = false + stmt.Columns[i].ColumnQuote = 0 } stmt.Columns = stmt.Columns[:0] @@ -679,6 +680,8 @@ func PutCreateIndexStatement(stmt *CreateIndexStatement) { stmt.Name = "" stmt.Table = "" stmt.Using = "" + stmt.NameQuote = 0 + stmt.TableQuote = 0 createIndexStmtPool.Put(stmt) } @@ -736,6 +739,8 @@ func PutCreateMaterializedViewStatement(stmt *CreateMaterializedViewStatement) { stmt.Query = nil stmt.WithData = nil stmt.Tablespace = "" + stmt.NameQuote = 0 + stmt.ColumnQuotes = nil createMaterializedViewStmtPool.Put(stmt) } @@ -775,6 +780,7 @@ func PutDropStatement(stmt *DropStatement) { stmt.IfExists = false stmt.Names = stmt.Names[:0] stmt.CascadeType = "" + stmt.NameQuotes = nil dropStmtPool.Put(stmt) } @@ -796,6 +802,7 @@ func PutTruncateStatement(stmt *TruncateStatement) { stmt.RestartIdentity = false stmt.ContinueIdentity = false stmt.CascadeType = "" + stmt.TableQuotes = nil truncateStmtPool.Put(stmt) } @@ -869,6 +876,7 @@ func PutAlterStatement(stmt *AlterStatement) { stmt.Type = 0 stmt.Name = "" stmt.Operation = nil + stmt.NameQuote = 0 alterStmtPool.Put(stmt) } diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index b7f83437..1d134045 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -671,6 +671,8 @@ func PutCreateTableStatement(stmt *CreateTableStatement) { stmt.Constraints[i].Name = "" stmt.Constraints[i].Type = "" stmt.Constraints[i].Columns = stmt.Constraints[i].Columns[:0] + stmt.Constraints[i].NameQuote = 0 + stmt.Constraints[i].ColumnQuotes = nil } stmt.Constraints = stmt.Constraints[:0] diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index bcd19c68..72dd9fbc 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1615,9 +1615,9 @@ func (c *CreateIndexStatement) SQL() string { if c.IfNotExists { sb.WriteString("IF NOT EXISTS ") } - writeQualifiedName(sb, c.Name) + writeQualifiedNameQ(sb, c.Name, c.NameQuote) sb.WriteString(" ON ") - writeQualifiedName(sb, c.Table) + writeQualifiedNameQ(sb, c.Table, c.TableQuote) if c.Using != "" { sb.WriteString(" USING ") @@ -1629,7 +1629,7 @@ func (c *CreateIndexStatement) SQL() string { if i > 0 { sb.WriteString(", ") } - writeColumnName(sb, col.Column) + writeTableAliasQ(sb, col.Column, col.ColumnQuote) if col.Collate != "" { sb.WriteString(" COLLATE ") writeCollationName(sb, col.Collate, col.CollateQuoted) @@ -1683,7 +1683,7 @@ func (d *DropStatement) SQL() string { if d.IfExists { sb.WriteString("IF EXISTS ") } - writeQualifiedNameList(sb, d.Names) + writeQualifiedNameListQ(sb, d.Names, d.NameQuotes) if c := CascadeText(d.CascadeType); c != "" { sb.WriteString(" ") sb.WriteString(c) @@ -1700,7 +1700,7 @@ func (t *TruncateStatement) SQL() string { sb := getBuf() defer putBuf(sb) sb.WriteString("TRUNCATE TABLE ") - writeQualifiedNameList(sb, t.Tables) + writeQualifiedNameListQ(sb, t.Tables, t.TableQuotes) if t.RestartIdentity { sb.WriteString(" RESTART IDENTITY") } else if t.ContinueIdentity { @@ -1847,10 +1847,10 @@ func (c *CreateMaterializedViewStatement) SQL() string { if c.IfNotExists { sb.WriteString("IF NOT EXISTS ") } - writeQualifiedName(sb, c.Name) + writeQualifiedNameQ(sb, c.Name, c.NameQuote) if len(c.Columns) > 0 { sb.WriteString(" (") - writeNameList(sb, c.Columns) + writeNameListQ(sb, c.Columns, c.ColumnQuotes) sb.WriteString(")") } sb.WriteString(" AS ") @@ -2566,21 +2566,21 @@ func tableConstraintSQL(tc *TableConstraint) string { defer putBuf(sb) if tc.Name != "" { sb.WriteString("CONSTRAINT ") - writeConstraintName(sb, tc.Name) + writeConstraintNameQ(sb, tc.Name, tc.NameQuote) sb.WriteString(" ") } switch tc.Type { case "PRIMARY KEY": sb.WriteString("PRIMARY KEY (") - writeColumnNameList(sb, tc.Columns) + writeColumnNameListQ(sb, tc.Columns, tc.ColumnQuotes) sb.WriteString(")") case "UNIQUE": sb.WriteString("UNIQUE (") - writeColumnNameList(sb, tc.Columns) + writeColumnNameListQ(sb, tc.Columns, tc.ColumnQuotes) sb.WriteString(")") case "FOREIGN KEY": sb.WriteString("FOREIGN KEY (") - writeColumnNameList(sb, tc.Columns) + writeColumnNameListQ(sb, tc.Columns, tc.ColumnQuotes) sb.WriteString(") ") if tc.References != nil { sb.WriteString(referenceSQL(tc.References)) @@ -2601,10 +2601,10 @@ func referenceSQL(r *ReferenceDefinition) string { sb := getBuf() defer putBuf(sb) sb.WriteString("REFERENCES ") - writeQualifiedName(sb, r.Table) + writeQualifiedNameQ(sb, r.Table, r.TableQuote) if len(r.Columns) > 0 { sb.WriteString(" (") - writeColumnNameList(sb, r.Columns) + writeColumnNameListQ(sb, r.Columns, r.ColumnQuotes) sb.WriteString(")") } if a := ReferentialActionText(r.OnDelete); a != "" { diff --git a/pkg/sql/ast/types.go b/pkg/sql/ast/types.go index 5b8977f6..a41ca888 100644 --- a/pkg/sql/ast/types.go +++ b/pkg/sql/ast/types.go @@ -120,6 +120,9 @@ type Setting struct { // Ident represents an identifier in SQL (table name, column name, etc.) type Ident struct { Name string + // Quote records the delimiters of Name when they must be kept (see + // NameQuote). + Quote NameQuote } func (i *Ident) String() string { return i.Name } diff --git a/pkg/sql/parser/alter.go b/pkg/sql/parser/alter.go index 877dcea6..f578c355 100644 --- a/pkg/sql/parser/alter.go +++ b/pkg/sql/parser/alter.go @@ -44,6 +44,7 @@ func (p *Parser) parseAlterStatement() (*ast.AlterStatement, error) { // parseAlterTableStatement parses ALTER TABLE statements func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterStatement, error) { + stmt.NameQuote = p.currentNameQuote() stmt.Name = p.parseIdentAsString() op := &ast.AlterTableOperation{} @@ -75,7 +76,7 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS if ident == nil { return nil, p.expectedError("column name") } - op.ColumnName = &ast.Ident{Name: ident.Name} + op.ColumnName = &ast.Ident{Name: ident.Name, Quote: ident.Quotes} if p.matchType(models.TokenTypeCascade) { op.CascadeDrops = true } @@ -86,7 +87,7 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS if ident == nil { return nil, p.expectedError("constraint name") } - op.ConstraintName = &ast.Ident{Name: ident.Name} + op.ConstraintName = &ast.Ident{Name: ident.Name, Quote: ident.Quotes} if p.matchType(models.TokenTypeCascade) { op.CascadeDrops = true } @@ -105,7 +106,7 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS if ident == nil { return nil, p.expectedError("column name") } - op.ColumnName = &ast.Ident{Name: ident.Name} + op.ColumnName = &ast.Ident{Name: ident.Name, Quote: ident.Quotes} if !p.matchType(models.TokenTypeTo) { return nil, p.expectedError("TO") } @@ -114,7 +115,7 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS if newIdent == nil { return nil, p.expectedError("new column name") } - op.NewColumnName = &ast.Ident{Name: newIdent.Name} + op.NewColumnName = &ast.Ident{Name: newIdent.Name, Quote: newIdent.Quotes} } else { return nil, p.expectedError("TO or COLUMN") } @@ -129,7 +130,7 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS if ident == nil { return nil, p.expectedError("column name") } - op.ColumnName = &ast.Ident{Name: ident.Name} + op.ColumnName = &ast.Ident{Name: ident.Name, Quote: ident.Quotes} colDef, err := p.parseColumnDef() if err != nil { return nil, err diff --git a/pkg/sql/parser/ddl.go b/pkg/sql/parser/ddl.go index a6bcf63b..58f0d953 100644 --- a/pkg/sql/parser/ddl.go +++ b/pkg/sql/parser/ddl.go @@ -677,10 +677,11 @@ func (p *Parser) parseDropStatement() (*ast.DropStatement, error) { // Parse object names (can be comma-separated, supports schema.name qualification) for { - dropName, err := p.parseQualifiedName() + dropName, dropQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "object name") } + stmt.NameQuotes = nameQuoteList(stmt.NameQuotes, len(stmt.Names), dropQuote) stmt.Names = append(stmt.Names, dropName) if p.isType(models.TokenTypeComma) { @@ -714,10 +715,11 @@ func (p *Parser) parseTruncateStatement() (*ast.TruncateStatement, error) { // Parse table names (can be comma-separated, supports schema.table qualification) for { - truncTableName, err := p.parseQualifiedName() + truncTableName, truncQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "table name") } + stmt.TableQuotes = nameQuoteList(stmt.TableQuotes, len(stmt.Tables), truncQuote) stmt.Tables = append(stmt.Tables, truncTableName) if p.isType(models.TokenTypeComma) { diff --git a/pkg/sql/parser/ddl_columns.go b/pkg/sql/parser/ddl_columns.go index b70f716c..f0a52778 100644 --- a/pkg/sql/parser/ddl_columns.go +++ b/pkg/sql/parser/ddl_columns.go @@ -218,7 +218,8 @@ func (p *Parser) parseColumnConstraint() (*ast.ColumnConstraint, bool, error) { return nil, false, p.expectedError("table name after REFERENCES") } refDef := &ast.ReferenceDefinition{ - Table: p.currentToken.Token.Value, + Table: p.currentToken.Token.Value, + TableQuote: p.currentNameQuote(), } p.advance() @@ -229,6 +230,7 @@ func (p *Parser) parseColumnConstraint() (*ast.ColumnConstraint, bool, error) { if !p.isIdentifier() { return nil, false, p.expectedError("column name in REFERENCES") } + refDef.ColumnQuotes = nameQuoteList(refDef.ColumnQuotes, len(refDef.Columns), p.currentNameQuote()) refDef.Columns = append(refDef.Columns, p.currentToken.Token.Value) p.advance() @@ -355,6 +357,7 @@ func (p *Parser) parseTableConstraint() (*ast.TableConstraint, error) { if p.isIdentifier() && !p.isAnyType(models.TokenTypePrimary, models.TokenTypeForeign, models.TokenTypeUnique, models.TokenTypeCheck) { constraint.Name = p.currentToken.Token.Value + constraint.NameQuote = p.currentNameQuote() p.advance() } @@ -368,11 +371,12 @@ func (p *Parser) parseTableConstraint() (*ast.TableConstraint, error) { constraint.Type = "PRIMARY KEY" // Parse column list - columns, err := p.parseConstraintColumnList() + columns, columnQuotes, err := p.parseConstraintColumnListQuoted() if err != nil { return nil, err } constraint.Columns = columns + constraint.ColumnQuotes = columnQuotes return constraint, nil } @@ -386,11 +390,12 @@ func (p *Parser) parseTableConstraint() (*ast.TableConstraint, error) { constraint.Type = "FOREIGN KEY" // Parse column list - columns, err := p.parseConstraintColumnList() + columns, columnQuotes, err := p.parseConstraintColumnListQuoted() if err != nil { return nil, err } constraint.Columns = columns + constraint.ColumnQuotes = columnQuotes // Expect REFERENCES if !p.isType(models.TokenTypeReferences) { @@ -403,17 +408,19 @@ func (p *Parser) parseTableConstraint() (*ast.TableConstraint, error) { return nil, p.expectedError("table name after REFERENCES") } refDef := &ast.ReferenceDefinition{ - Table: p.currentToken.Token.Value, + Table: p.currentToken.Token.Value, + TableQuote: p.currentNameQuote(), } p.advance() // Parse optional referenced column list if p.isType(models.TokenTypeLParen) { - refColumns, err := p.parseConstraintColumnList() + refColumns, refColumnQuotes, err := p.parseConstraintColumnListQuoted() if err != nil { return nil, err } refDef.Columns = refColumns + refDef.ColumnQuotes = refColumnQuotes } // Parse optional ON DELETE/UPDATE @@ -428,11 +435,12 @@ func (p *Parser) parseTableConstraint() (*ast.TableConstraint, error) { constraint.Type = "UNIQUE" // Parse column list - columns, err := p.parseConstraintColumnList() + columns, columnQuotes, err := p.parseConstraintColumnListQuoted() if err != nil { return nil, err } constraint.Columns = columns + constraint.ColumnQuotes = columnQuotes return constraint, nil } @@ -464,16 +472,26 @@ func (p *Parser) parseTableConstraint() (*ast.TableConstraint, error) { // parseConstraintColumnList parses a parenthesized list of column names func (p *Parser) parseConstraintColumnList() ([]string, error) { + columns, _, err := p.parseConstraintColumnListQuoted() + return columns, err +} + +// parseConstraintColumnListQuoted parses a parenthesized list of column +// names and also returns how each was delimited (nil when none keeps its +// delimiters; see ast.NameQuote). +func (p *Parser) parseConstraintColumnListQuoted() ([]string, []ast.NameQuote, error) { if !p.isType(models.TokenTypeLParen) { - return nil, p.expectedError("( for column list") + return nil, nil, p.expectedError("( for column list") } p.advance() // Consume ( var columns []string + var quotes []ast.NameQuote for { if !p.isIdentifier() { - return nil, p.expectedError("column name") + return nil, nil, p.expectedError("column name") } + quotes = nameQuoteList(quotes, len(columns), p.currentNameQuote()) columns = append(columns, p.currentToken.Token.Value) p.advance() @@ -485,11 +503,11 @@ func (p *Parser) parseConstraintColumnList() ([]string, error) { } if !p.isType(models.TokenTypeRParen) { - return nil, p.expectedError(") after column list") + return nil, nil, p.expectedError(") after column list") } p.advance() // Consume ) - return columns, nil + return columns, quotes, nil } // parseTypeArgsString consumes a balanced parenthesised type-argument list diff --git a/pkg/sql/parser/ddl_index.go b/pkg/sql/parser/ddl_index.go index 20063f4c..f35fdf6f 100644 --- a/pkg/sql/parser/ddl_index.go +++ b/pkg/sql/parser/ddl_index.go @@ -43,11 +43,12 @@ func (p *Parser) parseCreateIndex(unique bool) (*ast.CreateIndexStatement, error } // Parse index name (supports schema.index qualification and double-quoted identifiers) - indexName, err := p.parseQualifiedName() + indexName, indexQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "index name") } stmt.Name = indexName + stmt.NameQuote = indexQuote // Expect ON if !p.isType(models.TokenTypeOn) { @@ -56,11 +57,12 @@ func (p *Parser) parseCreateIndex(unique bool) (*ast.CreateIndexStatement, error p.advance() // Consume ON // Parse table name (supports schema.table qualification and double-quoted identifiers) - indexTableName, err := p.parseQualifiedName() + indexTableName, indexTableQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "table name") } stmt.Table = indexTableName + stmt.TableQuote = indexTableQuote // Parse optional USING if p.isType(models.TokenTypeUsing) { @@ -85,6 +87,7 @@ func (p *Parser) parseCreateIndex(unique bool) (*ast.CreateIndexStatement, error return nil, p.expectedError("column name") } col.Column = p.currentToken.Token.Value + col.ColumnQuote = p.currentNameQuote() p.advance() // Parse optional COLLATE (SQLite, PostgreSQL): col COLLATE NOCASE diff --git a/pkg/sql/parser/ddl_view.go b/pkg/sql/parser/ddl_view.go index 31c222f6..4c1807d6 100644 --- a/pkg/sql/parser/ddl_view.go +++ b/pkg/sql/parser/ddl_view.go @@ -152,11 +152,12 @@ func (p *Parser) parseCreateMaterializedView() (*ast.CreateMaterializedViewState } // Parse view name (supports schema.view qualification and double-quoted identifiers) - matViewName, err := p.parseQualifiedName() + matViewName, matViewQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "materialized view name") } stmt.Name = matViewName + stmt.NameQuote = matViewQuote // Parse optional column list if p.isType(models.TokenTypeLParen) { @@ -165,6 +166,7 @@ func (p *Parser) parseCreateMaterializedView() (*ast.CreateMaterializedViewState if !p.isIdentifier() { return nil, p.expectedError("column name") } + stmt.ColumnQuotes = nameQuoteList(stmt.ColumnQuotes, len(stmt.Columns), p.currentNameQuote()) stmt.Columns = append(stmt.Columns, p.currentToken.Token.Value) p.advance() diff --git a/pkg/sql/parser/name_quote_test.go b/pkg/sql/parser/name_quote_test.go index 001d49c3..60cd5690 100644 --- a/pkg/sql/parser/name_quote_test.go +++ b/pkg/sql/parser/name_quote_test.go @@ -68,6 +68,15 @@ func TestNameQuote_SQLKeepsNeededQuotes(t *testing.T) { `MERGE INTO "T" "Tgt" USING "S" "Src" ON "Tgt".id = "Src".id WHEN MATCHED THEN UPDATE SET "Val" = "Src"."Val" WHEN NOT MATCHED THEN INSERT ("Id", b) VALUES (1, 2)`}, {"pg create table", keywords.DialectPostgreSQL, `CREATE TABLE "MyTable" ("ColA" int, "order" text, b int)`, `CREATE TABLE "MyTable" ("ColA" int, "order" text, b int)`}, {"pg create view", keywords.DialectPostgreSQL, `CREATE VIEW "V" ("A", b) AS SELECT 1, 2`, `CREATE VIEW "V" ("A", b) AS SELECT 1, 2`}, + {"pg create materialized view", keywords.DialectPostgreSQL, `CREATE MATERIALIZED VIEW "MV" ("A", b) AS SELECT 1, 2`, `CREATE MATERIALIZED VIEW "MV" ("A", b) AS SELECT 1, 2`}, + {"pg prisma table", keywords.DialectPostgreSQL, + `CREATE TABLE "Post" ("id" SERIAL NOT NULL, "authorId" INTEGER REFERENCES "User"("Id"), CONSTRAINT "Post_pkey" PRIMARY KEY ("id"), CONSTRAINT "Post_fk" FOREIGN KEY ("authorId") REFERENCES "User"("Id"))`, + `CREATE TABLE "Post" (id SERIAL NOT NULL, "authorId" INTEGER REFERENCES "User" ("Id"), CONSTRAINT "Post_pkey" PRIMARY KEY (id), CONSTRAINT "Post_fk" FOREIGN KEY ("authorId") REFERENCES "User" ("Id"))`}, + {"pg create index", keywords.DialectPostgreSQL, + `CREATE UNIQUE INDEX "User_email_key" ON "User"("Email", name)`, + `CREATE UNIQUE INDEX "User_email_key" ON "User" ("Email", name)`}, + {"pg drop", keywords.DialectPostgreSQL, `DROP TABLE "Post", other, "x y"`, `DROP TABLE "Post", other, "x y"`}, + {"pg truncate", keywords.DialectPostgreSQL, `TRUNCATE "Post", other`, `TRUNCATE TABLE "Post", other`}, // Keywords and niladic words keep their quotes in every dialect. {"pg keyword names", keywords.DialectPostgreSQL, `SELECT 1 AS "select", 2 AS "order", "user", "group" FROM "table"`, diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 78fa3303..aa6c62bb 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -1118,7 +1118,7 @@ func (p *Parser) parseObjectName() ast.ObjectName { if ident == nil { return ast.ObjectName{} } - return ast.ObjectName{Name: ident.Name} + return ast.ObjectName{Name: ident.Name, Quote: ident.Quotes} } // parseStringLiteral parses a string literal diff --git a/pkg/transpiler/quoting_test.go b/pkg/transpiler/quoting_test.go index 6be66ab3..f1889685 100644 --- a/pkg/transpiler/quoting_test.go +++ b/pkg/transpiler/quoting_test.go @@ -61,6 +61,13 @@ func TestTranspile_IdentifierDelimiters(t *testing.T) { `SELECT s."select"(1), "MyFunc"(2)`, "SELECT s.`select`(1), `MyFunc`(2)"}, {"pg to mysql create table", keywords.DialectPostgreSQL, keywords.DialectMySQL, `CREATE TABLE "MyTable" ("ColA" INT)`, "CREATE TABLE `MyTable` (`ColA` INT)"}, + {"pg to mysql prisma ddl", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `ALTER TABLE "Post" ADD CONSTRAINT "Post_fk" FOREIGN KEY ("authorId") REFERENCES "User"("Id")`, + "ALTER TABLE `Post` ADD CONSTRAINT `Post_fk` FOREIGN KEY (`authorId`) REFERENCES `User` (`Id`)"}, + {"pg to mysql index", keywords.DialectPostgreSQL, keywords.DialectMySQL, + `CREATE UNIQUE INDEX "User_email_key" ON "User"("Email")`, "CREATE UNIQUE INDEX `User_email_key` ON `User` (`Email`)"}, + {"pg to sqlserver drop", keywords.DialectPostgreSQL, keywords.DialectSQLServer, + `DROP TABLE "Post", "x y"`, "DROP TABLE [Post], [x y]"}, {"sqlserver to mysql qualified", keywords.DialectSQLServer, keywords.DialectMySQL, "SELECT [a b] FROM [dbo].[My Table]", "SELECT `a b` FROM dbo.`My Table`"}, {"oracle hash to pg", keywords.DialectOracle, keywords.DialectPostgreSQL, From 16804ffa3bc6aaa3ad72e29265ffb0d59bae6441 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:47:09 +0530 Subject: [PATCH 39/95] fix(ast,parser,formatter): keep needed quotes on window names Records NameQuote for named windows: the WINDOW clause definition name, the base window a spec refines (OVER ("W" ORDER BY y), "W2" AS ("W")), and a bare OVER "W" reference, so a window quoted for its case keeps its quotes on both the definition and the references. Transpile restyles them for the target dialect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_guard_test.go | 5 ++--- pkg/formatter/name_quote_test.go | 3 +++ pkg/formatter/render.go | 4 ++-- pkg/sql/ast/ast_clauses.go | 4 ++++ pkg/sql/ast/name_quote_restyle.go | 9 +++++++++ pkg/sql/ast/pool_statement_release.go | 2 ++ pkg/sql/ast/sql.go | 16 ++++++++-------- pkg/sql/parser/name_quote_test.go | 3 +++ pkg/sql/parser/select.go | 5 +++-- pkg/sql/parser/window.go | 3 ++- 10 files changed, 38 insertions(+), 16 deletions(-) diff --git a/cmd/gosqlx/cmd/format_guard_test.go b/cmd/gosqlx/cmd/format_guard_test.go index b2169460..3733414a 100644 --- a/cmd/gosqlx/cmd/format_guard_test.go +++ b/cmd/gosqlx/cmd/format_guard_test.go @@ -33,9 +33,8 @@ func TestFormatSQL_RefusesLostQuotes(t *testing.T) { dialect string in string }{ - // Names the AST does not record quoting for (window names, sequence - // names) still render bare, so the CLI must refuse. - {name: "window name mixed case", in: `SELECT SUM(x) OVER "W" FROM t WINDOW "W" AS (ORDER BY x)`}, + // Names the AST does not record quoting for (sequence names, for + // example) still render bare, so the CLI must refuse. {name: "sequence mixed case", in: `CREATE SEQUENCE "Seq"`}, } for _, tc := range tests { diff --git a/pkg/formatter/name_quote_test.go b/pkg/formatter/name_quote_test.go index 9d990083..3df21765 100644 --- a/pkg/formatter/name_quote_test.go +++ b/pkg/formatter/name_quote_test.go @@ -37,6 +37,9 @@ func TestNameQuote_FormatKeepsCaseQuotes(t *testing.T) { {"pg select", keywords.DialectPostgreSQL, `SELECT "C"."name" AS "CName" FROM "companies" AS "C" JOIN "Users" "U" ON "U".cid = "C".id`, `SELECT "C".name AS "CName" FROM companies "C" INNER JOIN "Users" "U" ON "U".cid = "C".id`}, + {"pg window names", keywords.DialectPostgreSQL, + `SELECT SUM(x) OVER "W" FROM t WINDOW "W" AS (ORDER BY x), "W2" AS ("W")`, + `SELECT SUM(x) OVER "W" FROM t WINDOW "W" AS (ORDER BY x), "W2" AS ("W")`}, {"pg cte", keywords.DialectPostgreSQL, `WITH "Cte"("Total") AS (SELECT 1) SELECT "Total" FROM "Cte"`, `WITH "Cte" ("Total") AS (SELECT 1) SELECT "Total" FROM "Cte"`}, diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index c0e99fd0..d9aa8769 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -303,7 +303,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(" ") wins := make([]string, len(s.Windows)) for i := range s.Windows { - wins[i] = ast.QuoteName(s.Windows[i].Name) + " AS (" + windowSpecSQL(&s.Windows[i]) + ")" + wins[i] = ast.QuoteNameWith(s.Windows[i].Name, f.nq(s.Windows[i].NameQuote)) + " AS (" + windowSpecSQL(&s.Windows[i]) + ")" } sb.WriteString(strings.Join(wins, ", ")) } @@ -1544,7 +1544,7 @@ func joinSQL(j *ast.JoinClause, f *nodeFormatter) string { func windowSpecSQL(w *ast.WindowSpec) string { var parts []string if w.BaseWindow != "" { - parts = append(parts, ast.QuoteName(w.BaseWindow)) + parts = append(parts, ast.QuoteNameWith(w.BaseWindow, w.BaseWindowQuote)) } if len(w.PartitionBy) > 0 { parts = append(parts, "PARTITION BY "+exprListSQL(w.PartitionBy)) diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index be271ba6..9d79ad0c 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -351,6 +351,10 @@ type WindowSpec struct { PartitionBy []Expression OrderBy []OrderByExpression FrameClause *WindowFrame + // NameQuote and BaseWindowQuote record the delimiters of Name and + // BaseWindow when they must be kept (see NameQuote). + NameQuote NameQuote + BaseWindowQuote NameQuote } func (w *WindowSpec) statementNode() {} diff --git a/pkg/sql/ast/name_quote_restyle.go b/pkg/sql/ast/name_quote_restyle.go index b2a4c5d8..3c864f35 100644 --- a/pkg/sql/ast/name_quote_restyle.go +++ b/pkg/sql/ast/name_quote_restyle.go @@ -49,6 +49,15 @@ func SetNameQuoteStyle(node Node, style rune, keepBareHash bool) { v.AliasQuote = restyle(v.AliasQuote) case *FunctionCall: v.NameQuote = restyle(v.NameQuote) + if v.Over != nil { + v.Over.NameQuote = restyle(v.Over.NameQuote) + v.Over.BaseWindowQuote = restyle(v.Over.BaseWindowQuote) + } + case *SelectStatement: + for i := range v.Windows { + v.Windows[i].NameQuote = restyle(v.Windows[i].NameQuote) + v.Windows[i].BaseWindowQuote = restyle(v.Windows[i].BaseWindowQuote) + } case *TableReference: v.NameQuote = restyle(v.NameQuote) v.AliasQuote = restyle(v.AliasQuote) diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index 1d134045..71bdf593 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -465,6 +465,8 @@ func releaseSelectStatement(stmt *SelectStatement, mode releaseMode) { } w.Name = "" w.BaseWindow = "" + w.NameQuote = 0 + w.BaseWindowQuote = 0 } stmt.Windows = stmt.Windows[:0] diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 72dd9fbc..41671759 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -844,16 +844,16 @@ func (f *FunctionCall) writeSQL(buf *bytes.Buffer) { if o.Name != "" && o.BaseWindow == "" && len(o.PartitionBy) == 0 && len(o.OrderBy) == 0 && o.FrameClause == nil { // Bare named-window reference: OVER w. buf.WriteString(" OVER ") - writeName(buf, o.Name) + writeNameQ(buf, o.Name, o.NameQuote) } else { // Hand-built specs may carry the referenced window in Name alongside // other parts; render it as the base window, as before. - base := o.BaseWindow + base, baseQuote := o.BaseWindow, o.BaseWindowQuote if base == "" { - base = o.Name + base, baseQuote = o.Name, o.NameQuote } buf.WriteString(" OVER (") - writeWindowSpec(buf, base, o) + writeWindowSpec(buf, base, baseQuote, o) buf.WriteByte(')') } } @@ -1310,9 +1310,9 @@ func (s *SelectStatement) writeSQL(buf *bytes.Buffer) { if i > 0 { buf.WriteString(", ") } - writeName(buf, s.Windows[i].Name) + writeNameQ(buf, s.Windows[i].Name, s.Windows[i].NameQuote) buf.WriteString(" AS (") - writeWindowSpec(buf, s.Windows[i].BaseWindow, &s.Windows[i]) + writeWindowSpec(buf, s.Windows[i].BaseWindow, s.Windows[i].BaseWindowQuote, &s.Windows[i]) buf.WriteByte(')') } } @@ -2336,7 +2336,7 @@ func writeJoin(buf *bytes.Buffer, j *JoinClause) { // space-separated base (existing) window name, PARTITION BY, ORDER BY and // frame parts that are present. w.Name is never written here; callers write // it outside the parentheses (OVER w, WINDOW w AS (...)). -func writeWindowSpec(buf *bytes.Buffer, base string, w *WindowSpec) { +func writeWindowSpec(buf *bytes.Buffer, base string, baseQuote NameQuote, w *WindowSpec) { first := true sep := func() { if !first { @@ -2346,7 +2346,7 @@ func writeWindowSpec(buf *bytes.Buffer, base string, w *WindowSpec) { } if base != "" { sep() - writeName(buf, base) + writeNameQ(buf, base, baseQuote) } if len(w.PartitionBy) > 0 { sep() diff --git a/pkg/sql/parser/name_quote_test.go b/pkg/sql/parser/name_quote_test.go index 60cd5690..d0efb834 100644 --- a/pkg/sql/parser/name_quote_test.go +++ b/pkg/sql/parser/name_quote_test.go @@ -54,6 +54,9 @@ func TestNameQuote_SQLKeepsNeededQuotes(t *testing.T) { {"pg table function and column aliases", keywords.DialectPostgreSQL, `SELECT "G"."Elem" FROM unnest(arr) AS "G"("Elem")`, `SELECT "G"."Elem" FROM unnest(arr) "G"("Elem")`}, + {"pg window names", keywords.DialectPostgreSQL, + `SELECT SUM(x) OVER "W", SUM(y) OVER ("W" ORDER BY y) FROM t WINDOW "W" AS (ORDER BY x), "W2" AS ("W")`, + `SELECT SUM(x) OVER "W", SUM(y) OVER ("W" ORDER BY y) FROM t WINDOW "W" AS (ORDER BY x), "W2" AS ("W")`}, {"pg join using", keywords.DialectPostgreSQL, `SELECT * FROM a JOIN b USING ("Id", id2)`, `SELECT * FROM a INNER JOIN b USING ("Id", id2)`}, {"pg insert", keywords.DialectPostgreSQL, `INSERT INTO "MyTable" ("ColA", colb) VALUES (1, 2)`, `INSERT INTO "MyTable" ("ColA", colb) VALUES (1, 2)`}, {"pg on conflict target", keywords.DialectPostgreSQL, diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index 22915ad6..3c8cb81d 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -240,6 +240,7 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { return nil, p.expectedError("window name after WINDOW") } winName := p.currentToken.Token.Value + winQuote := p.currentNameQuote() p.advance() if !p.isType(models.TokenTypeAs) { return nil, p.expectedError("AS after window name") @@ -253,9 +254,9 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { // is w2; keep it as the base window instead of dropping it, so it // renders as the standard w AS (w2). if winSpec.Name != "" { - winSpec.BaseWindow = winSpec.Name + winSpec.BaseWindow, winSpec.BaseWindowQuote = winSpec.Name, winSpec.NameQuote } - winSpec.Name = winName + winSpec.Name, winSpec.NameQuote = winName, winQuote selectStmt.Windows = append(selectStmt.Windows, *winSpec) if !p.isType(models.TokenTypeComma) { break diff --git a/pkg/sql/parser/window.go b/pkg/sql/parser/window.go index 7c0e30ee..825ebe36 100644 --- a/pkg/sql/parser/window.go +++ b/pkg/sql/parser/window.go @@ -357,7 +357,7 @@ func (p *Parser) parseWindowSpec() (*ast.WindowSpec, error) { // This must be checked before the '(' path so that e.g. OVER w is not // mistakenly rejected. if p.isIdentifier() { - spec := &ast.WindowSpec{Name: p.currentToken.Token.Value} + spec := &ast.WindowSpec{Name: p.currentToken.Token.Value, NameQuote: p.currentNameQuote()} p.advance() return spec, nil } @@ -375,6 +375,7 @@ func (p *Parser) parseWindowSpec() (*ast.WindowSpec, error) { // have dedicated token types, so a leading identifier can only be a name. if p.isIdentifier() { windowSpec.BaseWindow = p.currentToken.Token.Value + windowSpec.BaseWindowQuote = p.currentNameQuote() p.advance() } From af2f5398890a2b3933dcf3dd4d89b334905a7960 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 19:55:18 +0530 Subject: [PATCH 40/95] perf(parser,ast): keep name-quote recording off the hot path Identifier and FunctionCall keep their sizes (56 and 192 bytes) by placing the new NameQuote fields in existing padding, and the parser checks an inlinable nameQuoteRelevant guard before recording a name part, so bare names outside Oracle skip the call entirely. Parser benchmarks are back within noise of the base with unchanged allocations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/ast/ast.go | 13 +++++++------ pkg/sql/ast/ast_expressions.go | 5 +---- pkg/sql/parser/expressions_literal.go | 13 ++++++++++--- pkg/sql/parser/name_quote.go | 18 ++++++++++++++++-- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index d2775504..ab0e030d 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -133,6 +133,13 @@ type Identifier struct { // recorded in Quotes. It is set only for ANSI double quotes; QuoteStyle // records MySQL backticks and SQL Server brackets. Quoted bool + // Quotes records which parts of the reference (part 0 is Name, part 1 + // the last qualifier part, ...) were delimited in the source and must + // stay delimited, such as a name quoted for its letter case ("CreatedAt") + // or a keyword ("table"); see NameQuote. Its style, when set, takes + // precedence over QuoteStyle. It sits next to Quoted so the struct keeps + // its size. + Quotes NameQuote // QuoteStyle is the opening delimiter the source used for the quoted // part(s) of the reference: '"', '`' (MySQL, MariaDB, ClickHouse) or // '[' (SQL Server), and zero when no part was delimited. A reserved @@ -140,12 +147,6 @@ type Identifier struct { // quotes with the same delimiters, so a MySQL `from` is not turned into // the string "from". QuoteStyle rune - // Quotes records which parts of the reference (part 0 is Name, part 1 - // the last qualifier part, ...) were delimited in the source and must - // stay delimited, such as a name quoted for its letter case ("CreatedAt") - // or a keyword ("table"); see NameQuote. Its style, when set, takes - // precedence over QuoteStyle. - Quotes NameQuote } func (i *Identifier) expressionNode() {} diff --git a/pkg/sql/ast/ast_expressions.go b/pkg/sql/ast/ast_expressions.go index ab63c7f4..82db9c4f 100644 --- a/pkg/sql/ast/ast_expressions.go +++ b/pkg/sql/ast/ast_expressions.go @@ -143,6 +143,7 @@ type FunctionCall struct { Parameters []Expression // ClickHouse parametric aggregates: quantile(0.5)(x) — params before args Over *WindowSpec // For window functions Distinct bool + NameQuote NameQuote // Delimited parts of Name to keep, as in s."select"(1) (see NameQuote); shares Distinct's padding Filter Expression // WHERE clause for aggregate functions OrderBy []OrderByExpression // ORDER BY clause for aggregate functions (STRING_AGG, ARRAY_AGG, etc.) WithinGroup []OrderByExpression // ORDER BY clause for ordered-set aggregates (PERCENTILE_CONT, etc.) @@ -150,10 +151,6 @@ type FunctionCall struct { Pos models.Location // Source position of the function name (1-based line and column) Separator *LiteralValue // MySQL/MariaDB GROUP_CONCAT(... SEPARATOR 'str'); nil when absent Keep *KeepClause // Oracle KEEP (DENSE_RANK FIRST|LAST ORDER BY ...); nil when absent - // NameQuote records the parts of Name the source delimited and that - // must stay delimited, such as s."select" or "MySchema"."MyFunc" (see - // NameQuote). - NameQuote NameQuote } func (f *FunctionCall) expressionNode() {} diff --git a/pkg/sql/parser/expressions_literal.go b/pkg/sql/parser/expressions_literal.go index 5c1bd604..44e90fbb 100644 --- a/pkg/sql/parser/expressions_literal.go +++ b/pkg/sql/parser/expressions_literal.go @@ -199,7 +199,10 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { identPos := p.currentLocation() identName := p.currentToken.Token.Value identQuote := p.currentIdentQuote() - identQuotes := p.appendNamePartQuoted(0, identQuote) + var identQuotes ast.NameQuote + if p.nameQuoteRelevant(identQuote) { + identQuotes = p.appendNamePartQuoted(0, identQuote) + } p.advance() // Check for function call (identifier followed by parentheses) @@ -265,7 +268,9 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) { if q != 0 { ident.QuoteStyle = q } - ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) + if ident.Quotes != 0 || p.nameQuoteRelevant(q) { + ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) + } p.advance() // Multi-part reference: db.table.column (MySQL/MariaDB), @@ -627,7 +632,9 @@ func (p *Parser) parseMultiPartColumnRef(ident *ast.Identifier) error { if q != 0 { ident.QuoteStyle = q } - ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) + if ident.Quotes != 0 || p.nameQuoteRelevant(q) { + ident.Quotes = p.appendNamePartQuoted(ident.Quotes, q) + } n++ p.advance() default: diff --git a/pkg/sql/parser/name_quote.go b/pkg/sql/parser/name_quote.go index dd5d18a5..d1243cf4 100644 --- a/pkg/sql/parser/name_quote.go +++ b/pkg/sql/parser/name_quote.go @@ -112,6 +112,15 @@ func (p *Parser) appendNamePart(q ast.NameQuote) ast.NameQuote { return p.appendNamePartQuoted(q, p.currentIdentQuote()) } +// nameQuoteRelevant reports whether a name part with delimiter quote (0 for +// a bare part) can record anything in a NameQuote: it is delimited, or it is +// an Oracle name that may carry a bare '#'. Hot paths check it inline before +// calling appendNamePartQuoted, since appending an unmarked part to a zero +// NameQuote leaves it zero. +func (p *Parser) nameQuoteRelevant(quote rune) bool { + return quote != 0 || p.dialect == string(keywords.DialectOracle) +} + // appendNamePartQuoted is appendNamePart for a caller that already has the // current token's delimiter (p.currentIdentQuote()). func (p *Parser) appendNamePartQuoted(q ast.NameQuote, quote rune) ast.NameQuote { @@ -138,7 +147,10 @@ func (p *Parser) parseQualifiedNameQuoted() (string, ast.NameQuote, error) { } var parts [maxQualifiedNameParts]string parts[0] = p.currentToken.Token.Value - q := p.currentNameQuote() + var q ast.NameQuote + if quote := p.currentIdentQuote(); p.nameQuoteRelevant(quote) { + q = p.appendNamePartQuoted(0, quote) + } n := 1 p.advance() @@ -156,7 +168,9 @@ func (p *Parser) parseQualifiedNameQuoted() (string, ast.NameQuote, error) { return "", 0, p.expectedError("identifier after .") } parts[n] = p.currentToken.Token.Value - q = p.appendNamePart(q) + if quote := p.currentIdentQuote(); q != 0 || p.nameQuoteRelevant(quote) { + q = p.appendNamePartQuoted(q, quote) + } n++ p.advance() } From 283285ee3d4af5e2965b1f87c8dfe82913b8f712 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:02:44 +0530 Subject: [PATCH 41/95] fix(parser): keep ClickHouse projection/index columns and quoted literals intact - A column named "projection" with a parameterised type (projection FixedString(16)) was parsed as a PROJECTION element and moved after the columns. PROJECTION now also requires "(SELECT". - The ClickHouse INDEX lookahead matched a 'type' string literal (index String DEFAULT 'type') and then failed to parse. Only an unquoted TYPE word counts now. - parseTypeArgsString wrote string literals back without doubling embedded quotes, so a projection body with 'it''s' became unbalanced and the renderer dropped it, leaving "PROJECTION p", which reparses as a column. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/parser/ddl_column_ext_test.go | 3 +++ pkg/sql/parser/ddl_columns.go | 3 ++- pkg/sql/parser/ddl_table_constraints.go | 14 +++++++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/sql/parser/ddl_column_ext_test.go b/pkg/sql/parser/ddl_column_ext_test.go index ae0199ba..f5edb70d 100644 --- a/pkg/sql/parser/ddl_column_ext_test.go +++ b/pkg/sql/parser/ddl_column_ext_test.go @@ -282,6 +282,9 @@ func TestTableConstraints_AndIndexElements(t *testing.T) { {"clickhouse check parens then and", keywords.DialectClickHouse, "CREATE TABLE t (a UInt8, CONSTRAINT c CHECK (a > 0) AND a < 9) ENGINE = MergeTree ORDER BY a", []string{"CHECK"}, []string{"CONSTRAINT c CHECK ((a > 0) AND a < 9)"}}, {"clickhouse assume", keywords.DialectClickHouse, "CREATE TABLE t (a UInt8, CONSTRAINT c2 ASSUME a < 10) ENGINE = MergeTree ORDER BY a", []string{"ASSUME"}, []string{"CONSTRAINT c2 ASSUME a < 10"}}, {"clickhouse projection", keywords.DialectClickHouse, "CREATE TABLE t (a UInt8, b UInt8, PROJECTION p1 (SELECT a, sum(b) GROUP BY a)) ENGINE = MergeTree ORDER BY a", []string{"PROJECTION"}, []string{"PROJECTION p1 (SELECT a, sum(b) GROUP BY a)"}}, + {"clickhouse projection with quoted literal", keywords.DialectClickHouse, "CREATE TABLE t (a UInt8, b String, PROJECTION p (SELECT a WHERE b = 'it''s')) ENGINE = MergeTree ORDER BY a", []string{"PROJECTION"}, []string{"PROJECTION p (SELECT a WHERE b = 'it''s')"}}, + {"clickhouse column named projection", keywords.DialectClickHouse, "CREATE TABLE t (projection FixedString(16), a UInt8) ENGINE = MergeTree ORDER BY a", nil, nil}, + {"clickhouse column named index with a type literal", keywords.DialectClickHouse, "CREATE TABLE t (index String DEFAULT 'type', a UInt8) ENGINE = MergeTree ORDER BY a", nil, nil}, {"clickhouse columns named index and key", keywords.DialectClickHouse, "CREATE TABLE t (index UInt8, key String) ENGINE = MergeTree ORDER BY index", nil, nil}, {"sqlite column named key", keywords.DialectSQLite, "CREATE TABLE t (key TEXT, index_no INTEGER)", nil, nil}, {"mysql column named key with a type", keywords.DialectMySQL, "CREATE TABLE t (key INT, b INT)", nil, nil}, diff --git a/pkg/sql/parser/ddl_columns.go b/pkg/sql/parser/ddl_columns.go index 63a78169..80835c12 100644 --- a/pkg/sql/parser/ddl_columns.go +++ b/pkg/sql/parser/ddl_columns.go @@ -430,8 +430,9 @@ func (p *Parser) parseTypeArgsString() (string, error) { switch tok.Type { case models.TokenTypeString, models.TokenTypeSingleQuotedString, models.TokenTypeDoubleQuotedString: + // Double embedded quotes so 'it''s' is written back as one literal. buf.WriteByte('\'') - buf.WriteString(val) + buf.WriteString(strings.ReplaceAll(val, "'", "''")) buf.WriteByte('\'') prevWasIdent = false default: diff --git a/pkg/sql/parser/ddl_table_constraints.go b/pkg/sql/parser/ddl_table_constraints.go index d8b09400..f40f5b8e 100644 --- a/pkg/sql/parser/ddl_table_constraints.go +++ b/pkg/sql/parser/ddl_table_constraints.go @@ -68,7 +68,9 @@ func (p *Parser) isTableElementStart() bool { case p.isType(models.TokenTypeIndex) || p.isUnquotedWord("INDEX"): return p.clickHouseIndexAhead() case p.isUnquotedWord("PROJECTION"): - return p.peekAt(2).Token.Type == models.TokenTypeLParen + // PROJECTION name (SELECT ...); "projection FixedString(16)" is + // a column. + return p.peekAt(2).Token.Type == models.TokenTypeLParen && p.peekValueIs(3, "SELECT") } } return p.isType(models.TokenTypeLike) && @@ -101,8 +103,14 @@ func (p *Parser) clickHouseIndexAhead() bool { if t.Token.Type == models.TokenTypeUnknown && t.Token.Value == "" { return false } - if depth == 0 && equalsUpper(t.Token.Value, "TYPE") && i >= 2 { - return true + if depth == 0 && i >= 2 && t.Token.Quote == 0 && equalsUpper(t.Token.Value, "TYPE") { + switch t.Token.Type { + case models.TokenTypeString, models.TokenTypeSingleQuotedString, + models.TokenTypeDoubleQuotedString, models.TokenTypeDollarQuotedString: + // A 'type' literal (DEFAULT 'type') is not the TYPE keyword. + default: + return true + } } } } From 15e13c6564ccc1e0e338e6d1502a42c94cace601 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:07:32 +0530 Subject: [PATCH 42/95] fix(gosqlx,formatter): do not merge statements after a begin column; refuse moving --+ hints - SplitStatements treated a column named begin (CREATE TABLE t (begin INT), ALTER TABLE t ADD COLUMN begin int, MODIFY begin TIME) as a block opener. A later unrelated END word (SET end = 1, ORDER BY end) then closed it, so plain scripts in every dialect were silently merged into one segment. A BEGIN after "(", "," or a column-clause word is no longer a block. - JoinWithComments moved an Oracle --+ line hint inside a statement to the line above it, dropping the hint. --+ comments are now significant: kept when leading or trailing, refused (ErrUnplaceableComment) when inside a statement, since they cannot be written inline after a keyword. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/comments.go | 10 +++++++++- pkg/formatter/comments_test.go | 1 + pkg/gosqlx/splitter_proc.go | 30 +++++++++++++++++++++++++++++- pkg/gosqlx/splitter_proc_test.go | 6 ++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/pkg/formatter/comments.go b/pkg/formatter/comments.go index e7c383f4..2af2d3b8 100644 --- a/pkg/formatter/comments.go +++ b/pkg/formatter/comments.go @@ -354,9 +354,17 @@ func isExecutable(text string) bool { return strings.HasPrefix(text, "/*!") || strings.HasPrefix(text, "/*M!") } +// isLineHint reports whether text is an Oracle line hint, --+ ... . It is +// significant like /*+ */ but cannot be written inline after a keyword (it +// would swallow the rest of the line), so it is never placed, only refused +// when it sits inside a statement. +func isLineHint(text string) bool { return strings.HasPrefix(text, "--+") } + // isSignificantComment reports whether a comment affects how the database // runs the statement. -func isSignificantComment(text string) bool { return isHint(text) || isExecutable(text) } +func isSignificantComment(text string) bool { + return isHint(text) || isLineHint(text) || isExecutable(text) +} // insertHints writes hints after the first word of the rendered statement: // "SELECT a FROM t" becomes "SELECT /*+ H */ a FROM t". diff --git a/pkg/formatter/comments_test.go b/pkg/formatter/comments_test.go index d6ba768a..c5482279 100644 --- a/pkg/formatter/comments_test.go +++ b/pkg/formatter/comments_test.go @@ -102,6 +102,7 @@ func TestJoinWithComments_RefusesWhatItCannotPlace(t *testing.T) { "hint in union branch": "select 1 union select /*+ x */ 2", "executable in stmt": "select 1 /*!50000 + 1 */", "executable before stmt": "/*!40101 SET x = 1 */ select 1", + "oracle line hint": "select --+ FULL(t)\n a from t", } { t.Run(name, func(t *testing.T) { got, err := renderJoin(t, sql, layout) diff --git a/pkg/gosqlx/splitter_proc.go b/pkg/gosqlx/splitter_proc.go index 76d9981c..954a82e0 100644 --- a/pkg/gosqlx/splitter_proc.go +++ b/pkg/gosqlx/splitter_proc.go @@ -157,7 +157,7 @@ func (s *procState) word(src string, i, j int) int { s.stmtStart = true return j } - if !beginIsBlock(src, j) { + if beginNamesColumn(src, i) || !beginIsBlock(src, j) { return j } s.push(false) @@ -349,6 +349,34 @@ func beginIsBlock(src string, j int) bool { return true } +// beginNamesColumn reports whether the BEGIN starting at i is a column name +// rather than a block opener because of what precedes it: a column list +// ("(begin INT", ", begin DATE") or an ALTER TABLE column clause ("ADD +// COLUMN begin INT", "MODIFY begin TIME"). Treating such a BEGIN as a block +// would merge the following statements up to the next unrelated END. +func beginNamesColumn(src string, i int) bool { + k := i + for k > 0 && (src[k-1] == ' ' || src[k-1] == '\t' || src[k-1] == '\n' || src[k-1] == '\r') { + k-- + } + if k == 0 { + return false + } + if c := src[k-1]; c == ',' || c == '(' { + return true + } + e := k + for k > 0 && isWordByte(src[k-1]) { + k-- + } + var b [12]byte + switch string(foldWord(&b, src[k:e])) { + case "column", "add", "modify", "change", "alter", "drop", "rename": + return true + } + return false +} + // peekWord skips whitespace and comments from j and returns the word that // follows (empty when the next token is not a word), the byte found there // (0 at end of input) and the index just past the word. diff --git a/pkg/gosqlx/splitter_proc_test.go b/pkg/gosqlx/splitter_proc_test.go index b0776b70..b90ff1e2 100644 --- a/pkg/gosqlx/splitter_proc_test.go +++ b/pkg/gosqlx/splitter_proc_test.go @@ -96,6 +96,12 @@ END` {"qualified begin", "SELECT t.begin, @begin FROM t; SELECT 2", "sqlserver", []string{"SELECT t.begin, @begin FROM t", " SELECT 2"}}, {"case expression at top level", "SELECT CASE WHEN a THEN 1 END; SELECT 2", "", []string{"SELECT CASE WHEN a THEN 1 END", " SELECT 2"}}, {"stray end at top level", "END; SELECT 1", "postgresql", []string{"END", " SELECT 1"}}, + {"begin column in create table", "CREATE TABLE t (begin INT, x INT); INSERT INTO t VALUES (1, 2); UPDATE t SET end = 1; SELECT 3", "mysql", + []string{"CREATE TABLE t (begin INT, x INT)", " INSERT INTO t VALUES (1, 2)", " UPDATE t SET end = 1", " SELECT 3"}}, + {"begin column added by alter table", "ALTER TABLE t ADD COLUMN begin int; SELECT 1; SELECT y FROM t ORDER BY end; SELECT 2", "postgresql", + []string{"ALTER TABLE t ADD COLUMN begin int", " SELECT 1", " SELECT y FROM t ORDER BY end", " SELECT 2"}}, + {"begin column modified", "ALTER TABLE t MODIFY begin TIME NOT NULL; SELECT end FROM t; SELECT 2", "mysql", + []string{"ALTER TABLE t MODIFY begin TIME NOT NULL", " SELECT end FROM t", " SELECT 2"}}, // --- MySQL / MariaDB routines --- {"mysql routine without delimiter", mysqlRoutine + ";\nSELECT 9", "mysql", []string{mysqlRoutine, "\nSELECT 9"}}, From 7cd6403e2c1f3f58262ae3d75f47b9d74e644291 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:09:01 +0530 Subject: [PATCH 43/95] fix(tokenizer): decode ClickHouse \/ and \= escapes; refuse \e and \N ClickHouse drops the backslash before / and = (as it does before \\ \' \"), so 'http:\/\/x' is http://x. The Lenient mode kept the backslash, and formatting wrote 'http:\\/\\/x', a different value. Decode both, and refuse \e and \N, whose ClickHouse meanings (ESC, nothing) are not guessed. BigQuery shares the mode but rejects all four escapes, so no valid BigQuery literal changes. Also rewrite a test doc comment that gofmt had turned into curly quotes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/dialect/string_escape.go | 6 +++--- pkg/sql/tokenizer/string_escape.go | 14 +++++++++++--- pkg/sql/tokenizer/string_escape_strict_test.go | 12 +++++++++--- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/sql/dialect/string_escape.go b/pkg/sql/dialect/string_escape.go index 201e3d9f..effaa812 100644 --- a/pkg/sql/dialect/string_escape.go +++ b/pkg/sql/dialect/string_escape.go @@ -30,9 +30,9 @@ const ( // literal is better than silently changing its value on a format pass. StringEscapeStrict StringEscape = iota // StringEscapeLenient is ClickHouse and BigQuery: C-style backslash - // escapes (\b \f \n \r \t \0 \a \v \xHH) are decoded and an - // unrecognised sequence keeps its backslash, as ClickHouse does so that - // LIKE patterns and regular expressions survive. + // escapes (\b \f \n \r \t \0 \a \v \xHH, and \/ \= as in ClickHouse) + // are decoded and an unrecognised sequence keeps its backslash, as + // ClickHouse does so that LIKE patterns and regular expressions survive. StringEscapeLenient // StringEscapeNone means a backslash is an ordinary character // (PostgreSQL standard strings, Oracle, SQLite, SQL Server and the diff --git a/pkg/sql/tokenizer/string_escape.go b/pkg/sql/tokenizer/string_escape.go index 578691ff..c7105441 100644 --- a/pkg/sql/tokenizer/string_escape.go +++ b/pkg/sql/tokenizer/string_escape.go @@ -34,8 +34,9 @@ import ( // else, since its meaning depends on the dialect; // - MySQL decodes \0 \b \Z, keeps the backslash of \% and \_ and drops it // before any other character; -// - Lenient (ClickHouse, BigQuery) decodes \` \0 \a \b \f \v and ASCII -// \xHH and keeps the backslash before any other character; +// - Lenient (ClickHouse, BigQuery) decodes \` \/ \= \0 \a \b \f \v and +// ASCII \xHH, rejects \e and \N and keeps the backslash before any +// other character; // - DropUnknown (Snowflake, Redshift) decodes \b \f, three-digit octal, // \xhh and \uhhhh and drops the backslash before any other character. // @@ -94,8 +95,15 @@ func (t *Tokenizer) handleEscapeSequence(buf *bytes.Buffer) error { return t.decodeDropUnknownEscape(buf, r, size) default: // StringEscapeLenient switch r { - case '`': + case '`', '/', '=': + // ClickHouse drops the backslash before these, as it does + // before \\ \' and \" (BigQuery rejects \/ and \=, so + // decoding them cannot change a valid BigQuery literal). buf.WriteRune(r) + case 'e', 'N': + // ClickHouse reads \e as ESC and \N as nothing; refused rather + // than guessed, since BigQuery rejects both. + return t.invalidEscape(r) case '0': buf.WriteByte(0) case 'a': diff --git a/pkg/sql/tokenizer/string_escape_strict_test.go b/pkg/sql/tokenizer/string_escape_strict_test.go index c94f0dd0..00512102 100644 --- a/pkg/sql/tokenizer/string_escape_strict_test.go +++ b/pkg/sql/tokenizer/string_escape_strict_test.go @@ -165,10 +165,15 @@ func TestStringEscapeLenientClickHouse(t *testing.T) { {name: "hex", dialect: ch, sql: `SELECT '\x41'`, want: "A"}, {name: "not hex keeps backslash", dialect: ch, sql: `SELECT '\xzz'`, want: `\xzz`}, {name: "raw NUL byte", dialect: ch, sql: "SELECT 'a\x00b'", want: "a\x00b"}, + {name: "escaped slash", dialect: ch, sql: `SELECT 'http:\/\/x'`, want: "http://x"}, + {name: "escaped equals", dialect: ch, sql: `SELECT 'k\=v'`, want: "k=v"}, + {name: "escaped backtick", dialect: ch, sql: "SELECT 'a\\`b'", want: "a`b"}, {name: "hex ctrl-z", dialect: ch, sql: `SELECT '\x1a'`, wantErr: true}, {name: "hex above ASCII", dialect: ch, sql: `SELECT '\xff'`, wantErr: true}, {name: "raw ctrl-z byte", dialect: ch, sql: "SELECT 'a\x1ab'", wantErr: true}, + {name: "escape char", dialect: ch, sql: `SELECT 'a\eb'`, wantErr: true}, + {name: "null marker", dialect: ch, sql: `SELECT 'a\Nb'`, wantErr: true}, }) } @@ -195,9 +200,10 @@ func TestStringRawControlBytes(t *testing.T) { } } -// TestTripleQuotedOnlyBigQuery checks that ”' opens a triple-quoted string -// only in BigQuery. Elsewhere it is a string whose text starts with a quote, -// so ”'a' (the value 'a) tokenizes and ”'abc”' means 'abc', not abc. +// TestTripleQuotedOnlyBigQuery checks that three single quotes open a +// triple-quoted string only in BigQuery. Elsewhere they open a string whose +// text starts with a quote: the literal of three quotes, a and one quote is +// the value 'a, and the value of three quotes, abc, three quotes is 'abc'. func TestTripleQuotedOnlyBigQuery(t *testing.T) { type tc struct { dialect keywords.SQLDialect From f9dd1a14333eea893070c45088f85af64a1c92f4 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:09:15 +0530 Subject: [PATCH 44/95] fix(parser,formatter): end ALTER TABLE at the next statement; allow-list ALTER keyword fields ALTER TABLE now ends at a statement keyword as well as at ';' or EOF, so semicolon-less scripts (common in T-SQL) such as "ALTER TABLE t DROP COLUMN c SELECT 1" parse as two statements again instead of failing with "expected end of ALTER TABLE action". The formatter no longer writes the keyword fields of a hand-built ALTER TABLE verbatim (Modifiers, Modifier, Keyword, IndexKind, IndexKeyword, ConstraintKind): a value that is not a bare keyword phrase is dropped, and an ON CLUSTER target that is not a well-formed name or string is quoted, following the #632 allow-list policy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/render_alter.go | 71 +++++++++++++++++++++----- pkg/formatter/render_alter_ext_test.go | 36 +++++++++++++ pkg/sql/parser/alter_table.go | 5 +- pkg/sql/parser/alter_table_test.go | 41 +++++++++++++++ 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/pkg/formatter/render_alter.go b/pkg/formatter/render_alter.go index bc91d532..6a0b404a 100644 --- a/pkg/formatter/render_alter.go +++ b/pkg/formatter/render_alter.go @@ -37,8 +37,10 @@ func renderAlterStatement(a *ast.AlterStatement, opts ast.FormatOptions) string f := newNodeFormatter(opts) f.sb.WriteString(f.kw("ALTER")) for _, m := range a.Modifiers { - f.sb.WriteString(" ") - f.sb.WriteString(f.kw(m)) + if m = alterWordsText(m); m != "" { + f.sb.WriteString(" ") + f.sb.WriteString(f.kw(m)) + } } f.sb.WriteString(" ") f.sb.WriteString(f.kw("TABLE")) @@ -52,11 +54,11 @@ func renderAlterStatement(a *ast.AlterStatement, opts ast.FormatOptions) string } f.sb.WriteString(" ") f.sb.WriteString(ast.QuoteQualifiedName(a.Name)) - if a.OnCluster != "" { + if cluster := alterClusterText(a.OnCluster); cluster != "" { f.sb.WriteString(" ") f.sb.WriteString(f.kw("ON CLUSTER")) f.sb.WriteString(" ") - f.sb.WriteString(a.OnCluster) + f.sb.WriteString(cluster) } ops := a.Operations if len(ops) == 0 && a.Operation != nil { @@ -167,6 +169,7 @@ func writeAlterDropTail(f *nodeFormatter, op *ast.AlterTableOperation) { // writeAlterKeywordText renders a keyword phrase followed by text as written. func writeAlterKeywordText(f *nodeFormatter, keyword, text string) { + keyword = alterWordsText(keyword) if keyword != "" { f.sb.WriteString(f.kw(keyword)) } @@ -193,7 +196,7 @@ func writeAlterColumnSub(f *nodeFormatter, op *ast.AlterTableOperation) { case ast.AlterColumnDropNotNull: sb.WriteString(f.kw("DROP NOT NULL")) case ast.AlterColumnSetDataType: - kw := op.Keyword + kw := alterWordsText(op.Keyword) if kw == "" { kw = "TYPE" } @@ -236,8 +239,8 @@ func alterConstraintSQL(f *nodeFormatter, op *ast.AlterTableOperation) string { // types the ALTER TABLE parser does not produce are left empty. func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { sb := f.sb - if op.Modifier != "" { - sb.WriteString(f.kw(op.Modifier)) + if m := alterWordsText(op.Modifier); m != "" { + sb.WriteString(f.kw(m)) sb.WriteString(" ") } switch op.Type { @@ -265,13 +268,13 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { } case ast.AddIndex: sb.WriteString(f.kw("ADD")) - if op.IndexKind != "" { + if k := alterWordsText(op.IndexKind); k != "" { sb.WriteString(" ") - sb.WriteString(f.kw(op.IndexKind)) + sb.WriteString(f.kw(k)) } - if op.IndexKeyword != "" { + if k := alterWordsText(op.IndexKeyword); k != "" { sb.WriteString(" ") - sb.WriteString(f.kw(op.IndexKeyword)) + sb.WriteString(f.kw(k)) } if op.IfNotExists { sb.WriteString(" ") @@ -296,8 +299,8 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { writeAlterDropTail(f, op) case ast.DropConstraint: kind := "CONSTRAINT" - if op.ConstraintKind != "" { - kind = op.ConstraintKind + if k := alterWordsText(op.ConstraintKind); k != "" { + kind = k } sb.WriteString(f.kw("DROP " + kind)) if op.ConstraintKind == "PRIMARY KEY" { @@ -315,7 +318,7 @@ func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { sb.WriteString(strings.Join(names, ", ")) writeAlterDropTail(f, op) case ast.DropIndex: - kw := op.IndexKeyword + kw := alterWordsText(op.IndexKeyword) if kw == "" { kw = "INDEX" } @@ -443,3 +446,43 @@ func writeAlterMutationTail(f *nodeFormatter, op *ast.AlterTableOperation) { sb.WriteString(exprSQL(op.Where)) } } + +// alterWordsText returns s when it is one or more bare words (an ASCII letter +// or '_' followed by letters, digits and '_') separated by single spaces, and +// "" otherwise. The parser only stores such words in the keyword fields of an +// ALTER statement; a hand-built field holding anything else is dropped rather +// than written verbatim (#632). +func alterWordsText(s string) string { + if s == "" { + return "" + } + for _, w := range strings.Split(s, " ") { + if w == "" { + return "" + } + for i := 0; i < len(w); i++ { + c := w[i] + letter := c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') + if !letter && (i == 0 || c < '0' || c > '9') { + return "" + } + } + } + return s +} + +// alterClusterText returns the ClickHouse ON CLUSTER target c as the parser +// stores it (a single-quoted string, a "..." or `...` identifier, or a bare +// name) when it is well formed, and c as a quoted name otherwise (#632). +func alterClusterText(c string) string { + if c == "" { + return "" + } + for _, q := range []string{"'", `"`, "`"} { + if n := len(c); n >= 2 && c[:1] == q && c[n-1:] == q && + !strings.Contains(strings.ReplaceAll(c[1:n-1], q+q, ""), q) { + return c + } + } + return ast.QuoteQualifiedName(c) +} diff --git a/pkg/formatter/render_alter_ext_test.go b/pkg/formatter/render_alter_ext_test.go index b0776533..cd2a6726 100644 --- a/pkg/formatter/render_alter_ext_test.go +++ b/pkg/formatter/render_alter_ext_test.go @@ -15,6 +15,7 @@ package formatter_test import ( + "strings" "testing" "github.com/ajitpratap0/GoSQLX/pkg/formatter" @@ -218,3 +219,38 @@ func TestFormatStatement_AlterRolePolicyConnector(t *testing.T) { }) } } + +// TestFormatStatement_AlterHandBuiltKeywords_PR632 checks that the keyword +// fields of a hand-built ALTER TABLE are not written verbatim: a value that +// is not a bare keyword phrase is dropped, and an ON CLUSTER target that is +// not a well-formed name or string is quoted. +func TestFormatStatement_AlterHandBuiltKeywords_PR632(t *testing.T) { + const evil = "x; DROP TABLE y; --" + col := &ast.ColumnDef{Name: "a", Type: "INT"} + tests := []struct { + name string + stmt *ast.AlterStatement + }{ + {"other keyword", &ast.AlterStatement{Type: ast.AlterTypeTable, Name: "t", + Operation: &ast.AlterTableOperation{Type: ast.AlterTableOther, Keyword: evil}}}, + {"index kind", &ast.AlterStatement{Type: ast.AlterTypeTable, Name: "t", + Operation: &ast.AlterTableOperation{Type: ast.AddIndex, IndexKind: evil, IndexKeyword: "INDEX", IndexName: "i", Text: "(a)"}}}, + {"drop index keyword", &ast.AlterStatement{Type: ast.AlterTypeTable, Name: "t", + Operation: &ast.AlterTableOperation{Type: ast.DropIndex, IndexKeyword: evil, IndexName: "i"}}}, + {"constraint kind", &ast.AlterStatement{Type: ast.AlterTypeTable, Name: "t", + Operation: &ast.AlterTableOperation{Type: ast.DropConstraint, ConstraintKind: evil, ConstraintName: &ast.Ident{Name: "c"}}}}, + {"action modifier", &ast.AlterStatement{Type: ast.AlterTypeTable, Name: "t", + Operation: &ast.AlterTableOperation{Type: ast.AddColumn, Modifier: evil, ColumnDef: col}}}, + {"statement modifier and cluster", &ast.AlterStatement{Type: ast.AlterTypeTable, Name: "t", + Modifiers: []string{evil}, OnCluster: evil, + Operation: &ast.AlterTableOperation{Type: ast.DropColumn, ColumnName: &ast.Ident{Name: "a"}}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatter.FormatStatement(tc.stmt, ast.CompactStyle()) + if strings.Contains(got, "; DROP") && !strings.Contains(got, `"x; DROP TABLE y; --"`) { + t.Errorf("hand-built keyword written verbatim: %s", got) + } + }) + } +} diff --git a/pkg/sql/parser/alter_table.go b/pkg/sql/parser/alter_table.go index 228ad518..7e666e68 100644 --- a/pkg/sql/parser/alter_table.go +++ b/pkg/sql/parser/alter_table.go @@ -69,7 +69,10 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS p.advance() } stmt.Operation = stmt.Operations[0] - if !p.isType(models.TokenTypeSemicolon) && !p.isType(models.TokenTypeEOF) { + // The statement ends at ';', EOF, or (as for every other statement, and + // as T-SQL scripts without semicolons rely on) the next statement keyword. + if !p.isType(models.TokenTypeSemicolon) && !p.isType(models.TokenTypeEOF) && + !p.isStatementStartingKeyword() { return nil, p.expectedError("end of ALTER TABLE action") } return stmt, nil diff --git a/pkg/sql/parser/alter_table_test.go b/pkg/sql/parser/alter_table_test.go index d0b030d5..0195a6a3 100644 --- a/pkg/sql/parser/alter_table_test.go +++ b/pkg/sql/parser/alter_table_test.go @@ -814,3 +814,44 @@ func TestAlterConnector_OwnerWords(t *testing.T) { ast.ReleaseAST(tree) } } + +// TestAlterTable_NextStatementWithoutSemicolon checks that an ALTER TABLE +// followed by another statement without a ';' (common in T-SQL scripts) is +// two statements, as it was before ALTER TABLE accepted action lists, and +// that trailing junk is still an error. +func TestAlterTable_NextStatementWithoutSemicolon(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + want int // statement count; 0 means a parse error + }{ + {"sqlserver add then update", keywords.DialectSQLServer, "ALTER TABLE t ADD c INT UPDATE t SET c = 1", 2}, + {"sqlserver two alters", keywords.DialectSQLServer, "ALTER TABLE t ADD c INT\nALTER TABLE t ADD d INT", 2}, + {"postgresql drop then select", keywords.DialectPostgreSQL, "ALTER TABLE t DROP COLUMN c SELECT 1", 2}, + {"postgresql rename then select", keywords.DialectPostgreSQL, "ALTER TABLE t RENAME TO u SELECT 1", 2}, + {"trailing junk", keywords.DialectPostgreSQL, "ALTER TABLE t DROP COLUMN c junk", 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := ParseWithDialect(tc.sql, tc.dialect) + if tc.want == 0 { + if err == nil { + ast.ReleaseAST(tree) + t.Fatalf("ParseWithDialect(%q) succeeded, want an error", tc.sql) + } + return + } + if err != nil { + t.Fatalf("ParseWithDialect(%q): %v", tc.sql, err) + } + defer ast.ReleaseAST(tree) + if got := len(tree.Statements); got != tc.want { + t.Fatalf("got %d statements, want %d", got, tc.want) + } + if _, ok := tree.Statements[0].(*ast.AlterStatement); !ok { + t.Fatalf("statement 0 is %T, want *ast.AlterStatement", tree.Statements[0]) + } + }) + } +} From 0b788ebf818bd1f838b16e7fe3fab4c84f6a3ae0 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:12:33 +0530 Subject: [PATCH 45/95] fix(formatter,parser): keep ';' after verbatim statements; reject bare CREATE/DROP modifiers With AddSemicolon (ReadableStyle, gosqlx.Format AddSemicolon, the MCP format tool) FormatAST joined statements with a newline on the assumption that each renderer writes its own ';'. Statements kept as written (UtilityStatement and the stmtSQL fallback) do not, so the next statement was read back as part of their text: "CREATE SCHEMA s; CREATE TABLE s.t (a INT)" re-parsed as one CREATE SCHEMA. FormatAST now terminates any statement that lacks the ';'. createVerbatimKind and dropVerbatimKind accepted modifier words on their own, so "CREATE OR FUNCTION f", "CREATE REPLACE FUNCTION f", "DROP REPLACE x" and "DROP TEMP x" validated as statements. OR must now be followed by REPLACE or ALTER, and a DROP may only start with PUBLIC / GLOBAL / UNIQUE before an object kind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/render.go | 11 ++++++-- pkg/formatter/render_ddl_objects_test.go | 36 ++++++++++++++++++++++++ pkg/sql/parser/ddl_objects.go | 28 +++++++++++++----- pkg/sql/parser/ddl_objects_test.go | 8 ++++++ 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 5c580576..535a8ccc 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -84,11 +84,18 @@ func FormatAST(a *ast.AST, opts ast.FormatOptions) string { } parts := make([]string, 0, len(a.Statements)) for _, stmt := range a.Statements { - parts = append(parts, FormatStatement(stmt, opts)) + part := FormatStatement(stmt, opts) + // Renderers that keep a statement as written (UtilityStatement and + // the stmtSQL fallback) do not add the ";" themselves; without it the + // next statement would be read back as part of their text. + if opts.AddSemicolon && part != "" && !strings.HasSuffix(part, ";") { + part += ";" + } + parts = append(parts, part) } sep := ";\n" if opts.AddSemicolon { - // Each statement already ends with ";" + // Each statement now ends with ";" sep = "\n" } result := strings.Join(parts, sep) diff --git a/pkg/formatter/render_ddl_objects_test.go b/pkg/formatter/render_ddl_objects_test.go index fce662a6..8c80b43b 100644 --- a/pkg/formatter/render_ddl_objects_test.go +++ b/pkg/formatter/render_ddl_objects_test.go @@ -84,3 +84,39 @@ func TestFormatDDLObjects(t *testing.T) { }) } } + +// TestFormatAST_VerbatimStatementsKeepSeparators checks that statements kept +// as written (CREATE SCHEMA, DROP TYPE, GRANT, ...) still end with ";" when +// AddSemicolon is set, so the next statement is not read back as part of +// their text. +func TestFormatAST_VerbatimStatementsKeepSeparators(t *testing.T) { + tests := []struct { + dialect keywords.SQLDialect + sql string + }{ + {keywords.DialectPostgreSQL, "DROP TYPE mood; DROP SCHEMA s CASCADE"}, + {keywords.DialectPostgreSQL, "CREATE SCHEMA s; CREATE TABLE s.t (a INT)"}, + {keywords.DialectPostgreSQL, "CREATE FUNCTION f() RETURNS int AS $$ SELECT 1 $$ LANGUAGE sql; SELECT 1"}, + {keywords.DialectPostgreSQL, "GRANT SELECT ON t TO u; SELECT 1"}, + {keywords.DialectSnowflake, "CREATE STAGE s URL = 's3://b/p'; DROP WAREHOUSE w"}, + } + for _, tt := range tests { + t.Run(tt.sql, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tt.sql, tt.dialect) + if err != nil { + t.Fatalf("parse: %v", err) + } + defer ast.ReleaseAST(tree) + want := len(tree.Statements) + out := formatter.FormatAST(tree, ast.ReadableStyle()) + tree2, err := gosqlx.ParseWithDialect(out, tt.dialect) + if err != nil { + t.Fatalf("re-parse %q: %v", out, err) + } + defer ast.ReleaseAST(tree2) + if got := len(tree2.Statements); got != want { + t.Fatalf("readable output re-parses to %d statements, want %d:\n%s", got, want, out) + } + }) + } +} diff --git a/pkg/sql/parser/ddl_objects.go b/pkg/sql/parser/ddl_objects.go index 5ea31e0c..b6a9dad5 100644 --- a/pkg/sql/parser/ddl_objects.go +++ b/pkg/sql/parser/ddl_objects.go @@ -142,6 +142,16 @@ func (p *Parser) createVerbatimKind() string { options = true i += 2 continue + case "OR": + // OR REPLACE / OR ALTER only; anything else is left to the + // structured parser, which reports it. + if next := p.wordAt(i + 1); next != "REPLACE" && next != "ALTER" { + return "" + } + i++ + continue + case "REPLACE", "ALTER": + return "" // only valid after OR case "TABLE", "INDEX", "MATERIALIZED", "FULLTEXT", "SPATIAL", "BITMAP", "CLUSTERED", "NONCLUSTERED": return "" @@ -245,14 +255,18 @@ func (p *Parser) dropVerbatimKind() string { if w == "SEQUENCE" && (p.isMariaDB() || p.dialect == string(keywords.DialectMySQL)) { return "" } - if !verbatimCreateKinds[w] && !createModifierWords[w] { - return "" - } - if w == "PUBLIC" || w == "UNIQUE" || w == "GLOBAL" { - if next := p.wordAt(1); next != "" { - w += " " + next + if !verbatimCreateKinds[w] { + // Of the CREATE modifiers only PUBLIC / GLOBAL / UNIQUE may lead a + // DROP, and only before an object kind (DROP PUBLIC SYNONYM); DROP + // REPLACE x or DROP OR x is not a statement. + if w != "PUBLIC" && w != "UNIQUE" && w != "GLOBAL" { + return "" + } + next := p.wordAt(1) + if !verbatimCreateKinds[next] { + return "" } - return "DROP " + w + return "DROP " + w + " " + next } if next := p.wordAt(1); verbatimKindSuffixes[next] { w += " " + next diff --git a/pkg/sql/parser/ddl_objects_test.go b/pkg/sql/parser/ddl_objects_test.go index 775f7e82..53e02d95 100644 --- a/pkg/sql/parser/ddl_objects_test.go +++ b/pkg/sql/parser/ddl_objects_test.go @@ -312,6 +312,7 @@ func TestDDLObjects_VerbatimObjects(t *testing.T) { {keywords.DialectOracle, "DROP USER u CASCADE", "DROP USER"}, {keywords.DialectMySQL, "DROP FUNCTION IF EXISTS f", "DROP FUNCTION"}, {keywords.DialectOracle, "DROP PUBLIC SYNONYM e", "DROP PUBLIC SYNONYM"}, + {keywords.DialectSQLServer, "CREATE OR ALTER PROCEDURE p AS SELECT 1", "CREATE PROCEDURE"}, {keywords.DialectSnowflake, "DROP SCHEMA s", "DROP SCHEMA"}, } for _, tt := range tests { @@ -347,6 +348,13 @@ func TestDDLObjects_Errors(t *testing.T) { {keywords.DialectClickHouse, "CREATE TABLE t (a UInt8) ENGINE = MergeTree ORDER BY a ORDER BY a"}, {keywords.DialectPostgreSQL, "CREATE INDEX i ON t (a NULLS)"}, {keywords.DialectSnowflake, "CREATE SCHEMA a.b.c.d.e.f"}, + // Modifier words alone do not make a verbatim statement. + {keywords.DialectPostgreSQL, "CREATE OR FUNCTION f"}, + {keywords.DialectPostgreSQL, "CREATE REPLACE FUNCTION f"}, + {keywords.DialectPostgreSQL, "DROP REPLACE x"}, + {keywords.DialectPostgreSQL, "DROP OR x"}, + {keywords.DialectPostgreSQL, "DROP TEMP x"}, + {keywords.DialectPostgreSQL, "DROP UNIQUE x"}, } for _, tt := range tests { t.Run(tt.sql, func(t *testing.T) { From 8863acd5e10a2210a1bb6d9c473fbc9c9be02f58 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:13:50 +0530 Subject: [PATCH 46/95] fix(parser,ast): query tail after nested paren operand, looser left operand parens With INTERSECT binding tighter, the last operand of A UNION B INTERSECT (C) sits in a right-nested set operation, so a following ORDER BY / LIMIT was rejected with E4001 (accepted on the base commit). attachParenthesizedQueryTail now descends the unparenthesized right spine to find it. SetOperandNeedsParens now parenthesizes a left set-operation operand that binds looser than its parent under standard precedence, so a tree parsed with Oracle / SQLite equal precedence ((A UNION B) INTERSECT C) keeps its meaning when written for another dialect or by SQL(). A leading WITH clause still keeps the operand unparenthesized. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/formatter/setop_chain_test.go | 8 +++---- pkg/sql/ast/set_operation_render.go | 35 +++++++++++++++++++++++++---- pkg/sql/ast/table_factor_test.go | 6 ++++- pkg/sql/parser/select_set_ops.go | 24 +++++++++++++++++--- pkg/sql/parser/table_factor_test.go | 27 ++++++++++++++++++++++ 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/pkg/formatter/setop_chain_test.go b/pkg/formatter/setop_chain_test.go index 9420ce0d..e622ca0a 100644 --- a/pkg/formatter/setop_chain_test.go +++ b/pkg/formatter/setop_chain_test.go @@ -29,9 +29,9 @@ import ( // render(Left) + sep + op + sep + render(Right), recursing on both sides with // operands rendered without ";", and a single trailing ";" after the whole // top-level set operation when AddSemicolon is set (#602). An operand is -// wrapped in parentheses when its Parenthesized flag is set or, for Right, -// when it is a non-nil *SetOperation that standard precedence would not -// group as the tree does (#612). +// wrapped in parentheses when its Parenthesized flag is set or when it is a +// non-nil *SetOperation that standard precedence would not group as the +// tree does (#612); see ast.SetOperandNeedsParens. func setOpReference(s *ast.SetOperation, opts ast.FormatOptions) string { if s == nil { return "" @@ -80,7 +80,7 @@ func setOpReferenceBody(s *ast.SetOperation, opts ast.FormatOptions) string { } var sb strings.Builder if s.Left != nil { - sb.WriteString(operand(s.Left, s.LeftParenthesized)) + sb.WriteString(operand(s.Left, ast.SetOperandNeedsParens(s, false, opts.Dialect))) } sb.WriteString(sep + op + sep) if s.Right != nil { diff --git a/pkg/sql/ast/set_operation_render.go b/pkg/sql/ast/set_operation_render.go index 3fb8e5b6..2aa34638 100644 --- a/pkg/sql/ast/set_operation_render.go +++ b/pkg/sql/ast/set_operation_render.go @@ -41,15 +41,27 @@ func SetOperatorsEqualPrecedence(dialect string) bool { // under which INTERSECT binds tighter than UNION and EXCEPT. // // A left operand that is a set operation is written without parentheses -// unless they were in the source: the parser builds left-deep trees for -// operators of equal precedence, and parenthesizing the left operand would -// also move a WITH clause of its first SELECT inside the parentheses. +// unless they were in the source, or unless it binds looser than s under +// the precedence of the target dialect, as for a tree parsed with +// equal-precedence rules (Oracle, SQLite: (A UNION B) INTERSECT C) and +// written for another dialect. The parser builds left-deep trees for +// operators of equal precedence. A WITH clause on the first SELECT keeps the +// operand unparenthesized, since the parentheses would move it inside and +// narrow its scope. func SetOperandNeedsParens(s *SetOperation, right bool, dialect string) bool { if s == nil { return false } if !right { - return s.LeftParenthesized + if s.LeftParenthesized { + return true + } + child, ok := s.Left.(*SetOperation) + if !ok || child == nil || SetOperatorsEqualPrecedence(dialect) { + return false + } + return setOperatorPrecedence(child.Operator) < setOperatorPrecedence(s.Operator) && + !setOperationLeadsWithCTE(child) } if s.RightParenthesized { return true @@ -65,6 +77,21 @@ func SetOperandNeedsParens(s *SetOperation, right bool, dialect string) bool { return setOperatorPrecedence(child.Operator) <= setOperatorPrecedence(s.Operator) } +// setOperationLeadsWithCTE reports whether the first SELECT of s carries a +// WITH clause. +func setOperationLeadsWithCTE(s *SetOperation) bool { + var left Statement = s + for { + op, ok := left.(*SetOperation) + if !ok || op == nil { + break + } + left = op.Left + } + sel, ok := left.(*SelectStatement) + return ok && sel != nil && sel.With != nil +} + // SetOperationKeyword returns the operator of s as written by the renderers, // with its ALL / DISTINCT quantifier, and whether the operator is a known // one (see SetOperatorSQL). EXCEPT written as MINUS is written as MINUS diff --git a/pkg/sql/ast/table_factor_test.go b/pkg/sql/ast/table_factor_test.go index f044ace8..9ead058c 100644 --- a/pkg/sql/ast/table_factor_test.go +++ b/pkg/sql/ast/table_factor_test.go @@ -106,7 +106,11 @@ func TestSetOperandNeedsParens(t *testing.T) { {"right union under union", &SetOperation{Left: sel, Operator: "UNION", Right: union}, true, "", true}, {"right union under intersect", &SetOperation{Left: sel, Operator: "INTERSECT", Right: union}, true, "postgresql", true}, {"right parenthesized select", &SetOperation{Left: sel, Operator: "UNION", Right: sel, RightParenthesized: true}, true, "", true}, - {"left set operation", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel}, false, "", false}, + {"left looser set operation", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel}, false, "", true}, + {"left looser set operation oracle", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel}, false, "oracle", false}, + {"left looser set operation with CTE", &SetOperation{Left: &SetOperation{Left: &SelectStatement{With: &WithClause{}}, Operator: "UNION", Right: sel}, Operator: "INTERSECT", Right: sel}, false, "", false}, + {"left equal set operation", &SetOperation{Left: union, Operator: "EXCEPT", Right: sel}, false, "", false}, + {"left tighter set operation", &SetOperation{Left: inter, Operator: "UNION", Right: sel}, false, "", false}, {"left parenthesized", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel, LeftParenthesized: true}, false, "", true}, } for _, tc := range cases { diff --git a/pkg/sql/parser/select_set_ops.go b/pkg/sql/parser/select_set_ops.go index 21129efe..648360f9 100644 --- a/pkg/sql/parser/select_set_ops.go +++ b/pkg/sql/parser/select_set_ops.go @@ -268,11 +268,15 @@ func (p *Parser) isQueryTailStart() bool { // right operand, without WITH); anything else returns E4001. func (p *Parser) attachParenthesizedQueryTail(stmt ast.Statement) error { var target *ast.SelectStatement + var owner *ast.SetOperation switch s := stmt.(type) { case *ast.SelectStatement: target = s case *ast.SetOperation: - if right, ok := s.Right.(*ast.SelectStatement); ok && right.With == nil { + // The last operand can sit in a right-nested operation that bound + // tighter, as in A UNION B INTERSECT (C) ORDER BY x. + owner = lastSetOperation(s) + if right, ok := owner.Right.(*ast.SelectStatement); ok && right.With == nil { target = right } } @@ -284,8 +288,8 @@ func (p *Parser) attachParenthesizedQueryTail(stmt ast.Statement) error { "", ) } - if s, ok := stmt.(*ast.SetOperation); ok { - s.RightParenthesized = false + if owner != nil { + owner.RightParenthesized = false } var err error @@ -303,6 +307,20 @@ func (p *Parser) attachParenthesizedQueryTail(stmt ast.Statement) error { return nil } +// lastSetOperation returns the set operation that holds the last operand of +// s: s itself, or the innermost unparenthesized set operation on its right +// spine. +func lastSetOperation(s *ast.SetOperation) *ast.SetOperation { + for !s.RightParenthesized { + r, ok := s.Right.(*ast.SetOperation) + if !ok || r == nil { + break + } + s = r + } + return s +} + // isMinusSetOp returns true if the current token is the Snowflake / Oracle // MINUS keyword used as a set operator (synonym for EXCEPT). MINUS tokenizes // as an identifier; gate by dialect so that MINUS as a legitimate column diff --git a/pkg/sql/parser/table_factor_test.go b/pkg/sql/parser/table_factor_test.go index bee8d062..e0f4fd59 100644 --- a/pkg/sql/parser/table_factor_test.go +++ b/pkg/sql/parser/table_factor_test.go @@ -73,6 +73,33 @@ func setOpShape(t *testing.T, s ast.Statement) string { return "" } +// TestSetOperationTailAfterNestedParenOperand checks that a query-level +// ORDER BY / LIMIT after a parenthesized last operand is accepted when that +// operand sits in a right-nested operation that bound tighter. +func TestSetOperationTailAfterNestedParenOperand(t *testing.T) { + cases := []struct { + sql string + want string + }{ + {"SELECT a FROM t UNION SELECT a FROM u INTERSECT (SELECT a FROM v) ORDER BY a", + "SELECT a FROM t UNION SELECT a FROM u INTERSECT SELECT a FROM v ORDER BY a"}, + {"SELECT 1 UNION ALL SELECT 2 INTERSECT (SELECT 3) LIMIT 1", + "SELECT 1 UNION ALL SELECT 2 INTERSECT SELECT 3 LIMIT 1"}, + } + for _, tc := range cases { + t.Run(tc.sql, func(t *testing.T) { + stmt := parseOne(t, keywords.DialectPostgreSQL, tc.sql) + so, ok := stmt.(*ast.SetOperation) + if !ok { + t.Fatalf("want SetOperation, got %T", stmt) + } + if got := so.SQL(); got != tc.want { + t.Errorf("SQL() = %q, want %q", got, tc.want) + } + }) + } +} + func TestSetOperationPrecedence(t *testing.T) { cases := []struct { name string From 30a04dfeb95d6c72ceab72541b12e4fedb0b20b6 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:14:08 +0530 Subject: [PATCH 47/95] fix(parser,transform): keep cursor set operations whole, scope-aware DML alias DECLARE c CURSOR FOR SELECT ... UNION SELECT ... was split at the second SELECT into "... UNION;" and a separate SELECT, which the formatter wrote with a ';' after UNION. A statement-starting word right after UNION, EXCEPT, INTERSECT, ALL or DISTINCT now continues the utility statement. AddTableAlias on UPDATE / DELETE renamed column qualifiers inside subqueries that name the table in their own FROM (UPDATE t SET a = (SELECT max(t.b) FROM t)), turning them into correlated references to the outer alias. Qualifiers inside such a subquery are now left alone; correlated references are still renamed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/parser/utility_implicit_end.go | 7 +- pkg/sql/parser/utility_implicit_end_test.go | 4 ++ pkg/transform/tables_dml.go | 75 ++++++++++++++++++++- pkg/transform/tables_dml_test.go | 31 +++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/pkg/sql/parser/utility_implicit_end.go b/pkg/sql/parser/utility_implicit_end.go index ce87ca5d..301b9169 100644 --- a/pkg/sql/parser/utility_implicit_end.go +++ b/pkg/sql/parser/utility_implicit_end.go @@ -82,9 +82,10 @@ func (p *Parser) startsNewStatement(i, start int) bool { return false } switch p.utilityWord(i - 1) { - case "FOR", "AS": - // DECLARE c CURSOR FOR SELECT ..., CURSOR FOR UPDATE: the word - // continues the statement. + case "FOR", "AS", "UNION", "EXCEPT", "INTERSECT", "ALL", "DISTINCT": + // DECLARE c CURSOR FOR SELECT ..., CURSOR FOR UPDATE, and the + // set-operation operands of a cursor query (... UNION [ALL] + // SELECT ...): the word continues the statement. return false } if word == "WITH" { diff --git a/pkg/sql/parser/utility_implicit_end_test.go b/pkg/sql/parser/utility_implicit_end_test.go index b3962143..ebe03beb 100644 --- a/pkg/sql/parser/utility_implicit_end_test.go +++ b/pkg/sql/parser/utility_implicit_end_test.go @@ -65,6 +65,10 @@ func TestParser_UtilityStatementEndsAtNextStatement(t *testing.T) { "DECLARE @n INT = 5 SET @n = @n + 1 SELECT @n", []string{util, set, sel}}, {"declare cursor keeps its select", keywords.DialectSQLServer, "DECLARE c CURSOR FOR SELECT a FROM t", []string{util}}, + {"declare cursor keeps union operand", keywords.DialectSQLServer, + "DECLARE c CURSOR FOR SELECT a FROM t UNION SELECT b FROM u", []string{util}}, + {"declare cursor keeps union all operand", keywords.DialectSQLServer, + "DECLARE c CURSOR FOR SELECT a FROM t UNION ALL SELECT b FROM u EXCEPT SELECT c FROM v", []string{util}}, {"declare cursor for update", keywords.DialectSQLServer, "DECLARE c CURSOR FOR SELECT a FROM t FOR UPDATE OF a", []string{util}}, {"exec with recompile option", keywords.DialectSQLServer, diff --git a/pkg/transform/tables_dml.go b/pkg/transform/tables_dml.go index 816a8f42..f2272588 100644 --- a/pkg/transform/tables_dml.go +++ b/pkg/transform/tables_dml.go @@ -279,11 +279,15 @@ func (t *dmlAliasTargets) apply() { // qualifierRewriter returns a function that renames the qualifier of every // column that named the table (by name or previous alias) to the alias. +// Columns inside a subquery whose own FROM / JOIN introduces one of those +// names refer to the subquery's table, not the aliased one, and are left +// alone. func (t *dmlAliasTargets) qualifierRewriter() func(ast.Expression) ast.Expression { names := t.matched return func(expr ast.Expression) ast.Expression { + shadowed := shadowedIdentifiers(expr, names) return walkExpr(expr, func(e ast.Expression) ast.Expression { - if id, ok := e.(*ast.Identifier); ok && id.Table != "" { + if id, ok := e.(*ast.Identifier); ok && id.Table != "" && !shadowed[id] { for _, n := range names { if strings.EqualFold(id.Table, n) { id.Table = t.alias @@ -295,3 +299,72 @@ func (t *dmlAliasTargets) qualifierRewriter() func(ast.Expression) ast.Expressio }) } } + +// shadowedIdentifiers returns the identifiers of expr that sit inside a +// subquery whose FROM or JOIN list introduces one of names, so that a +// qualifier with that name resolves to the subquery's own table. +func shadowedIdentifiers(expr ast.Expression, names []string) map[*ast.Identifier]bool { + var shadowed map[*ast.Identifier]bool + mark := func(e ast.Expression) ast.Expression { + if id, ok := e.(*ast.Identifier); ok { + if shadowed == nil { + shadowed = make(map[*ast.Identifier]bool) + } + shadowed[id] = true + } + return e + } + walkExpr(expr, func(e ast.Expression) ast.Expression { + if sel := exprSubquery(e); sel != nil && selectIntroduces(sel, names) { + walkStmtExprs(sel, mark) + } + return e + }) + return shadowed +} + +// exprSubquery returns the SELECT held by a subquery-bearing expression. +func exprSubquery(e ast.Expression) *ast.SelectStatement { + var stmt ast.Statement + switch x := e.(type) { + case *ast.SubqueryExpression: + stmt = x.Subquery + case *ast.ExistsExpression: + stmt = x.Subquery + case *ast.InExpression: + stmt = x.Subquery + case *ast.AnyExpression: + stmt = x.Subquery + case *ast.AllExpression: + stmt = x.Subquery + case *ast.ArrayConstructorExpression: + stmt = x.Subquery + } + sel, _ := stmt.(*ast.SelectStatement) + return sel +} + +// selectIntroduces reports whether the FROM or JOIN list of sel introduces +// a table known by one of names. +func selectIntroduces(sel *ast.SelectStatement, names []string) bool { + match := func(ref *ast.TableReference) bool { + eff := effectiveRefName(ref) + for _, n := range names { + if eff != "" && strings.EqualFold(eff, n) { + return true + } + } + return false + } + for i := range sel.From { + if match(&sel.From[i]) { + return true + } + } + for i := range sel.Joins { + if match(&sel.Joins[i].Right) { + return true + } + } + return false +} diff --git a/pkg/transform/tables_dml_test.go b/pkg/transform/tables_dml_test.go index f8face14..c7f9f6be 100644 --- a/pkg/transform/tables_dml_test.go +++ b/pkg/transform/tables_dml_test.go @@ -170,3 +170,34 @@ func TestAddTableAlias_AmbiguousDML(t *testing.T) { }) } } + +// AddTableAlias leaves the qualifiers of a subquery that names the table in +// its own FROM alone: they refer to the subquery's table, and renaming them +// to the outer alias would turn them into correlated references. +func TestAddTableAlias_DMLSubqueryScope(t *testing.T) { + tests := []struct { + name string + sql string + want string + }{ + {"shadowing scalar subquery", "UPDATE t SET a = (SELECT max(t.b) FROM t)", + "UPDATE t x SET a = (SELECT max(t.b) FROM t)"}, + {"shadowing IN subquery", "DELETE FROM t WHERE t.id IN (SELECT t.id FROM t WHERE t.b = 1)", + "DELETE FROM t x WHERE x.id IN (SELECT t.id FROM t WHERE t.b = 1)"}, + {"correlated reference is renamed", "DELETE FROM t WHERE EXISTS (SELECT 1 FROM u WHERE u.id = t.id)", + "DELETE FROM t x WHERE EXISTS (SELECT 1 FROM u WHERE u.id = x.id)"}, + {"aliased inner table does not shadow", "DELETE FROM t WHERE EXISTS (SELECT 1 FROM t t2 WHERE t2.p = t.id)", + "DELETE FROM t x WHERE EXISTS (SELECT 1 FROM t t2 WHERE t2.p = x.id)"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := applyDialect(t, keywords.DialectPostgreSQL, tc.sql, AddTableAlias("t", "x")) + if err != nil { + t.Fatalf("apply: %v", err) + } + if got != tc.want { + t.Errorf("got %s\nwant %s", got, tc.want) + } + }) + } +} From 9ec87a4c7ead51fb4547f113373252d64fe2b9f8 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:16:03 +0530 Subject: [PATCH 48/95] fix(transpiler): render output for the target dialect SetOperation now keeps the MINUS spelling and renderers write it unless the target dialect lacks MINUS, but Transpile formatted with no dialect, so Snowflake / Oracle MINUS came out as MINUS for PostgreSQL and MySQL targets (the base commit wrote EXCEPT). Transpile now sets the target dialect on the format style, which also gives Oracle set-operation chains the grouping the target's precedence needs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/transpiler/transpiler.go | 8 +++++-- pkg/transpiler/transpiler_test.go | 36 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/pkg/transpiler/transpiler.go b/pkg/transpiler/transpiler.go index 42c3ab0c..a73546c1 100644 --- a/pkg/transpiler/transpiler.go +++ b/pkg/transpiler/transpiler.go @@ -75,10 +75,14 @@ func Transpile(sql string, from, to keywords.SQLDialect) (string, error) { } } - // Format each statement and join with ";\n". + // Format each statement for the target dialect and join with ";\n". The + // target decides dialect-specific spellings such as MINUS versus EXCEPT + // and set-operation grouping. + style := ast.CompactStyle() + style.Dialect = string(to) parts := make([]string, 0, len(tree.Statements)) for _, stmt := range tree.Statements { - parts = append(parts, formatter.FormatStatement(stmt, ast.CompactStyle())) + parts = append(parts, formatter.FormatStatement(stmt, style)) } return strings.Join(parts, ";\n"), nil } diff --git a/pkg/transpiler/transpiler_test.go b/pkg/transpiler/transpiler_test.go index 2b2a1e3d..ede8f2b3 100644 --- a/pkg/transpiler/transpiler_test.go +++ b/pkg/transpiler/transpiler_test.go @@ -66,3 +66,39 @@ func BenchmarkTranspile(b *testing.B) { } } } + +// TestTranspile_SetOperationsForTarget checks that set operations are +// written for the target dialect: MINUS becomes EXCEPT where the target has +// no MINUS, and an Oracle chain (all operators of equal precedence, grouped +// left to right) keeps its grouping under standard precedence. +func TestTranspile_SetOperationsForTarget(t *testing.T) { + tests := []struct { + from, to keywords.SQLDialect + sql string + want string + }{ + {keywords.DialectSnowflake, keywords.DialectPostgreSQL, + "SELECT a FROM t MINUS SELECT a FROM u", + "SELECT a FROM t EXCEPT SELECT a FROM u"}, + {keywords.DialectOracle, keywords.DialectMySQL, + "SELECT a FROM t MINUS SELECT a FROM u", + "SELECT a FROM t EXCEPT SELECT a FROM u"}, + {keywords.DialectOracle, keywords.DialectSnowflake, + "SELECT a FROM t MINUS SELECT a FROM u", + "SELECT a FROM t MINUS SELECT a FROM u"}, + {keywords.DialectOracle, keywords.DialectPostgreSQL, + "SELECT 1 FROM dual UNION SELECT 2 FROM dual INTERSECT SELECT 3 FROM dual", + "(SELECT 1 FROM dual UNION SELECT 2 FROM dual) INTERSECT SELECT 3 FROM dual"}, + } + for _, tc := range tests { + t.Run(string(tc.from)+"_to_"+string(tc.to), func(t *testing.T) { + got, err := transpiler.Transpile(tc.sql, tc.from, tc.to) + if err != nil { + t.Fatalf("Transpile: %v", err) + } + if got != tc.want { + t.Errorf("got %s\nwant %s", got, tc.want) + } + }) + } +} From 059b52bcf1220e6a2b59c73f5ccf4cf742a349d8 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:39:58 +0530 Subject: [PATCH 49/95] fix(transform): drop recorded quotes of names a table transform replaces ReplaceTable and AddTableAlias replace names and aliases in place, but the quote-preservation work records each name's delimiters next to it. The old name's marks then carried over: a renamed "Users" became the case-sensitive "Accounts", and an UPDATE alias U was written next to a still-quoted "U" qualifier, which names a different relation in PostgreSQL. Replaced names and qualifiers now drop the delimited-part marks (keeping the source's delimiter style), so the new name is written as the caller gave it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/transform/tables.go | 26 ++++++++++ pkg/transform/tables_dml.go | 30 +++++++---- pkg/transform/tables_quote_test.go | 81 ++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 pkg/transform/tables_quote_test.go diff --git a/pkg/transform/tables.go b/pkg/transform/tables.go index cd93c548..948d28e3 100644 --- a/pkg/transform/tables.go +++ b/pkg/transform/tables.go @@ -89,6 +89,7 @@ func AddTableAlias(tableName, alias string) Rule { for i := range s.From { if strings.EqualFold(s.From[i].Name, tableName) { s.From[i].Alias = alias + s.From[i].AliasQuote = replacedNameQuote(s.From[i].AliasQuote) } } return nil @@ -153,11 +154,35 @@ func renameTableRef(ref *ast.TableReference, old, new string) { return } ref.Name = new + ref.NameQuote = replacedNameQuote(ref.NameQuote) if ref.TableFunc != nil && strings.EqualFold(ref.TableFunc.Name, old) { ref.TableFunc.Name = new + ref.TableFunc.NameQuote = replacedNameQuote(ref.TableFunc.NameQuote) } } +// replacedNameQuote returns the NameQuote for a name that a transform has +// replaced: the new name is written as the caller gave it (quoted only when +// its content needs it), so the delimited parts recorded for the old name +// are dropped; the source's delimiter style is kept. +func replacedNameQuote(q ast.NameQuote) ast.NameQuote { + if !q.HasStyle() { + return 0 + } + return ast.NameQuoteOf(q.Style()) +} + +// replacedQualifierQuote returns the NameQuote of a column reference whose +// table qualifier a transform has replaced: the column's own mark (part 0) +// is kept and the marks of the old qualifier's parts are dropped. +func replacedQualifierQuote(q ast.NameQuote) ast.NameQuote { + out := replacedNameQuote(q).WithBareHash(q.BareHash()) + if q.Part(0) { + out = out.WithPart(0) + } + return out +} + // walkExpr recursively walks all expression types and applies fn to each expression. // It handles all known AST expression types that can contain sub-expressions, // and recurses into subqueries (SelectStatements) where applicable. @@ -359,6 +384,7 @@ func replaceTableInExpr(expr ast.Expression, old, new string) ast.Expression { if id, ok := e.(*ast.Identifier); ok { if strings.EqualFold(id.Table, old) { id.Table = new + id.Quotes = replacedQualifierQuote(id.Quotes) } } return e diff --git a/pkg/transform/tables_dml.go b/pkg/transform/tables_dml.go index f2272588..6bb0dbea 100644 --- a/pkg/transform/tables_dml.go +++ b/pkg/transform/tables_dml.go @@ -31,7 +31,7 @@ import ( // replaceTableInUpdate renames old to new in every table reference and // column qualifier of an UPDATE statement. func replaceTableInUpdate(s *ast.UpdateStatement, old, new string) { - renameTableName(&s.TableName, old, new) + renameTableName(&s.TableName, &s.TableNameQuote, old, new) replaceTableInDMLTargets(&s.DMLTargetFields, old, new) replaceTableInFrom(s.From, old, new) for i := range s.Assignments { @@ -47,7 +47,7 @@ func replaceTableInUpdate(s *ast.UpdateStatement, old, new string) { // replaceTableInDelete renames old to new in every table reference and // column qualifier of a DELETE statement. func replaceTableInDelete(s *ast.DeleteStatement, old, new string) { - renameTableName(&s.TableName, old, new) + renameTableName(&s.TableName, &s.TableNameQuote, old, new) replaceTableInDMLTargets(&s.DMLTargetFields, old, new) replaceTableInFrom(s.From, old, new) replaceTableInFrom(s.Using, old, new) @@ -70,9 +70,10 @@ func replaceTableInDMLTargets(d *ast.DMLTargetFields, old, new string) { } } -func renameTableName(name *string, old, new string) { +func renameTableName(name *string, quote *ast.NameQuote, old, new string) { if strings.EqualFold(*name, old) { *name = new + *quote = replacedNameQuote(*quote) } } @@ -80,7 +81,7 @@ func renameTableName(name *string, old, new string) { // statement and points the column qualifiers at the alias. func addAliasToUpdate(s *ast.UpdateStatement, tableName, alias string) error { refs := dmlAliasTargets{tableName: tableName, alias: alias} - refs.name(&s.TableName, &s.Alias) + refs.name(&s.TableName, &s.Alias, &s.AliasQuote) refs.list(s.Tables) refs.list(s.From) refs.joins(s.Joins) @@ -113,7 +114,7 @@ func addAliasToDelete(s *ast.DeleteStatement, tableName, alias string) error { // an alias of their own. refs.list(s.From) } else { - refs.name(&s.TableName, &s.Alias) + refs.name(&s.TableName, &s.Alias, &s.AliasQuote) refs.list(s.Tables) } refs.list(s.Using) @@ -187,11 +188,15 @@ func renameDeleteTargets(s *ast.DeleteStatement, names []string, alias string) { if matches(s.TableName) { s.TableName = alias s.Alias = "" + s.TableNameQuote = replacedNameQuote(s.TableNameQuote) + s.AliasQuote = 0 } for i := range s.Tables { if matches(s.Tables[i].Name) { s.Tables[i].Name = alias s.Tables[i].Alias = "" + s.Tables[i].NameQuote = replacedNameQuote(s.Tables[i].NameQuote) + s.Tables[i].AliasQuote = 0 } } } @@ -227,21 +232,22 @@ type dmlAliasTargets struct { type aliasSlot struct { alias *string + quote *ast.NameQuote prev string } -func (t *dmlAliasTargets) record(alias *string) { +func (t *dmlAliasTargets) record(alias *string, quote *ast.NameQuote) { t.matched = append(t.matched, t.tableName) if *alias != "" { t.matched = append(t.matched, *alias) } - t.sets = append(t.sets, aliasSlot{alias: alias, prev: *alias}) + t.sets = append(t.sets, aliasSlot{alias: alias, quote: quote, prev: *alias}) } // name records the statement's own target. -func (t *dmlAliasTargets) name(name, alias *string) { +func (t *dmlAliasTargets) name(name, alias *string, quote *ast.NameQuote) { if strings.EqualFold(*name, t.tableName) { - t.record(alias) + t.record(alias, quote) } } @@ -249,7 +255,7 @@ func (t *dmlAliasTargets) name(name, alias *string) { func (t *dmlAliasTargets) list(refs []ast.TableReference) { for i := range refs { if strings.EqualFold(refs[i].Name, t.tableName) { - t.record(&refs[i].Alias) + t.record(&refs[i].Alias, &refs[i].AliasQuote) } } } @@ -258,7 +264,7 @@ func (t *dmlAliasTargets) list(refs []ast.TableReference) { func (t *dmlAliasTargets) joins(joins []ast.JoinClause) { for i := range joins { if strings.EqualFold(joins[i].Right.Name, t.tableName) { - t.record(&joins[i].Right.Alias) + t.record(&joins[i].Right.Alias, &joins[i].Right.AliasQuote) } } } @@ -274,6 +280,7 @@ func (t *dmlAliasTargets) check() error { func (t *dmlAliasTargets) apply() { for _, s := range t.sets { *s.alias = t.alias + *s.quote = replacedNameQuote(*s.quote) } } @@ -291,6 +298,7 @@ func (t *dmlAliasTargets) qualifierRewriter() func(ast.Expression) ast.Expressio for _, n := range names { if strings.EqualFold(id.Table, n) { id.Table = t.alias + id.Quotes = replacedQualifierQuote(id.Quotes) break } } diff --git a/pkg/transform/tables_quote_test.go b/pkg/transform/tables_quote_test.go new file mode 100644 index 00000000..26ac42d9 --- /dev/null +++ b/pkg/transform/tables_quote_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transform + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestTableTransforms_DropReplacedNameQuotes checks that a name a transform +// replaces is written as the caller gave it: the delimiters recorded for the +// old name (or old alias) must not carry over, or a renamed "Users" would +// become a case-sensitive "Accounts" and a new alias U would not match a +// still-quoted "U" qualifier. +func TestTableTransforms_DropReplacedNameQuotes(t *testing.T) { + tests := []struct { + name string + sql string + rule Rule + want string + }{ + { + name: "replace quoted table and qualifiers", + sql: `SELECT "Users".id, "Users"."Name" FROM "Users" WHERE "Users".x = 1`, + rule: ReplaceTable("Users", "Accounts"), + want: `SELECT Accounts.id, Accounts."Name" FROM Accounts WHERE Accounts.x = 1`, + }, + { + name: "alias replaces quoted alias", + sql: `SELECT * FROM t AS "X"`, + rule: AddTableAlias("t", "Y"), + want: `SELECT * FROM t Y`, + }, + { + name: "update replace", + sql: `UPDATE "Users" SET a = 1 WHERE "Users".b = 2`, + rule: ReplaceTable("Users", "Accounts"), + want: `UPDATE Accounts SET a = 1 WHERE Accounts.b = 2`, + }, + { + name: "update alias rewrites quoted qualifier", + sql: `UPDATE "Users" SET a = 1 WHERE "Users".b = 2`, + rule: AddTableAlias("Users", "U"), + want: `UPDATE "Users" U SET a = 1 WHERE U.b = 2`, + }, + { + name: "delete replace", + sql: `DELETE FROM "Users" WHERE "Users".b = 2`, + rule: ReplaceTable("Users", "Accounts"), + want: `DELETE FROM Accounts WHERE Accounts.b = 2`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := ParseSQLWithDialect(tc.sql, keywords.DialectPostgreSQL) + if err != nil { + t.Fatalf("parse %q: %v", tc.sql, err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, tc.rule); err != nil { + t.Fatalf("apply: %v", err) + } + if got := FormatSQL(stmt); got != tc.want { + t.Errorf("got %s\nwant %s", got, tc.want) + } + }) + } +} From 6c81d7ca87921e56c6dab5faa9c85c1a304da708 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:44:24 +0530 Subject: [PATCH 50/95] chore(lint): remove dead helpers left by the dialect merges; scope errcheck Remove functions the parallel fix packages left unused (rejectGlobalOutput, writeNameList, TableModifiers.isEmpty, Parser.parseObjectName), simplify a single-case switch, and exclude ast.textWriter's WriteString/WriteByte from errcheck: the interface is implemented only by *bytes.Buffer and *strings.Builder, whose writes never fail (errcheck already exempts those concrete methods). golangci-lint: 229 issues -> 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- .golangci.yml | 4 ++++ cmd/gosqlx/cmd/global_flags.go | 9 --------- pkg/sql/ast/name_quote.go | 2 +- pkg/sql/ast/quote_names.go | 10 ---------- pkg/sql/ast/quote_names_test.go | 4 ++-- pkg/sql/ast/table_factor.go | 6 ------ pkg/sql/parser/expressions_primary_ext.go | 6 +----- pkg/sql/parser/parser.go | 9 --------- 8 files changed, 8 insertions(+), 42 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index a7366360..0c0d04e3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -22,6 +22,10 @@ linters: - fmt.Print - fmt.Printf - fmt.Println + # ast.textWriter is implemented only by *bytes.Buffer and + # *strings.Builder, whose writes never return an error. + - (github.com/ajitpratap0/GoSQLX/pkg/sql/ast.textWriter).WriteString + - (github.com/ajitpratap0/GoSQLX/pkg/sql/ast.textWriter).WriteByte staticcheck: checks: - "all" diff --git a/cmd/gosqlx/cmd/global_flags.go b/cmd/gosqlx/cmd/global_flags.go index 915109e1..ef65ab11 100644 --- a/cmd/gosqlx/cmd/global_flags.go +++ b/cmd/gosqlx/cmd/global_flags.go @@ -46,15 +46,6 @@ func rejectGlobalFormat(cmd *cobra.Command) error { return nil } -// rejectGlobalOutput fails when -o/--output was set on a command that only -// writes to stdout. -func rejectGlobalOutput(cmd *cobra.Command) error { - if outputFile != "" { - return fmt.Errorf("%s does not support -o/--output", cmd.CommandPath()) - } - return nil -} - // outputSink returns the writer a command should print its result to and a // finish function to call once it has printed. With -o the result is buffered // and written to the file by finish; otherwise it goes straight to stdout. diff --git a/pkg/sql/ast/name_quote.go b/pkg/sql/ast/name_quote.go index b906f842..9fd02f32 100644 --- a/pkg/sql/ast/name_quote.go +++ b/pkg/sql/ast/name_quote.go @@ -276,7 +276,7 @@ func writeTableAliasQ(buf *bytes.Buffer, name string, q NameQuote) { writeNamePart(buf, name, partDelimited(name, q.Part(0), tableAliasNeedsQuoting(name), q), q.Style()) } -// writeNameListQ writes names separated by ", " like writeNameList; qs, when +// writeNameListQ writes names separated by ", ", quoting each as needed; qs, when // not nil, holds the NameQuote of each name by index. func writeNameListQ(buf *bytes.Buffer, names []string, qs []NameQuote) { for i, n := range names { diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index 46c26075..3f0a06f1 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -258,16 +258,6 @@ func writeTableAlias(buf *bytes.Buffer, name string) { buf.WriteString(name) } -// writeNameList writes names separated by ", ", quoting each as needed. -func writeNameList(buf *bytes.Buffer, names []string) { - for i, n := range names { - if i > 0 { - buf.WriteString(", ") - } - writeName(buf, n) - } -} - // writeQualifiedName writes a possibly dotted table or view name, quoting // each dot-separated part on its own as needed. A dot is always treated as a // part separator (the same convention as writeDottedIdentifier). An empty diff --git a/pkg/sql/ast/quote_names_test.go b/pkg/sql/ast/quote_names_test.go index 6f7566ad..9e9ae1b7 100644 --- a/pkg/sql/ast/quote_names_test.go +++ b/pkg/sql/ast/quote_names_test.go @@ -85,9 +85,9 @@ func TestQuoteQualifiedName(t *testing.T) { func TestWriteNameList(t *testing.T) { var buf bytes.Buffer - writeNameList(&buf, []string{"a", "select", "a b", "Mixed"}) + writeNameListQ(&buf, []string{"a", "select", "a b", "Mixed"}, nil) if got, want := buf.String(), `a, "select", "a b", Mixed`; got != want { - t.Errorf("writeNameList = %s, want %s", got, want) + t.Errorf("writeNameListQ = %s, want %s", got, want) } } diff --git a/pkg/sql/ast/table_factor.go b/pkg/sql/ast/table_factor.go index 993fcbd4..7a5e1c8c 100644 --- a/pkg/sql/ast/table_factor.go +++ b/pkg/sql/ast/table_factor.go @@ -147,12 +147,6 @@ type OpenJSONColumn struct { AsJSON bool } -// isEmpty reports whether m carries no modifier. -func (m *TableModifiers) isEmpty() bool { - return m == nil || (m.TableSample == nil && m.Flashback == nil && m.DBLink == "" && len(m.Partitions) == 0 && len(m.IndexHints) == 0 && - m.IndexedBy == "" && !m.NotIndexed && len(m.WithSchema) == 0) -} - // tableSample returns the sampling clause of t, or nil. func (t *TableReference) tableSample() *TableSampleClause { if t.Modifiers == nil { diff --git a/pkg/sql/parser/expressions_primary_ext.go b/pkg/sql/parser/expressions_primary_ext.go index befb8af5..1f865013 100644 --- a/pkg/sql/parser/expressions_primary_ext.go +++ b/pkg/sql/parser/expressions_primary_ext.go @@ -57,11 +57,7 @@ func (p *Parser) isKeywordFunctionName() bool { // isFunctionKeywordWord reports whether a generic keyword may be written as // a function name. func isFunctionKeywordWord(w string) bool { - switch len(w) { - case 4: - return strings.EqualFold(w, "HASH") - } - return false + return len(w) == 4 && strings.EqualFold(w, "HASH") } // parseKeywordFunctionCall parses a keyword-named function call; the current diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index eb5fdcb6..786e426e 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -1119,15 +1119,6 @@ func (p *Parser) parseBareWordAsString() string { return val } -// parseObjectName parses an object name (possibly qualified) -func (p *Parser) parseObjectName() ast.ObjectName { - ident := p.parseIdent() - if ident == nil { - return ast.ObjectName{} - } - return ast.ObjectName{Name: ident.Name, Quote: ident.Quotes} -} - // parseStringLiteral parses a string literal func (p *Parser) parseStringLiteral() string { if !p.isStringLiteral() { From 82976e55ac80302a4962a410f08c9c74ce249650 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 20:58:53 +0530 Subject: [PATCH 51/95] fix(ast,parser,formatter): AlterStatement.SQL, keep quote kinds in type args, record more name quotes - ast.AlterStatement now has SQL(); the ALTER writers move from the formatter into ast (AlterSQL with DDLStyle) so SQL() and the formatter share one implementation. tree.SQL() no longer drops ALTER statements. - parseTypeArgsString keeps the delimiter kind of double-quoted and backticked words (ClickHouse PROJECTION bodies, engine and type arguments), no longer turning "x" into the string 'x'; the verbatim-text checks accept delimited identifiers. - Record the delimiters of ALTER ROLE/POLICY/CONNECTOR names, ALTER TABLE index names, index names inside table constraints, partition definition names and INHERITS parents. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/format_guard_test.go | 8 +- pkg/formatter/alter_sql_w3_test.go | 191 +++++ pkg/formatter/render_alter.go | 475 +----------- pkg/formatter/render_alter_misc.go | 203 ----- pkg/formatter/type_args_quoting_test.go | 63 ++ pkg/sql/ast/alter.go | 35 +- pkg/sql/ast/ast_clauses.go | 26 +- pkg/sql/ast/ast_columns.go | 28 + pkg/sql/ast/ddl_objects.go | 3 + pkg/sql/ast/pool_statement_release.go | 1 + pkg/sql/ast/quote_names.go | 8 + .../ast/render_ddl_name_quoting_632_test.go | 2 +- pkg/sql/ast/sql_alter.go | 703 ++++++++++++++++++ pkg/sql/ast/sql_columns.go | 8 +- pkg/sql/ast/sql_ddl_objects.go | 4 +- pkg/sql/parser/alter.go | 24 +- pkg/sql/parser/alter_table.go | 4 +- pkg/sql/parser/ddl.go | 1 + pkg/sql/parser/ddl_columns.go | 42 +- pkg/sql/parser/ddl_objects.go | 3 +- pkg/sql/parser/ddl_table_constraints.go | 5 + pkg/sql/parser/parser.go | 10 + 22 files changed, 1121 insertions(+), 726 deletions(-) create mode 100644 pkg/formatter/alter_sql_w3_test.go delete mode 100644 pkg/formatter/render_alter_misc.go create mode 100644 pkg/formatter/type_args_quoting_test.go create mode 100644 pkg/sql/ast/sql_alter.go diff --git a/cmd/gosqlx/cmd/format_guard_test.go b/cmd/gosqlx/cmd/format_guard_test.go index 5291398a..b9f1d726 100644 --- a/cmd/gosqlx/cmd/format_guard_test.go +++ b/cmd/gosqlx/cmd/format_guard_test.go @@ -33,10 +33,11 @@ func TestFormatSQL_RefusesLostQuotes(t *testing.T) { dialect string in string }{ - // Names the AST does not record quoting for (ALTER POLICY names, for + // Names the AST does not record quoting for (a TABLESPACE name, for // example) still render bare, so the CLI must refuse. (CREATE - // SEQUENCE is now kept as written, so its quotes survive.) - {name: "policy mixed case", in: `ALTER POLICY "Pol" ON t RENAME TO q`}, + // SEQUENCE and ALTER POLICY names are recorded, so their quotes + // survive.) + {name: "tablespace mixed case", in: `CREATE TABLE t (a INT) TABLESPACE "Ts"`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -66,6 +67,7 @@ func TestFormatSQL_AllowsHarmlessQuotes(t *testing.T) { want string }{ {name: "lowercase quoted", in: `SELECT "users_id" FROM "users"`, want: "SELECT users_id FROM users"}, + {name: "alter policy mixed case", in: `ALTER POLICY "Pol" ON "Tbl" RENAME TO "Q"`, want: `ALTER POLICY "Pol" ON "Tbl" RENAME TO "Q"`}, {name: "alter plain", in: "ALTER TABLE t ADD COLUMN c INT", want: "ALTER TABLE t ADD COLUMN c INT"}, // Reserved words keep their quotes on render (#632), so these format. {name: "alter reserved word", in: `ALTER TABLE t ADD COLUMN "order" INT`, want: `ALTER TABLE t ADD COLUMN "order" INT`}, diff --git a/pkg/formatter/alter_sql_w3_test.go b/pkg/formatter/alter_sql_w3_test.go new file mode 100644 index 00000000..42843811 --- /dev/null +++ b/pkg/formatter/alter_sql_w3_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestAlterStatementSQL checks that AST.SQL() and Statement.SQL() write every +// ALTER statement (they used to write nothing for an *ast.AlterStatement) and +// that the output matches the formatter's compact rendering and re-parses to +// the same text. +func TestAlterStatementSQL(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + in string + want string + }{ + {"add column", keywords.DialectPostgreSQL, + "ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0", "ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0"}, + {"several actions", keywords.DialectPostgreSQL, + "ALTER TABLE IF EXISTS ONLY s.t DROP COLUMN a CASCADE, ALTER COLUMN b SET NOT NULL, RENAME TO u", + "ALTER TABLE IF EXISTS ONLY s.t DROP COLUMN a CASCADE, ALTER COLUMN b SET NOT NULL, RENAME TO u"}, + {"set data type using", keywords.DialectPostgreSQL, + "ALTER TABLE t ALTER COLUMN c TYPE BIGINT USING CAST(c AS BIGINT)", "ALTER TABLE t ALTER COLUMN c TYPE BIGINT USING CAST(c AS BIGINT)"}, + {"quoted names", keywords.DialectPostgreSQL, + `ALTER TABLE "Post" RENAME COLUMN "Old" TO "New"`, `ALTER TABLE "Post" RENAME COLUMN "Old" TO "New"`}, + {"mysql add index", keywords.DialectMySQL, + "ALTER TABLE t ADD UNIQUE KEY idx (a, b)", "ALTER TABLE t ADD UNIQUE KEY idx (a, b)"}, + {"clickhouse on cluster", keywords.DialectClickHouse, + "ALTER TABLE t ON CLUSTER c DELETE WHERE x = 1", "ALTER TABLE t ON CLUSTER c DELETE WHERE x = 1"}, + {"alter policy", keywords.DialectGeneric, + "ALTER POLICY p ON s.t TO a, b USING (x = 1) WITH CHECK (y = 2)", "ALTER POLICY p ON s.t TO a, b USING (x = 1) WITH CHECK (y = 2)"}, + {"alter connector url", keywords.DialectGeneric, + "ALTER CONNECTOR c SET URL 'it''s'", "ALTER CONNECTOR c SET URL 'it''s'"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.in, tc.dialect) + if err != nil { + t.Fatalf("parse %q: %v", tc.in, err) + } + defer ast.ReleaseAST(tree) + if len(tree.Statements) != 1 { + t.Fatalf("got %d statements, want 1", len(tree.Statements)) + } + alter, ok := tree.Statements[0].(*ast.AlterStatement) + if !ok { + t.Fatalf("got %T, want *ast.AlterStatement", tree.Statements[0]) + } + if got := alter.SQL(); got != tc.want { + t.Errorf("Statement.SQL() = %q, want %q", got, tc.want) + } + if got := tree.SQL(); got != tc.want { + t.Errorf("AST.SQL() = %q, want %q", got, tc.want) + } + style := ast.CompactStyle() + style.Dialect = string(tc.dialect) + if got := formatter.FormatStatement(alter, style); got != tc.want { + t.Errorf("FormatStatement = %q, want %q", got, tc.want) + } + // The SQL() text parses back to the same statement. + again, err := gosqlx.ParseWithDialect(tc.want, tc.dialect) + if err != nil { + t.Fatalf("re-parse %q: %v", tc.want, err) + } + defer ast.ReleaseAST(again) + if got := again.SQL(); got != tc.want { + t.Errorf("re-parsed SQL() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestAlterStatementSQL_Bare checks that an ALTER statement without an +// operation, and a nil one, never write more than the ALTER keyword. +func TestAlterStatementSQL_Bare(t *testing.T) { + var nilStmt *ast.AlterStatement + if got := nilStmt.SQL(); got != "" { + t.Errorf("nil SQL() = %q, want empty", got) + } + role := &ast.AlterStatement{Type: ast.AlterTypeRole, Name: "r"} + if got := role.SQL(); got != "ALTER" { + t.Errorf("operation-less SQL() = %q, want ALTER", got) + } +} + +// TestAlterNameQuotes_Recorded checks that the delimiters of names in ALTER +// ROLE / POLICY / CONNECTOR, index names, partition names, INHERITS and index +// names inside constraints are recorded and written back when they carry +// meaning (case, keywords). +func TestAlterNameQuotes_Recorded(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + in string + want string + }{ + {"policy", keywords.DialectGeneric, + `ALTER POLICY "Pol" ON "Sch"."Tbl" TO "Admin", other`, `ALTER POLICY "Pol" ON "Sch"."Tbl" TO "Admin", other`}, + {"policy rename", keywords.DialectGeneric, + `ALTER POLICY "Pol" ON t RENAME TO "Pol2"`, `ALTER POLICY "Pol" ON t RENAME TO "Pol2"`}, + {"connector", keywords.DialectGeneric, + `ALTER CONNECTOR "Conn" SET OWNER USER "Bob"`, `ALTER CONNECTOR "Conn" SET OWNER USER "Bob"`}, + {"add index name", keywords.DialectMySQL, + "ALTER TABLE t ADD INDEX `Select` (a)", "ALTER TABLE t ADD INDEX `Select` (a)"}, + {"drop index name", keywords.DialectMySQL, + "ALTER TABLE t DROP INDEX `Select`", "ALTER TABLE t DROP INDEX `Select`"}, + {"constraint index name", keywords.DialectMySQL, + "CREATE TABLE t (a INT, KEY `Select` (a), UNIQUE KEY `Order` (a))", + "CREATE TABLE t (a INT, KEY `Select` (a), UNIQUE KEY `Order` (a))"}, + {"fulltext index name", keywords.DialectMySQL, + "CREATE TABLE t (a TEXT, FULLTEXT KEY `Select` (a))", "CREATE TABLE t (a TEXT, FULLTEXT KEY `Select` (a))"}, + {"clickhouse index name", keywords.DialectClickHouse, + "CREATE TABLE t (a Int32, INDEX `Select` a TYPE minmax GRANULARITY 1) ENGINE = MergeTree ORDER BY a", + "CREATE TABLE t (a Int32, INDEX `Select` a TYPE minmax GRANULARITY 1) ENGINE=MergeTree ORDER BY a"}, + {"inherits", keywords.DialectPostgreSQL, + `CREATE TABLE t (a INT) INHERITS ("Parent", "Sch"."Other")`, `CREATE TABLE t (a INT) INHERITS ("Parent", "Sch"."Other")`}, + {"partition name", keywords.DialectMySQL, + "CREATE TABLE t (a INT) PARTITION BY RANGE (a) (PARTITION `Select` VALUES LESS THAN (10))", + "CREATE TABLE t (a INT) PARTITION BY RANGE (a) (PARTITION `Select` VALUES LESS THAN (10))"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.in, tc.dialect) + if err != nil { + t.Fatalf("parse %q: %v", tc.in, err) + } + defer ast.ReleaseAST(tree) + style := ast.CompactStyle() + style.Dialect = string(tc.dialect) + got := formatter.FormatStatement(tree.Statements[0], style) + if got != tc.want { + t.Errorf("FormatStatement\n got: %s\nwant: %s", got, tc.want) + } + if sql := tree.SQL(); sql != tc.want { + t.Errorf("SQL()\n got: %s\nwant: %s", sql, tc.want) + } + }) + } +} + +// TestAlterRoleNameQuotes checks that a hand-built ALTER ROLE statement +// writes the delimiters recorded on its names, and only those. +func TestAlterRoleNameQuotes(t *testing.T) { + q := ast.QuotedName('"') + tests := []struct { + name string + stmt *ast.AlterStatement + want string + }{ + {"rename keeps case quotes", &ast.AlterStatement{ + Type: ast.AlterTypeRole, Name: "MyRole", NameQuote: q, + Operation: &ast.AlterRoleOperation{Type: ast.RenameRole, NewName: "NewRole", NewNameQuote: q}, + }, `ALTER ROLE "MyRole" RENAME TO "NewRole"`}, + {"plain names stay bare", &ast.AlterStatement{ + Type: ast.AlterTypeRole, Name: "myrole", + Operation: &ast.AlterRoleOperation{Type: ast.RenameRole, NewName: "newrole"}, + }, `ALTER ROLE myrole RENAME TO newrole`}, + {"add member", &ast.AlterStatement{ + Type: ast.AlterTypeRole, Name: "r", + Operation: &ast.AlterRoleOperation{Type: ast.AddMember, MemberName: "User", MemberNameQuote: q}, + }, `ALTER ROLE r ADD MEMBER "User"`}, + } + for _, tc := range tests { + if got := tc.stmt.SQL(); got != tc.want { + t.Errorf("%s: SQL() = %q, want %q", tc.name, got, tc.want) + } + if got := formatter.FormatStatement(tc.stmt, ast.CompactStyle()); got != tc.want { + t.Errorf("%s: FormatStatement = %q, want %q", tc.name, got, tc.want) + } + } +} diff --git a/pkg/formatter/render_alter.go b/pkg/formatter/render_alter.go index 618d118a..69273171 100644 --- a/pkg/formatter/render_alter.go +++ b/pkg/formatter/render_alter.go @@ -15,484 +15,19 @@ package formatter import ( - "strings" - "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" ) // renderAlterStatement renders an *ast.AlterStatement. ALTER TABLE statements // (every action the parser produces, comma-separated) are rendered in full, as -// are ALTER ROLE, POLICY and CONNECTOR; a statement without an operation keeps -// the stmtSQL output. +// are ALTER ROLE, POLICY and CONNECTOR; a statement without an operation is +// the bare ALTER keyword. The clause writers are shared with +// ast.AlterStatement.SQL() (ast.AlterSQL). func renderAlterStatement(a *ast.AlterStatement, opts ast.FormatOptions) string { if a == nil { return "" } - if a.Type != ast.AlterTypeTable { - if out := renderAlterOther(a, opts); out != "" { - return out - } - return stmtSQL(a) - } f := newNodeFormatter(opts) - f.sb.WriteString(f.kw("ALTER")) - for _, m := range a.Modifiers { - if m = alterWordsText(m); m != "" { - f.sb.WriteString(" ") - f.sb.WriteString(f.kw(m)) - } - } - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("TABLE")) - if a.IfExists { - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("IF EXISTS")) - } - if a.Only { - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("ONLY")) - } - f.sb.WriteString(" ") - f.sb.WriteString(ast.QuoteQualifiedNameWith(a.Name, f.nq(a.NameQuote))) - if cluster := alterClusterText(a.OnCluster); cluster != "" { - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("ON CLUSTER")) - f.sb.WriteString(" ") - f.sb.WriteString(cluster) - } - ops := a.Operations - if len(ops) == 0 && a.Operation != nil { - ops = []ast.AlterOperation{a.Operation} - } - first := true - for _, o := range ops { - op, ok := o.(*ast.AlterTableOperation) - if !ok || op == nil { - continue - } - if !first { - f.sb.WriteString(",") - } - f.sb.WriteString(f.clauseSep()) - writeAlterTableOperation(f, op) - first = false - } - if opts.AddSemicolon { - f.sb.WriteString(";") - } - return f.result() -} - -// alterIdentName returns the name of i quoted as a column name when it needs -// delimiters (or the source's are recorded); nil renders as an empty name. -func alterIdentName(f *nodeFormatter, i *ast.Ident) string { - if i == nil { - return ast.QuoteColumnName("") - } - return ast.QuoteColumnNameWith(i.Name, f.nq(i.Quote)) -} - -// alterColName returns i as a column name, quoted when needed. -func alterColName(f *nodeFormatter, i *ast.Ident) string { - return alterIdentName(f, i) -} - -// alterConstraintName returns i as a constraint name, quoted when needed -// and honouring its recorded delimiters. -func alterConstraintName(f *nodeFormatter, i *ast.Ident) string { - if i == nil { - return ast.QuoteConstraintName("") - } - return ast.QuoteConstraintNameWith(i.Name, f.nq(i.Quote)) -} - -// alterColNames renders i followed by extra, comma-separated. -func alterColNames(f *nodeFormatter, i *ast.Ident, extra []*ast.Ident) string { - parts := make([]string, 0, 1+len(extra)) - parts = append(parts, alterColName(f, i)) - for _, e := range extra { - parts = append(parts, alterColName(f, e)) - } - return strings.Join(parts, ", ") -} - -// alterColumnKeyword returns " COLUMN" or "" when the action was -// written without the COLUMN keyword. -func alterColumnKeyword(f *nodeFormatter, verb string, op *ast.AlterTableOperation) string { - if op.NoColumnKeyword { - return f.kw(verb) - } - return f.kw(verb + " COLUMN") -} - -// writeAlterColumnDefs renders the column definition(s) of an ADD / MODIFY. -func writeAlterColumnDefs(sb *strings.Builder, op *ast.AlterTableOperation) { - defs := op.ColumnDefs - if len(defs) == 0 && op.ColumnDef != nil { - defs = []*ast.ColumnDef{op.ColumnDef} - } - if op.Parenthesized { - sb.WriteString("(") - } - for i, d := range defs { - if i > 0 { - sb.WriteString(", ") - } - if d != nil { - sb.WriteString(columnDefSQL(d)) - } - } - if op.Parenthesized { - sb.WriteString(")") - } -} - -// writeAlterColumnPosition renders FIRST / AFTER column. -func writeAlterColumnPosition(f *nodeFormatter, op *ast.AlterTableOperation) { - pos := op.ColumnPosition - if pos == nil { - return - } - switch { - case pos.First: - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("FIRST")) - case pos.After != nil: - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("AFTER")) - f.sb.WriteString(" ") - f.sb.WriteString(alterColName(f, pos.After)) - } -} - -// writeAlterDropTail renders the CASCADE / RESTRICT that ends a DROP. -func writeAlterDropTail(f *nodeFormatter, op *ast.AlterTableOperation) { - switch { - case op.CascadeDrops: - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("CASCADE")) - case op.Restrict: - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("RESTRICT")) - } -} - -// writeAlterKeywordText renders a keyword phrase followed by text as written. -func writeAlterKeywordText(f *nodeFormatter, keyword, text string) { - keyword = alterWordsText(keyword) - if keyword != "" { - f.sb.WriteString(f.kw(keyword)) - } - if keyword != "" && text != "" { - f.sb.WriteString(" ") - } - f.sb.WriteString(text) -} - -// writeAlterColumnSub renders the sub-action of ALTER / MODIFY COLUMN name. -func writeAlterColumnSub(f *nodeFormatter, op *ast.AlterTableOperation) { - sb := f.sb - switch *op.AlterColumnOp { - case ast.AlterColumnSetDefault: - sb.WriteString(f.kw("SET DEFAULT")) - if op.DefaultValue != nil { - sb.WriteString(" ") - sb.WriteString(exprSQL(op.DefaultValue)) - } - case ast.AlterColumnDropDefault: - sb.WriteString(f.kw("DROP DEFAULT")) - case ast.AlterColumnSetNotNull: - sb.WriteString(f.kw("SET NOT NULL")) - case ast.AlterColumnDropNotNull: - sb.WriteString(f.kw("DROP NOT NULL")) - case ast.AlterColumnSetDataType: - kw := alterWordsText(op.Keyword) - if kw == "" { - kw = "TYPE" - } - sb.WriteString(f.kw(kw)) - sb.WriteString(" ") - sb.WriteString(ast.QuoteDataType(op.DataType)) - if op.Using != nil { - sb.WriteString(" ") - sb.WriteString(f.kw("USING")) - sb.WriteString(" ") - sb.WriteString(exprSQL(op.Using)) - } - case ast.AlterColumnOther: - writeAlterKeywordText(f, op.Keyword, op.Text) - } -} - -// alterConstraintSQL renders the constraint of an ADD, with the SQL Server -// CLUSTERED / NONCLUSTERED word and any trailing attribute phrase. -func alterConstraintSQL(f *nodeFormatter, op *ast.AlterTableOperation) string { - tc := op.Constraint - s := tableConstraintSQL(tc) - if op.Clustering != "" { - prefix := "" - if tc.Name != "" { - prefix = "CONSTRAINT " + ast.QuoteConstraintNameWith(tc.Name, tc.NameQuote) + " " - } - if strings.HasPrefix(s, prefix+tc.Type) { - rest := s[len(prefix)+len(tc.Type):] - s = prefix + tc.Type + " " + f.kw(op.Clustering) + rest - } - } - if op.ConstraintSuffix != "" { - s += " " + op.ConstraintSuffix - } - return s -} - -// writeAlterTableOperation renders a single ALTER TABLE operation. Operation -// types the ALTER TABLE parser does not produce are left empty. -func writeAlterTableOperation(f *nodeFormatter, op *ast.AlterTableOperation) { - sb := f.sb - if m := alterWordsText(op.Modifier); m != "" { - sb.WriteString(f.kw(m)) - sb.WriteString(" ") - } - switch op.Type { - case ast.AddColumn: - sb.WriteString(alterColumnKeyword(f, "ADD", op)) - if op.IfNotExists { - sb.WriteString(" ") - sb.WriteString(f.kw("IF NOT EXISTS")) - } - if op.ColumnDef != nil || len(op.ColumnDefs) > 0 { - sb.WriteString(" ") - writeAlterColumnDefs(sb, op) - } - writeAlterColumnPosition(f, op) - case ast.AddConstraint: - sb.WriteString(f.kw("ADD")) - if op.Constraint != nil { - // tableConstraintSQL prints "CONSTRAINT name" itself when named. - if op.Constraint.Name == "" && !op.BareConstraint { - sb.WriteString(" ") - sb.WriteString(f.kw("CONSTRAINT")) - } - sb.WriteString(" ") - sb.WriteString(alterConstraintSQL(f, op)) - } - case ast.AddIndex: - sb.WriteString(f.kw("ADD")) - if k := alterWordsText(op.IndexKind); k != "" { - sb.WriteString(" ") - sb.WriteString(f.kw(k)) - } - if k := alterWordsText(op.IndexKeyword); k != "" { - sb.WriteString(" ") - sb.WriteString(f.kw(k)) - } - if op.IfNotExists { - sb.WriteString(" ") - sb.WriteString(f.kw("IF NOT EXISTS")) - } - if op.IndexName != "" { - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.IndexName)) - } - if op.Text != "" { - sb.WriteString(" ") - sb.WriteString(op.Text) - } - case ast.DropColumn: - sb.WriteString(alterColumnKeyword(f, "DROP", op)) - if op.IfExists { - sb.WriteString(" ") - sb.WriteString(f.kw("IF EXISTS")) - } - sb.WriteString(" ") - sb.WriteString(alterColNames(f, op.ColumnName, op.ExtraNames)) - writeAlterDropTail(f, op) - case ast.DropConstraint: - kind := "CONSTRAINT" - if k := alterWordsText(op.ConstraintKind); k != "" { - kind = k - } - sb.WriteString(f.kw("DROP " + kind)) - if op.ConstraintKind == "PRIMARY KEY" { - return - } - if op.IfExists { - sb.WriteString(" ") - sb.WriteString(f.kw("IF EXISTS")) - } - sb.WriteString(" ") - names := []string{alterConstraintName(f, op.ConstraintName)} - for _, e := range op.ExtraNames { - names = append(names, alterConstraintName(f, e)) - } - sb.WriteString(strings.Join(names, ", ")) - writeAlterDropTail(f, op) - case ast.DropIndex: - kw := alterWordsText(op.IndexKeyword) - if kw == "" { - kw = "INDEX" - } - sb.WriteString(f.kw("DROP " + kw)) - if op.IfExists { - sb.WriteString(" ") - sb.WriteString(f.kw("IF EXISTS")) - } - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.IndexName)) - case ast.RenameTable: - sb.WriteString(f.kw("RENAME TO")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteQualifiedNameWith(op.NewTableName.Name, f.nq(op.NewTableName.Quote))) - case ast.RenameColumn: - oldName := op.ColumnName - if oldName == nil { - oldName = op.OldColumnName - } - sb.WriteString(alterColumnKeyword(f, "RENAME", op)) - if op.IfExists { - sb.WriteString(" ") - sb.WriteString(f.kw("IF EXISTS")) - } - sb.WriteString(" ") - sb.WriteString(alterColName(f, oldName)) - sb.WriteString(" ") - sb.WriteString(f.kw("TO")) - sb.WriteString(" ") - sb.WriteString(alterColName(f, op.NewColumnName)) - case ast.RenameConstraint: - sb.WriteString(f.kw("RENAME CONSTRAINT")) - sb.WriteString(" ") - sb.WriteString(alterConstraintName(f, op.OldName)) - sb.WriteString(" ") - sb.WriteString(f.kw("TO")) - sb.WriteString(" ") - sb.WriteString(alterConstraintName(f, op.NewName)) - case ast.AlterColumn: - sb.WriteString(alterColumnKeyword(f, "ALTER", op)) - writeAlterColumnTarget(f, op) - writeAlterColumnPosition(f, op) - case ast.ModifyColumn: - sb.WriteString(alterColumnKeyword(f, "MODIFY", op)) - if op.AlterColumnOp == nil && op.ColumnName == nil { - if op.ColumnDef != nil || len(op.ColumnDefs) > 0 { - sb.WriteString(" ") - writeAlterColumnDefs(sb, op) - } - } else { - writeAlterColumnTarget(f, op) - } - writeAlterColumnPosition(f, op) - case ast.ChangeColumn: - sb.WriteString(alterColumnKeyword(f, "CHANGE", op)) - sb.WriteString(" ") - sb.WriteString(alterColName(f, op.ColumnName)) - if op.ColumnDef != nil { - sb.WriteString(" ") - sb.WriteString(columnDefSQL(op.ColumnDef)) - } - writeAlterColumnPosition(f, op) - case ast.AlterTableOther: - writeAlterKeywordText(f, op.Keyword, op.Text) - case ast.UpdateMutation: - sb.WriteString(f.kw("UPDATE")) - sb.WriteString(" ") - for i := range op.Assignments { - if i > 0 { - sb.WriteString(", ") - } - sb.WriteString(exprSQL(op.Assignments[i].Column)) - sb.WriteString(" = ") - sb.WriteString(exprSQL(op.Assignments[i].Value)) - } - writeAlterMutationTail(f, op) - case ast.DeleteMutation: - sb.WriteString(f.kw("DELETE")) - writeAlterMutationTail(f, op) - } -} - -// writeAlterColumnTarget renders the part of ALTER / MODIFY COLUMN after the -// verb: the column name and its sub-action, or a full column definition. -func writeAlterColumnTarget(f *nodeFormatter, op *ast.AlterTableOperation) { - sb := f.sb - if op.AlterColumnOp != nil { - sb.WriteString(" ") - sb.WriteString(alterColName(f, op.ColumnName)) - sb.WriteString(" ") - writeAlterColumnSub(f, op) - return - } - if op.ColumnDef == nil { - if op.ColumnName != nil { - sb.WriteString(" ") - sb.WriteString(alterColName(f, op.ColumnName)) - } - return - } - // The full definition names the column; an older AST kept the column name - // in ColumnName and the rest of the clause in ColumnDef. - if op.ColumnName != nil && op.ColumnName.Name != op.ColumnDef.Name { - sb.WriteString(" ") - sb.WriteString(alterColName(f, op.ColumnName)) - } - sb.WriteString(" ") - sb.WriteString(columnDefSQL(op.ColumnDef)) -} - -// writeAlterMutationTail renders [IN PARTITION p] [WHERE cond] of a -// ClickHouse mutation. -func writeAlterMutationTail(f *nodeFormatter, op *ast.AlterTableOperation) { - sb := f.sb - if op.PartitionExpr != nil { - sb.WriteString(" ") - sb.WriteString(f.kw("IN PARTITION")) - sb.WriteString(" ") - sb.WriteString(exprSQL(op.PartitionExpr)) - } - if op.Where != nil { - sb.WriteString(" ") - sb.WriteString(f.kw("WHERE")) - sb.WriteString(" ") - sb.WriteString(exprSQL(op.Where)) - } -} - -// alterWordsText returns s when it is one or more bare words (an ASCII letter -// or '_' followed by letters, digits and '_') separated by single spaces, and -// "" otherwise. The parser only stores such words in the keyword fields of an -// ALTER statement; a hand-built field holding anything else is dropped rather -// than written verbatim (#632). -func alterWordsText(s string) string { - if s == "" { - return "" - } - for _, w := range strings.Split(s, " ") { - if w == "" { - return "" - } - for i := 0; i < len(w); i++ { - c := w[i] - letter := c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') - if !letter && (i == 0 || c < '0' || c > '9') { - return "" - } - } - } - return s -} - -// alterClusterText returns the ClickHouse ON CLUSTER target c as the parser -// stores it (a single-quoted string, a "..." or `...` identifier, or a bare -// name) when it is well formed, and c as a quoted name otherwise (#632). -func alterClusterText(c string) string { - if c == "" { - return "" - } - for _, q := range []string{"'", `"`, "`"} { - if n := len(c); n >= 2 && c[:1] == q && c[n-1:] == q && - !strings.Contains(strings.ReplaceAll(c[1:n-1], q+q, ""), q) { - return c - } - } - return ast.QuoteQualifiedName(c) + f.sb.WriteString(ast.AlterSQL(a, f.ddlStyle())) + return f.finishDDL() } diff --git a/pkg/formatter/render_alter_misc.go b/pkg/formatter/render_alter_misc.go deleted file mode 100644 index e6602bc7..00000000 --- a/pkg/formatter/render_alter_misc.go +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright 2026 GoSQLX 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, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package formatter - -import ( - "sort" - "strings" - - "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" -) - -// renderAlterOther renders ALTER ROLE, ALTER POLICY and ALTER CONNECTOR. It -// returns "" when the statement has no operation of the matching kind, so the -// caller can fall back to its previous output. -func renderAlterOther(a *ast.AlterStatement, opts ast.FormatOptions) string { - f := newNodeFormatter(opts) - switch op := a.Operation.(type) { - case *ast.AlterRoleOperation: - if a.Type != ast.AlterTypeRole || op == nil { - return "" - } - f.sb.WriteString(f.kw("ALTER ROLE")) - f.sb.WriteString(" ") - f.sb.WriteString(ast.QuoteColumnName(a.Name)) - f.sb.WriteString(f.clauseSep()) - writeAlterRoleOperation(f, op) - case *ast.AlterPolicyOperation: - if a.Type != ast.AlterTypePolicy || op == nil { - return "" - } - f.sb.WriteString(f.kw("ALTER POLICY")) - f.sb.WriteString(" ") - f.sb.WriteString(ast.QuoteColumnName(a.Name)) - f.sb.WriteString(" ") - f.sb.WriteString(f.kw("ON")) - f.sb.WriteString(" ") - f.sb.WriteString(ast.QuoteQualifiedName(op.Table)) - f.sb.WriteString(f.clauseSep()) - writeAlterPolicyOperation(f, op) - case *ast.AlterConnectorOperation: - if a.Type != ast.AlterTypeConnector || op == nil { - return "" - } - f.sb.WriteString(f.kw("ALTER CONNECTOR")) - f.sb.WriteString(" ") - f.sb.WriteString(ast.QuoteColumnName(a.Name)) - f.sb.WriteString(f.clauseSep()) - writeAlterConnectorOperation(f, op) - default: - return "" - } - if opts.AddSemicolon { - f.sb.WriteString(";") - } - return f.result() -} - -// writeAlterRoleOperation renders the action of ALTER ROLE. -func writeAlterRoleOperation(f *nodeFormatter, op *ast.AlterRoleOperation) { - sb := f.sb - switch op.Type { - case ast.RenameRole: - sb.WriteString(f.kw("RENAME TO")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.NewName)) - case ast.AddMember: - sb.WriteString(f.kw("ADD MEMBER")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.MemberName)) - case ast.DropMember: - sb.WriteString(f.kw("DROP MEMBER")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.MemberName)) - case ast.SetConfig: - sb.WriteString(f.kw("SET")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.ConfigName)) - if op.ConfigValue != nil { - sb.WriteString(" ") - sb.WriteString(f.kw("TO")) - sb.WriteString(" ") - sb.WriteString(exprSQL(op.ConfigValue)) - } - case ast.ResetConfig: - sb.WriteString(f.kw("RESET")) - sb.WriteString(" ") - if op.ConfigName == "ALL" { - sb.WriteString(f.kw("ALL")) - } else { - sb.WriteString(ast.QuoteColumnName(op.ConfigName)) - } - case ast.WithOptions: - sb.WriteString(f.kw("WITH")) - for i := range op.Options { - if i > 0 { - sb.WriteString(",") - } - sb.WriteString(" ") - sb.WriteString(roleOptionSQL(f, &op.Options[i])) - } - } -} - -// roleOptionSQL renders one role option; the value of PASSWORD and VALID -// UNTIL is an expression rendered as SQL. -func roleOptionSQL(f *nodeFormatter, opt *ast.RoleOption) string { - switch opt.Type { - case ast.Password: - if e, ok := opt.Value.(ast.Expression); ok && e != nil { - return f.kw("PASSWORD") + " " + exprSQL(e) - } - return f.kw("PASSWORD NULL") - case ast.ValidUntil: - if e, ok := opt.Value.(ast.Expression); ok && e != nil { - return f.kw("VALID UNTIL") + " " + exprSQL(e) - } - return f.kw("VALID UNTIL") - } - return f.kw(opt.String()) -} - -// writeAlterPolicyOperation renders the action of ALTER POLICY. -func writeAlterPolicyOperation(f *nodeFormatter, op *ast.AlterPolicyOperation) { - sb := f.sb - if op.Type == ast.RenamePolicy { - sb.WriteString(f.kw("RENAME TO")) - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.NewName)) - return - } - var parts []string - if len(op.To) > 0 { - names := make([]string, len(op.To)) - for i, n := range op.To { - names[i] = ast.QuoteColumnName(n) - } - parts = append(parts, f.kw("TO")+" "+strings.Join(names, ", ")) - } - if op.Using != nil { - parts = append(parts, f.kw("USING")+" ("+exprSQL(op.Using)+")") - } - if op.WithCheck != nil { - parts = append(parts, f.kw("WITH CHECK")+" ("+exprSQL(op.WithCheck)+")") - } - sb.WriteString(strings.Join(parts, f.clauseSep())) -} - -// writeAlterConnectorOperation renders the action of ALTER CONNECTOR. -func writeAlterConnectorOperation(f *nodeFormatter, op *ast.AlterConnectorOperation) { - sb := f.sb - sb.WriteString(f.kw("SET")) - sb.WriteString(" ") - switch { - case op.Properties != nil: - keys := make([]string, 0, len(op.Properties)) - for k := range op.Properties { - keys = append(keys, k) - } - sort.Strings(keys) - sb.WriteString(f.kw("DCPROPERTIES")) - sb.WriteString(" (") - for i, k := range keys { - if i > 0 { - sb.WriteString(", ") - } - sb.WriteString(stringLiteralSQL(k)) - sb.WriteString(" = ") - sb.WriteString(stringLiteralSQL(op.Properties[k])) - } - sb.WriteString(")") - case op.Owner != nil: - sb.WriteString(f.kw("OWNER")) - sb.WriteString(" ") - if op.Owner.IsUser { - sb.WriteString(f.kw("USER")) - } else { - sb.WriteString(f.kw("ROLE")) - } - sb.WriteString(" ") - sb.WriteString(ast.QuoteColumnName(op.Owner.Name)) - default: - sb.WriteString(f.kw("URL")) - sb.WriteString(" ") - sb.WriteString(stringLiteralSQL(op.URL)) - } -} - -// stringLiteralSQL renders s as a single-quoted string literal. -func stringLiteralSQL(s string) string { - return "'" + strings.ReplaceAll(s, "'", "''") + "'" -} diff --git a/pkg/formatter/type_args_quoting_test.go b/pkg/formatter/type_args_quoting_test.go new file mode 100644 index 00000000..d3887e61 --- /dev/null +++ b/pkg/formatter/type_args_quoting_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestTypeArgsKeepQuoteKinds checks that parenthesised argument text the +// parser keeps as written (ClickHouse PROJECTION bodies, engine arguments, +// type arguments) keeps the delimiters of its names and the escapes of its +// strings: "x" must not become the string 'x', and `x` must not lose its +// backticks. +func TestTypeArgsKeepQuoteKinds(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"projection double quotes", `CREATE TABLE t (a Int32, PROJECTION p (SELECT "a", sum("a") GROUP BY "a")) ENGINE=MergeTree ORDER BY a`}, + {"projection backticks", "CREATE TABLE t (a Int32, PROJECTION p (SELECT `a`, sum(`a`) GROUP BY `a`)) ENGINE=MergeTree ORDER BY a"}, + {"projection string stays string", `CREATE TABLE t (a Int32, PROJECTION p (SELECT 'a', "a" GROUP BY "a")) ENGINE=MergeTree ORDER BY a`}, + {"projection doubled delimiters", "CREATE TABLE t (a Int32, PROJECTION p (SELECT `x``y` GROUP BY `x``y`)) ENGINE=MergeTree ORDER BY a"}, + {"engine args with identifiers", "CREATE TABLE t (a Int32) ENGINE=Distributed(cluster, `db`, \"t\", rand())"}, + {"engine args escaped quote", "CREATE TABLE t (a Int32) ENGINE=ReplicatedMergeTree('/p/it''s', '{replica}') ORDER BY a"}, + {"engine args empty string", "CREATE TABLE t (a Int32) ENGINE=Foo('', 'x')"}, + {"named tuple elements", "CREATE TABLE t (a Tuple(`x y` UInt8, \"z\" String)) ENGINE=Memory"}, + {"enum with empty member", "CREATE TABLE t (a Enum8('' = 0, 'b' = 1)) ENGINE=Memory"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := gosqlx.ParseWithDialect(tc.in, keywords.DialectClickHouse) + if err != nil { + t.Fatalf("parse %q: %v", tc.in, err) + } + defer ast.ReleaseAST(tree) + if got := tree.SQL(); got != tc.in { + t.Errorf("SQL()\n got: %s\nwant: %s", got, tc.in) + } + style := ast.CompactStyle() + style.Dialect = string(keywords.DialectClickHouse) + if got := formatter.FormatStatement(tree.Statements[0], style); got != tc.in { + t.Errorf("FormatStatement\n got: %s\nwant: %s", got, tc.in) + } + }) + } +} diff --git a/pkg/sql/ast/alter.go b/pkg/sql/ast/alter.go index bebc08ac..70f5728c 100644 --- a/pkg/sql/ast/alter.go +++ b/pkg/sql/ast/alter.go @@ -219,6 +219,8 @@ type AlterTableOperation struct { IndexKeyword string // IndexName names the index of AddIndex and DropIndex. IndexName string + // IndexNameQuote records the delimiters of IndexName (see NameQuote). + IndexNameQuote NameQuote // Keyword and Text hold an AlterTableOther action: the leading keyword // phrase and the remainder as written. They also carry the keyword // spelling of an AlterColumnSetDataType (TYPE, SET DATA TYPE) and the @@ -393,13 +395,17 @@ func (opt *RoleOption) String() string { // AlterRoleOperation represents operations that can be performed on a role type AlterRoleOperation struct { - Type AlterRoleOpType - NewName string - Options []RoleOption - MemberName string - ConfigName string - ConfigValue Expression - InDatabase string + Type AlterRoleOpType + NewName string + // NewNameQuote records the delimiters of NewName (see NameQuote). + NewNameQuote NameQuote + Options []RoleOption + MemberName string + // MemberNameQuote records the delimiters of MemberName. + MemberNameQuote NameQuote + ConfigName string + ConfigValue Expression + InDatabase string } func (a *AlterRoleOperation) alterOperationNode() {} @@ -433,9 +439,16 @@ const ( type AlterPolicyOperation struct { Type AlterPolicyOpType // Table is the table the policy is on (ALTER POLICY name ON table). - Table string - NewName string - To []string + Table string + // TableQuote records the delimiters of Table (see NameQuote). + TableQuote NameQuote + NewName string + // NewNameQuote records the delimiters of NewName. + NewNameQuote NameQuote + To []string + // ToQuotes records the delimiters of To, by index; nil when no name needs + // them. + ToQuotes []NameQuote Using Expression WithCheck Expression } @@ -485,4 +498,6 @@ func (a AlterConnectorOperation) Children() []Node { return nil } type AlterConnectorOwner struct { IsUser bool Name string + // NameQuote records the delimiters of Name (see NameQuote). + NameQuote NameQuote } diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index 20d57666..e46fc487 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -696,15 +696,18 @@ type TableConstraint struct { // TYPE or PROJECTION body; Granularity the ClickHouse GRANULARITY value; // LikeTable and LikeOptions the PostgreSQL LIKE source and its // INCLUDING / EXCLUDING options. - Keyword string - IndexName string - Using string - Cluster string - Deferrable string - Exprs []Expression - Text string - Granularity string - LikeTable string + Keyword string + IndexName string + // IndexNameQuote records the delimiters of IndexName when they must be + // kept (see NameQuote). + IndexNameQuote NameQuote + Using string + Cluster string + Deferrable string + Exprs []Expression + Text string + Granularity string + LikeTable string // LikeTableQuote records the delimiters of LikeTable when they must // be kept (see NameQuote). LikeTableQuote NameQuote @@ -841,7 +844,10 @@ func (i IndexColumn) Children() []Node { // PartitionDefinition represents a partition definition in CREATE TABLE // Syntax: PARTITION name VALUES { LESS THAN (expr) | IN (list) | FROM (expr) TO (expr) } type PartitionDefinition struct { - Name string + Name string + // NameQuote records the delimiters of Name when they must be kept (see + // NameQuote). + NameQuote NameQuote Type string // FOR VALUES, IN, LESS THAN Values []Expression // Partition values or bounds LessThan Expression // For RANGE: LESS THAN (value) diff --git a/pkg/sql/ast/ast_columns.go b/pkg/sql/ast/ast_columns.go index 7f5c6b3a..0f05b7ec 100644 --- a/pkg/sql/ast/ast_columns.go +++ b/pkg/sql/ast/ast_columns.go @@ -135,6 +135,13 @@ func rawTextIsPlain(s string) bool { break } } + case c == '"' || c == '`': + // A delimited identifier: closed, doubled delimiters, no escapes. + j, ok := skipDelimited(s, i) + if !ok { + return false + } + i = j case c == '(' || c == '[': depth++ case c == ')' || c == ']': @@ -161,6 +168,27 @@ func rawTextIsPlain(s string) bool { return depth == 0 } +// skipDelimited returns the index of the closing delimiter of the "..." or +// `...` segment of s that starts at i, treating a doubled delimiter as part of +// the name. It reports false when the segment is not closed or holds a +// backslash. +func skipDelimited(s string, i int) (int, bool) { + q := s[i] + for j := i + 1; j < len(s); j++ { + switch s[j] { + case '\\': + return 0, false + case q: + if j+1 < len(s) && s[j+1] == q { + j++ + continue + } + return j, true + } + } + return 0, false +} + // qualifiedNameTextIsPlain reports whether s is a possibly dot-qualified name // written as in the source: each part is a bare word or a quoted segment // ("x", `x`, 'x'), so writing it back verbatim keeps its quoting. diff --git a/pkg/sql/ast/ddl_objects.go b/pkg/sql/ast/ddl_objects.go index 66557c3e..44535dd7 100644 --- a/pkg/sql/ast/ddl_objects.go +++ b/pkg/sql/ast/ddl_objects.go @@ -75,6 +75,9 @@ type CreateTableExtras struct { Kind string // OnCluster is the ClickHouse ON CLUSTER name. OnCluster string + // InheritsQuotes records the delimiters of each Inherits entry by index + // (nil when none keeps delimiters); see NameQuote. + InheritsQuotes []NameQuote // Like is the source table of CREATE TABLE t LIKE src. Like string // Clone is the Snowflake CLONE source. diff --git a/pkg/sql/ast/pool_statement_release.go b/pkg/sql/ast/pool_statement_release.go index 535ca712..079e3afd 100644 --- a/pkg/sql/ast/pool_statement_release.go +++ b/pkg/sql/ast/pool_statement_release.go @@ -701,6 +701,7 @@ func PutCreateTableStatement(stmt *CreateTableStatement) { stmt.Partitions[i].From = nil stmt.Partitions[i].To = nil stmt.Partitions[i].Name = "" + stmt.Partitions[i].NameQuote = 0 stmt.Partitions[i].Type = "" stmt.Partitions[i].Tablespace = "" } diff --git a/pkg/sql/ast/quote_names.go b/pkg/sql/ast/quote_names.go index 3f0a06f1..e5dbb60d 100644 --- a/pkg/sql/ast/quote_names.go +++ b/pkg/sql/ast/quote_names.go @@ -598,6 +598,14 @@ func dataTypeIsPlain(typ string) bool { break } } + case (c == '"' || c == '`') && depth > 0: + // A delimited identifier inside an argument list (a named tuple + // element, an engine argument): closed, no escapes. + j, ok := skipDelimited(typ, i) + if !ok { + return false + } + i = j case c == '(': depth++ case c == ')': diff --git a/pkg/sql/ast/render_ddl_name_quoting_632_test.go b/pkg/sql/ast/render_ddl_name_quoting_632_test.go index 9c3f5f15..5f438b77 100644 --- a/pkg/sql/ast/render_ddl_name_quoting_632_test.go +++ b/pkg/sql/ast/render_ddl_name_quoting_632_test.go @@ -353,7 +353,7 @@ func TestDDLNameRendering_Benign_PR632(t *testing.T) { {"", `CREATE TABLE "Mixed" ("Col" INT)`, `CREATE TABLE "Mixed" ("Col" INT)`}, {"", `CREATE TABLE t ("select" INT)`, `CREATE TABLE t ("select" INT)`}, {"", `CREATE TABLE t (a Nullable(String), c Enum8('a' = 1, 'b' = -2), d DateTime64(3, 'UTC'))`, - `CREATE TABLE t (a Nullable(String), c Enum8('a'= 1, 'b'= - 2), d DateTime64(3, 'UTC'))`}, + `CREATE TABLE t (a Nullable(String), c Enum8('a' = 1, 'b' = - 2), d DateTime64(3, 'UTC'))`}, {"", `CREATE TABLE t (a ENUM('x', 'y'))`, `CREATE TABLE t (a ENUM('x', 'y'))`}, {"", `CREATE TABLE t (a INT) ENGINE = InnoDB`, `CREATE TABLE t (a INT) ENGINE=InnoDB`}, {keywords.DialectClickHouse, `CREATE TABLE t (a INT) ENGINE = MergeTree()`, `CREATE TABLE t (a INT) ENGINE=MergeTree()`}, diff --git a/pkg/sql/ast/sql_alter.go b/pkg/sql/ast/sql_alter.go new file mode 100644 index 00000000..bf448c50 --- /dev/null +++ b/pkg/sql/ast/sql_alter.go @@ -0,0 +1,703 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// sql_alter.go - shared SQL writer for ALTER TABLE, ALTER ROLE, ALTER POLICY +// and ALTER CONNECTOR. AlterStatement.SQL() and the formatter both use it; +// DDLStyle carries the formatter's keyword casing and clause separator. + +package ast + +import ( + "bytes" + "sort" + "strings" +) + +// SQL returns the SQL representation of this ALTER statement. ALTER TABLE +// (every action the parser produces), ALTER ROLE, ALTER POLICY and ALTER +// CONNECTOR are written in full; a statement with no operation is "ALTER". +func (a *AlterStatement) SQL() string { + if a == nil { + return "" + } + return AlterSQL(a, DDLStyle{}) +} + +// AlterSQL renders an ALTER statement with the keyword casing, nested +// rendering, name delimiters and clause separator of st. It writes no +// trailing semicolon. A statement without a renderable operation yields the +// bare "ALTER" keyword. +func AlterSQL(a *AlterStatement, st DDLStyle) string { + if a == nil { + return "" + } + buf := getBuf() + defer putBuf(buf) + w := alterWriter{buf: buf, st: st} + if a.Type != AlterTypeTable { + if !w.writeOther(a) { + buf.WriteString(st.kw("ALTER")) + } + return buf.String() + } + w.writeTable(a) + return buf.String() +} + +// alterWriter carries the output buffer and style of one ALTER rendering. +type alterWriter struct { + buf *bytes.Buffer + st DDLStyle +} + +func (w alterWriter) kw(k string) string { return w.st.kw(k) } + +func (w alterWriter) str(s string) { w.buf.WriteString(s) } + +// kwStr writes keyword k cased by the style. +func (w alterWriter) kwStr(k string) { w.buf.WriteString(w.st.kw(k)) } + +func (w alterWriter) expr(e Expression) { w.st.writeExpr(w.buf, e) } + +func (w alterWriter) exprStr(e Expression) string { + b := getBuf() + defer putBuf(b) + w.st.writeExpr(b, e) + return b.String() +} + +// writeTable writes ALTER [modifiers] TABLE ... and every action. +func (w alterWriter) writeTable(a *AlterStatement) { + w.kwStr("ALTER") + for _, m := range a.Modifiers { + if m = alterWordsText(m); m != "" { + w.str(" ") + w.kwStr(m) + } + } + w.str(" ") + w.kwStr("TABLE") + if a.IfExists { + w.str(" ") + w.kwStr("IF EXISTS") + } + if a.Only { + w.str(" ") + w.kwStr("ONLY") + } + w.str(" ") + w.str(QuoteQualifiedNameWith(a.Name, w.st.nq(a.NameQuote))) + if cluster := alterClusterText(a.OnCluster); cluster != "" { + w.str(" ") + w.kwStr("ON CLUSTER") + w.str(" ") + w.str(cluster) + } + ops := a.Operations + if len(ops) == 0 && a.Operation != nil { + ops = []AlterOperation{a.Operation} + } + first := true + for _, o := range ops { + op, ok := o.(*AlterTableOperation) + if !ok || op == nil { + continue + } + if !first { + w.str(",") + } + w.str(w.st.sep()) + w.writeOperation(op) + first = false + } +} + +// identName returns the name of i quoted as a column name when it needs +// delimiters (or the source's are recorded); nil renders as an empty name. +func (w alterWriter) identName(i *Ident) string { + if i == nil { + return QuoteColumnName("") + } + return QuoteColumnNameWith(i.Name, w.st.nq(i.Quote)) +} + +// constraintName returns i as a constraint name, quoted when needed and +// honouring its recorded delimiters. +func (w alterWriter) constraintName(i *Ident) string { + if i == nil { + return QuoteConstraintName("") + } + return QuoteConstraintNameWith(i.Name, w.st.nq(i.Quote)) +} + +// colNames renders i followed by extra, comma-separated. +func (w alterWriter) colNames(i *Ident, extra []*Ident) string { + parts := make([]string, 0, 1+len(extra)) + parts = append(parts, w.identName(i)) + for _, e := range extra { + parts = append(parts, w.identName(e)) + } + return strings.Join(parts, ", ") +} + +// columnKeyword returns " COLUMN" or "" when the action was +// written without the COLUMN keyword. +func (w alterWriter) columnKeyword(verb string, op *AlterTableOperation) string { + if op.NoColumnKeyword { + return w.kw(verb) + } + return w.kw(verb + " COLUMN") +} + +// writeColumnDefs renders the column definition(s) of an ADD / MODIFY. +func (w alterWriter) writeColumnDefs(op *AlterTableOperation) { + defs := op.ColumnDefs + if len(defs) == 0 && op.ColumnDef != nil { + defs = []*ColumnDef{op.ColumnDef} + } + if op.Parenthesized { + w.str("(") + } + for i, d := range defs { + if i > 0 { + w.str(", ") + } + if d != nil { + w.str(columnDefSQL(d)) + } + } + if op.Parenthesized { + w.str(")") + } +} + +// writeColumnPosition renders FIRST / AFTER column. +func (w alterWriter) writeColumnPosition(op *AlterTableOperation) { + pos := op.ColumnPosition + if pos == nil { + return + } + switch { + case pos.First: + w.str(" ") + w.kwStr("FIRST") + case pos.After != nil: + w.str(" ") + w.kwStr("AFTER") + w.str(" ") + w.str(w.identName(pos.After)) + } +} + +// writeDropTail renders the CASCADE / RESTRICT that ends a DROP. +func (w alterWriter) writeDropTail(op *AlterTableOperation) { + switch { + case op.CascadeDrops: + w.str(" ") + w.kwStr("CASCADE") + case op.Restrict: + w.str(" ") + w.kwStr("RESTRICT") + } +} + +// writeKeywordText renders a keyword phrase followed by text as written. +func (w alterWriter) writeKeywordText(keyword, text string) { + keyword = alterWordsText(keyword) + if keyword != "" { + w.kwStr(keyword) + } + if keyword != "" && text != "" { + w.str(" ") + } + w.str(text) +} + +// writeColumnSub renders the sub-action of ALTER / MODIFY COLUMN name. +func (w alterWriter) writeColumnSub(op *AlterTableOperation) { + switch *op.AlterColumnOp { + case AlterColumnSetDefault: + w.kwStr("SET DEFAULT") + if op.DefaultValue != nil { + w.str(" ") + w.expr(op.DefaultValue) + } + case AlterColumnDropDefault: + w.kwStr("DROP DEFAULT") + case AlterColumnSetNotNull: + w.kwStr("SET NOT NULL") + case AlterColumnDropNotNull: + w.kwStr("DROP NOT NULL") + case AlterColumnSetDataType: + kw := alterWordsText(op.Keyword) + if kw == "" { + kw = "TYPE" + } + w.kwStr(kw) + w.str(" ") + w.str(QuoteDataType(op.DataType)) + if op.Using != nil { + w.str(" ") + w.kwStr("USING") + w.str(" ") + w.expr(op.Using) + } + case AlterColumnOther: + w.writeKeywordText(op.Keyword, op.Text) + } +} + +// constraintText renders the constraint of an ADD, with the SQL Server +// CLUSTERED / NONCLUSTERED word and any trailing attribute phrase. +func (w alterWriter) constraintText(op *AlterTableOperation) string { + tc := op.Constraint + s := tableConstraintSQL(tc) + if op.Clustering != "" { + prefix := "" + if tc.Name != "" { + prefix = "CONSTRAINT " + QuoteConstraintNameWith(tc.Name, tc.NameQuote) + " " + } + if strings.HasPrefix(s, prefix+tc.Type) { + rest := s[len(prefix)+len(tc.Type):] + s = prefix + tc.Type + " " + w.kw(op.Clustering) + rest + } + } + if op.ConstraintSuffix != "" { + s += " " + op.ConstraintSuffix + } + return s +} + +// writeOperation renders a single ALTER TABLE operation. Operation types the +// ALTER TABLE parser does not produce are left empty. +func (w alterWriter) writeOperation(op *AlterTableOperation) { + if m := alterWordsText(op.Modifier); m != "" { + w.kwStr(m) + w.str(" ") + } + switch op.Type { + case AddColumn: + w.str(w.columnKeyword("ADD", op)) + if op.IfNotExists { + w.str(" ") + w.kwStr("IF NOT EXISTS") + } + if op.ColumnDef != nil || len(op.ColumnDefs) > 0 { + w.str(" ") + w.writeColumnDefs(op) + } + w.writeColumnPosition(op) + case AddConstraint: + w.kwStr("ADD") + if op.Constraint != nil { + // tableConstraintSQL prints "CONSTRAINT name" itself when named. + if op.Constraint.Name == "" && !op.BareConstraint { + w.str(" ") + w.kwStr("CONSTRAINT") + } + w.str(" ") + w.str(w.constraintText(op)) + } + case AddIndex: + w.kwStr("ADD") + if k := alterWordsText(op.IndexKind); k != "" { + w.str(" ") + w.kwStr(k) + } + if k := alterWordsText(op.IndexKeyword); k != "" { + w.str(" ") + w.kwStr(k) + } + if op.IfNotExists { + w.str(" ") + w.kwStr("IF NOT EXISTS") + } + if op.IndexName != "" { + w.str(" ") + w.str(QuoteColumnNameWith(op.IndexName, w.st.nq(op.IndexNameQuote))) + } + if op.Text != "" { + w.str(" ") + w.str(op.Text) + } + case DropColumn: + w.str(w.columnKeyword("DROP", op)) + if op.IfExists { + w.str(" ") + w.kwStr("IF EXISTS") + } + w.str(" ") + w.str(w.colNames(op.ColumnName, op.ExtraNames)) + w.writeDropTail(op) + case DropConstraint: + kind := "CONSTRAINT" + if k := alterWordsText(op.ConstraintKind); k != "" { + kind = k + } + w.kwStr("DROP " + kind) + if op.ConstraintKind == "PRIMARY KEY" { + return + } + if op.IfExists { + w.str(" ") + w.kwStr("IF EXISTS") + } + w.str(" ") + names := []string{w.constraintName(op.ConstraintName)} + for _, e := range op.ExtraNames { + names = append(names, w.constraintName(e)) + } + w.str(strings.Join(names, ", ")) + w.writeDropTail(op) + case DropIndex: + kw := alterWordsText(op.IndexKeyword) + if kw == "" { + kw = "INDEX" + } + w.kwStr("DROP " + kw) + if op.IfExists { + w.str(" ") + w.kwStr("IF EXISTS") + } + w.str(" ") + w.str(QuoteColumnNameWith(op.IndexName, w.st.nq(op.IndexNameQuote))) + case RenameTable: + w.kwStr("RENAME TO") + w.str(" ") + w.str(QuoteQualifiedNameWith(op.NewTableName.Name, w.st.nq(op.NewTableName.Quote))) + case RenameColumn: + oldName := op.ColumnName + if oldName == nil { + oldName = op.OldColumnName + } + w.str(w.columnKeyword("RENAME", op)) + if op.IfExists { + w.str(" ") + w.kwStr("IF EXISTS") + } + w.str(" ") + w.str(w.identName(oldName)) + w.str(" ") + w.kwStr("TO") + w.str(" ") + w.str(w.identName(op.NewColumnName)) + case RenameConstraint: + w.kwStr("RENAME CONSTRAINT") + w.str(" ") + w.str(w.constraintName(op.OldName)) + w.str(" ") + w.kwStr("TO") + w.str(" ") + w.str(w.constraintName(op.NewName)) + case AlterColumn: + w.str(w.columnKeyword("ALTER", op)) + w.writeColumnTarget(op) + w.writeColumnPosition(op) + case ModifyColumn: + w.str(w.columnKeyword("MODIFY", op)) + if op.AlterColumnOp == nil && op.ColumnName == nil { + if op.ColumnDef != nil || len(op.ColumnDefs) > 0 { + w.str(" ") + w.writeColumnDefs(op) + } + } else { + w.writeColumnTarget(op) + } + w.writeColumnPosition(op) + case ChangeColumn: + w.str(w.columnKeyword("CHANGE", op)) + w.str(" ") + w.str(w.identName(op.ColumnName)) + if op.ColumnDef != nil { + w.str(" ") + w.str(columnDefSQL(op.ColumnDef)) + } + w.writeColumnPosition(op) + case AlterTableOther: + w.writeKeywordText(op.Keyword, op.Text) + case UpdateMutation: + w.kwStr("UPDATE") + w.str(" ") + for i := range op.Assignments { + if i > 0 { + w.str(", ") + } + w.expr(op.Assignments[i].Column) + w.str(" = ") + w.expr(op.Assignments[i].Value) + } + w.writeMutationTail(op) + case DeleteMutation: + w.kwStr("DELETE") + w.writeMutationTail(op) + } +} + +// writeColumnTarget renders the part of ALTER / MODIFY COLUMN after the +// verb: the column name and its sub-action, or a full column definition. +func (w alterWriter) writeColumnTarget(op *AlterTableOperation) { + if op.AlterColumnOp != nil { + w.str(" ") + w.str(w.identName(op.ColumnName)) + w.str(" ") + w.writeColumnSub(op) + return + } + if op.ColumnDef == nil { + if op.ColumnName != nil { + w.str(" ") + w.str(w.identName(op.ColumnName)) + } + return + } + // The full definition names the column; an older AST kept the column name + // in ColumnName and the rest of the clause in ColumnDef. + if op.ColumnName != nil && op.ColumnName.Name != op.ColumnDef.Name { + w.str(" ") + w.str(w.identName(op.ColumnName)) + } + w.str(" ") + w.str(columnDefSQL(op.ColumnDef)) +} + +// writeMutationTail renders [IN PARTITION p] [WHERE cond] of a ClickHouse +// mutation. +func (w alterWriter) writeMutationTail(op *AlterTableOperation) { + if op.PartitionExpr != nil { + w.str(" ") + w.kwStr("IN PARTITION") + w.str(" ") + w.expr(op.PartitionExpr) + } + if op.Where != nil { + w.str(" ") + w.kwStr("WHERE") + w.str(" ") + w.expr(op.Where) + } +} + +// writeOther renders ALTER ROLE, ALTER POLICY and ALTER CONNECTOR. It +// reports false, writing nothing, when the statement has no operation of the +// matching kind. +func (w alterWriter) writeOther(a *AlterStatement) bool { + switch op := a.Operation.(type) { + case *AlterRoleOperation: + if a.Type != AlterTypeRole || op == nil { + return false + } + w.kwStr("ALTER ROLE") + w.str(" ") + w.str(QuoteColumnNameWith(a.Name, w.st.nq(a.NameQuote))) + w.str(w.st.sep()) + w.writeRoleOperation(op) + case *AlterPolicyOperation: + if a.Type != AlterTypePolicy || op == nil { + return false + } + w.kwStr("ALTER POLICY") + w.str(" ") + w.str(QuoteColumnNameWith(a.Name, w.st.nq(a.NameQuote))) + w.str(" ") + w.kwStr("ON") + w.str(" ") + w.str(QuoteQualifiedNameWith(op.Table, w.st.nq(op.TableQuote))) + w.str(w.st.sep()) + w.writePolicyOperation(op) + case *AlterConnectorOperation: + if a.Type != AlterTypeConnector || op == nil { + return false + } + w.kwStr("ALTER CONNECTOR") + w.str(" ") + w.str(QuoteColumnNameWith(a.Name, w.st.nq(a.NameQuote))) + w.str(w.st.sep()) + w.writeConnectorOperation(op) + default: + return false + } + return true +} + +// writeRoleOperation renders the action of ALTER ROLE. +func (w alterWriter) writeRoleOperation(op *AlterRoleOperation) { + switch op.Type { + case RenameRole: + w.kwStr("RENAME TO") + w.str(" ") + w.str(QuoteColumnNameWith(op.NewName, w.st.nq(op.NewNameQuote))) + case AddMember: + w.kwStr("ADD MEMBER") + w.str(" ") + w.str(QuoteColumnNameWith(op.MemberName, w.st.nq(op.MemberNameQuote))) + case DropMember: + w.kwStr("DROP MEMBER") + w.str(" ") + w.str(QuoteColumnNameWith(op.MemberName, w.st.nq(op.MemberNameQuote))) + case SetConfig: + w.kwStr("SET") + w.str(" ") + w.str(QuoteColumnName(op.ConfigName)) + if op.ConfigValue != nil { + w.str(" ") + w.kwStr("TO") + w.str(" ") + w.expr(op.ConfigValue) + } + case ResetConfig: + w.kwStr("RESET") + w.str(" ") + if op.ConfigName == "ALL" { + w.kwStr("ALL") + } else { + w.str(QuoteColumnName(op.ConfigName)) + } + case WithOptions: + w.kwStr("WITH") + for i := range op.Options { + if i > 0 { + w.str(",") + } + w.str(" ") + w.str(w.roleOption(&op.Options[i])) + } + } +} + +// roleOption renders one role option; the value of PASSWORD and VALID UNTIL +// is an expression rendered as SQL. +func (w alterWriter) roleOption(opt *RoleOption) string { + switch opt.Type { + case Password: + if e, ok := opt.Value.(Expression); ok && e != nil { + return w.kw("PASSWORD") + " " + w.exprStr(e) + } + return w.kw("PASSWORD NULL") + case ValidUntil: + if e, ok := opt.Value.(Expression); ok && e != nil { + return w.kw("VALID UNTIL") + " " + w.exprStr(e) + } + return w.kw("VALID UNTIL") + } + return w.kw(opt.String()) +} + +// writePolicyOperation renders the action of ALTER POLICY. +func (w alterWriter) writePolicyOperation(op *AlterPolicyOperation) { + if op.Type == RenamePolicy { + w.kwStr("RENAME TO") + w.str(" ") + w.str(QuoteColumnNameWith(op.NewName, w.st.nq(op.NewNameQuote))) + return + } + var parts []string + if len(op.To) > 0 { + names := make([]string, len(op.To)) + for i, n := range op.To { + names[i] = QuoteColumnNameWith(n, w.st.nq(nameQuoteAt(op.ToQuotes, i))) + } + parts = append(parts, w.kw("TO")+" "+strings.Join(names, ", ")) + } + if op.Using != nil { + parts = append(parts, w.kw("USING")+" ("+w.exprStr(op.Using)+")") + } + if op.WithCheck != nil { + parts = append(parts, w.kw("WITH CHECK")+" ("+w.exprStr(op.WithCheck)+")") + } + w.str(strings.Join(parts, w.st.sep())) +} + +// writeConnectorOperation renders the action of ALTER CONNECTOR. +func (w alterWriter) writeConnectorOperation(op *AlterConnectorOperation) { + w.kwStr("SET") + w.str(" ") + switch { + case op.Properties != nil: + keys := make([]string, 0, len(op.Properties)) + for k := range op.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + w.kwStr("DCPROPERTIES") + w.str(" (") + for i, k := range keys { + if i > 0 { + w.str(", ") + } + w.str(alterStringLiteral(k)) + w.str(" = ") + w.str(alterStringLiteral(op.Properties[k])) + } + w.str(")") + case op.Owner != nil: + w.kwStr("OWNER") + w.str(" ") + if op.Owner.IsUser { + w.kwStr("USER") + } else { + w.kwStr("ROLE") + } + w.str(" ") + w.str(QuoteColumnNameWith(op.Owner.Name, w.st.nq(op.Owner.NameQuote))) + default: + w.kwStr("URL") + w.str(" ") + w.str(alterStringLiteral(op.URL)) + } +} + +// alterStringLiteral renders s as a single-quoted string literal. +func alterStringLiteral(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// alterWordsText returns s when it is one or more bare words (an ASCII letter +// or '_' followed by letters, digits and '_') separated by single spaces, and +// "" otherwise. The parser only stores such words in the keyword fields of an +// ALTER statement; a hand-built field holding anything else is dropped rather +// than written verbatim (#632). +func alterWordsText(s string) string { + if s == "" { + return "" + } + for _, w := range strings.Split(s, " ") { + if w == "" { + return "" + } + for i := 0; i < len(w); i++ { + c := w[i] + letter := c == '_' || (c|0x20 >= 'a' && c|0x20 <= 'z') + if !letter && (i == 0 || c < '0' || c > '9') { + return "" + } + } + } + return s +} + +// alterClusterText returns the ClickHouse ON CLUSTER target c as the parser +// stores it (a single-quoted string, a "..." or `...` identifier, or a bare +// name) when it is well formed, and c as a quoted name otherwise (#632). +func alterClusterText(c string) string { + if c == "" { + return "" + } + for _, q := range []string{"'", `"`, "`"} { + if n := len(c); n >= 2 && c[:1] == q && c[n-1:] == q && + !strings.Contains(strings.ReplaceAll(c[1:n-1], q+q, ""), q) { + return c + } + } + return QuoteQualifiedName(c) +} diff --git a/pkg/sql/ast/sql_columns.go b/pkg/sql/ast/sql_columns.go index 89d1fb6f..96d68d1a 100644 --- a/pkg/sql/ast/sql_columns.go +++ b/pkg/sql/ast/sql_columns.go @@ -255,7 +255,7 @@ func writeIndexHead(buf *bytes.Buffer, tc *TableConstraint) { } if tc.IndexName != "" { buf.WriteString(" ") - writeColumnName(buf, tc.IndexName) + writeTableAliasQ(buf, tc.IndexName, tc.IndexNameQuote) } if m := keywordOneOf(tc.Using, indexMethodKeywords); m != "" { buf.WriteString(" USING ") @@ -320,7 +320,7 @@ func writeTableConstraint(buf *bytes.Buffer, tc *TableConstraint) { buf.WriteString(tc.Type) if tc.IndexName != "" { buf.WriteString(" ") - writeColumnName(buf, tc.IndexName) + writeTableAliasQ(buf, tc.IndexName, tc.IndexNameQuote) } if m := keywordOneOf(tc.Using, indexMethodKeywords); m != "" { buf.WriteString(" USING ") @@ -344,7 +344,7 @@ func writeTableConstraint(buf *bytes.Buffer, tc *TableConstraint) { } case "PROJECTION": buf.WriteString("PROJECTION ") - writeColumnName(buf, tc.IndexName) + writeTableAliasQ(buf, tc.IndexName, tc.IndexNameQuote) writeSpacedArgs(buf, tc.Text) default: // The parser only stores the kinds above; any other kind can only @@ -356,7 +356,7 @@ func writeTableConstraint(buf *bytes.Buffer, tc *TableConstraint) { // writeClickHouseIndex writes INDEX name expr TYPE type GRANULARITY n. func writeClickHouseIndex(buf *bytes.Buffer, tc *TableConstraint) { buf.WriteString("INDEX ") - writeColumnName(buf, tc.IndexName) + writeTableAliasQ(buf, tc.IndexName, tc.IndexNameQuote) if len(tc.Exprs) > 0 { buf.WriteString(" ") writeExpr(buf, tc.Exprs[0]) diff --git a/pkg/sql/ast/sql_ddl_objects.go b/pkg/sql/ast/sql_ddl_objects.go index 365310c7..bbfdb7bd 100644 --- a/pkg/sql/ast/sql_ddl_objects.go +++ b/pkg/sql/ast/sql_ddl_objects.go @@ -238,7 +238,7 @@ func writeCreateTableTail(buf *bytes.Buffer, c *CreateTableStatement, st DDLStyl if len(c.Inherits) > 0 { buf.WriteByte(' ') st.writeKw(buf, "INHERITS (") - writeQualifiedNameList(buf, c.Inherits) + writeQualifiedNameListQ(buf, c.Inherits, st.nqList(c.InheritsQuotes, len(c.Inherits))) buf.WriteByte(')') } if c.WithSystemVersioning { @@ -470,7 +470,7 @@ var partitionDefTypes = []string{"LESS THAN", "IN", "FROM TO", "VALUES", "HISTOR func writePartitionDefinition(buf *bytes.Buffer, d *PartitionDefinition, st DDLStyle) { st.writeKw(buf, "PARTITION ") - writeColumnName(buf, d.Name) + writeTableAliasQ(buf, d.Name, st.nq(d.NameQuote)) switch typ := strings.ToUpper(keywordOneOf(d.Type, partitionDefTypes)); typ { case "LESS THAN": buf.WriteByte(' ') diff --git a/pkg/sql/parser/alter.go b/pkg/sql/parser/alter.go index 0e80ae8e..3ef297fb 100644 --- a/pkg/sql/parser/alter.go +++ b/pkg/sql/parser/alter.go @@ -79,7 +79,7 @@ func (p *Parser) alterModifierFollows(i int) bool { // parseAlterRoleStatement parses ALTER ROLE statements func (p *Parser) parseAlterRoleStatement(stmt *ast.AlterStatement) (*ast.AlterStatement, error) { - stmt.Name = p.parseIdentAsString() + stmt.Name, stmt.NameQuote = p.parseIdentAsStringQuoted() op := &ast.AlterRoleOperation{} switch { @@ -88,21 +88,21 @@ func (p *Parser) parseAlterRoleStatement(stmt *ast.AlterStatement) (*ast.AlterSt return nil, p.expectedError("TO") } op.Type = ast.RenameRole - op.NewName = p.parseIdentAsString() + op.NewName, op.NewNameQuote = p.parseIdentAsStringQuoted() case p.matchType(models.TokenTypeAdd): if !p.matchType(models.TokenTypeMember) { return nil, p.expectedError("MEMBER") } op.Type = ast.AddMember - op.MemberName = p.parseIdentAsString() + op.MemberName, op.MemberNameQuote = p.parseIdentAsStringQuoted() case p.matchType(models.TokenTypeDrop): if !p.matchType(models.TokenTypeMember) { return nil, p.expectedError("MEMBER") } op.Type = ast.DropMember - op.MemberName = p.parseIdentAsString() + op.MemberName, op.MemberNameQuote = p.parseIdentAsStringQuoted() case p.matchType(models.TokenTypeSet): op.Type = ast.SetConfig @@ -146,28 +146,30 @@ func (p *Parser) parseAlterRoleStatement(stmt *ast.AlterStatement) (*ast.AlterSt // parseAlterPolicyStatement parses ALTER POLICY statements func (p *Parser) parseAlterPolicyStatement(stmt *ast.AlterStatement) (*ast.AlterStatement, error) { - stmt.Name = p.parseIdentAsString() + stmt.Name, stmt.NameQuote = p.parseIdentAsStringQuoted() if !p.matchType(models.TokenTypeOn) { return nil, p.expectedError("ON") } - table, err := p.parseQualifiedName() + table, tableQuote, err := p.parseQualifiedNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "table name") } - op := &ast.AlterPolicyOperation{Table: table} + op := &ast.AlterPolicyOperation{Table: table, TableQuote: tableQuote} if p.matchType(models.TokenTypeRename) { if !p.matchType(models.TokenTypeTo) { return nil, p.expectedError("TO") } op.Type = ast.RenamePolicy - op.NewName = p.parseIdentAsString() + op.NewName, op.NewNameQuote = p.parseIdentAsStringQuoted() } else { op.Type = ast.ModifyPolicy if p.matchType(models.TokenTypeTo) { for { - op.To = append(op.To, p.parseIdentAsString()) + name, q := p.parseIdentAsStringQuoted() + op.ToQuotes = nameQuoteList(op.ToQuotes, len(op.To), q) + op.To = append(op.To, name) if !p.matchType(models.TokenTypeComma) { break } @@ -207,7 +209,7 @@ func (p *Parser) parseAlterPolicyStatement(stmt *ast.AlterStatement) (*ast.Alter // parseAlterConnectorStatement parses ALTER CONNECTOR statements func (p *Parser) parseAlterConnectorStatement(stmt *ast.AlterStatement) (*ast.AlterStatement, error) { - stmt.Name = p.parseIdentAsString() + stmt.Name, stmt.NameQuote = p.parseIdentAsStringQuoted() if !p.matchType(models.TokenTypeSet) { return nil, p.expectedError("SET") } @@ -253,7 +255,7 @@ func (p *Parser) parseAlterConnectorStatement(stmt *ast.AlterStatement) (*ast.Al return nil, p.expectedError("USER or ROLE") } p.advance() - owner.Name = p.parseIdentAsString() + owner.Name, owner.NameQuote = p.parseIdentAsStringQuoted() op.Owner = owner default: diff --git a/pkg/sql/parser/alter_table.go b/pkg/sql/parser/alter_table.go index cd4cceef..95b7bd18 100644 --- a/pkg/sql/parser/alter_table.go +++ b/pkg/sql/parser/alter_table.go @@ -395,7 +395,7 @@ func (p *Parser) parseAlterAddIndex(op *ast.AlterTableOperation) (*ast.AlterTabl } if !p.isType(models.TokenTypeLParen) && !p.isType(models.TokenTypeUsing) { if name := p.parseColumnName(); name != nil { - op.IndexName = name.Name + op.IndexName, op.IndexNameQuote = name.Name, name.Quotes } } return p.parseAlterTail(op) @@ -460,7 +460,7 @@ func (p *Parser) parseAlterDrop(op *ast.AlterTableOperation) (*ast.AlterTableOpe if name == nil { return nil, p.expectedError("index name") } - op.IndexName = name.Name + op.IndexName, op.IndexNameQuote = name.Name, name.Quotes return op, nil } } diff --git a/pkg/sql/parser/ddl.go b/pkg/sql/parser/ddl.go index f32a6058..c64b09c5 100644 --- a/pkg/sql/parser/ddl.go +++ b/pkg/sql/parser/ddl.go @@ -426,6 +426,7 @@ func (p *Parser) parsePartitionDefinition() (*ast.PartitionDefinition, error) { return nil, p.expectedError("partition name") } partDef.Name = p.currentToken.Token.Value + partDef.NameQuote = p.currentNameQuote() p.advance() switch w := p.wordAt(0); { diff --git a/pkg/sql/parser/ddl_columns.go b/pkg/sql/parser/ddl_columns.go index 03c90cd5..d4556dd1 100644 --- a/pkg/sql/parser/ddl_columns.go +++ b/pkg/sql/parser/ddl_columns.go @@ -424,7 +424,7 @@ func (p *Parser) parseTypeArgsString() (string, error) { // Render leaf token. Quote string literals; everything else is rendered // by its raw value (numbers, identifiers, keywords like Nullable / Array). val := tok.Value - if val == "" { + if val == "" && !isQuotedTokenType(tok) { return "", p.expectedError("type argument") } @@ -434,19 +434,43 @@ func (p *Parser) parseTypeArgsString() (string, error) { buf.WriteByte(' ') } - switch tok.Type { - case models.TokenTypeString, models.TokenTypeSingleQuotedString, - models.TokenTypeDoubleQuotedString: + switch { + case tok.Type == models.TokenTypeString || tok.Type == models.TokenTypeSingleQuotedString: // Double embedded quotes so 'it''s' is written back as one literal. - buf.WriteByte('\'') - buf.WriteString(strings.ReplaceAll(val, "'", "''")) - buf.WriteByte('\'') - prevWasIdent = false + writeDelimitedToken(&buf, val, '\'') + case tok.Type == models.TokenTypeDoubleQuotedString: + // A "quoted" word is an identifier (or, in MySQL, a string): + // keep its delimiters so neither changes meaning. + writeDelimitedToken(&buf, val, '"') + case tok.Type == models.TokenTypeIdentifier && (tok.Quote == '"' || tok.Quote == '`' || tok.Quote == '['): + writeDelimitedToken(&buf, val, tok.Quote) default: buf.WriteString(val) - prevWasIdent = true } + prevWasIdent = true p.advance() } } + +// writeDelimitedToken writes val delimited with quote (' " ` or [), doubling +// any closing delimiter inside it so the text reads back as one token. +func writeDelimitedToken(buf *strings.Builder, val string, quote rune) { + closer := quote + if quote == '[' { + closer = ']' + } + buf.WriteRune(quote) + buf.WriteString(strings.ReplaceAll(val, string(closer), string(closer)+string(closer))) + buf.WriteRune(closer) +} + +// isQuotedTokenType reports whether tok is a string literal or a delimited +// identifier, whose empty value is still a token. +func isQuotedTokenType(tok models.Token) bool { + switch tok.Type { + case models.TokenTypeString, models.TokenTypeSingleQuotedString, models.TokenTypeDoubleQuotedString: + return true + } + return tok.Quote != 0 +} diff --git a/pkg/sql/parser/ddl_objects.go b/pkg/sql/parser/ddl_objects.go index fe6736cd..ace75c1a 100644 --- a/pkg/sql/parser/ddl_objects.go +++ b/pkg/sql/parser/ddl_objects.go @@ -1007,10 +1007,11 @@ func (p *Parser) parseCreateTableTail(stmt *ast.CreateTableStatement) error { p.advance() // INHERITS p.advance() // ( for { - name, err := p.parseQualifiedName() + name, nq, err := p.parseQualifiedNameQuoted() if err != nil { return p.qualifiedNameError(err, "parent table") } + stmt.InheritsQuotes = nameQuoteList(stmt.InheritsQuotes, len(stmt.Inherits), nq) stmt.Inherits = append(stmt.Inherits, name) if !p.isType(models.TokenTypeComma) { break diff --git a/pkg/sql/parser/ddl_table_constraints.go b/pkg/sql/parser/ddl_table_constraints.go index 41c22f4d..8540a2d2 100644 --- a/pkg/sql/parser/ddl_table_constraints.go +++ b/pkg/sql/parser/ddl_table_constraints.go @@ -304,6 +304,7 @@ func (p *Parser) parseIndexHead(tc *ast.TableConstraint) { if tc.Keyword != "" && p.isWordToken() && !p.isType(models.TokenTypeLParen) && !p.isUnquotedWord("USING") { tc.IndexName = p.currentToken.Token.Value + tc.IndexNameQuote = p.currentNameQuote() p.advance() } p.parseIndexMethod(tc) @@ -417,6 +418,7 @@ func (p *Parser) parseTableElement(tc *ast.TableConstraint) (*ast.TableConstrain p.advance() // Consume PROJECTION tc.Type = "PROJECTION" tc.IndexName = p.currentToken.Token.Value + tc.IndexNameQuote = p.currentNameQuote() p.advance() body, err := p.parseTypeArgsString() if err != nil { @@ -434,6 +436,7 @@ func (p *Parser) parseTableElement(tc *ast.TableConstraint) (*ast.TableConstrain p.parseIndexHead(tc) if tc.Keyword == "" && p.isWordToken() && !p.isType(models.TokenTypeLParen) { tc.IndexName = p.currentToken.Token.Value + tc.IndexNameQuote = p.currentNameQuote() p.advance() } @@ -443,6 +446,7 @@ func (p *Parser) parseTableElement(tc *ast.TableConstraint) (*ast.TableConstrain p.parseIndexMethod(tc) if p.isWordToken() && !p.isType(models.TokenTypeLParen) && !p.isUnquotedWord("USING") { tc.IndexName = p.currentToken.Token.Value + tc.IndexNameQuote = p.currentNameQuote() p.advance() } p.parseIndexMethod(tc) @@ -462,6 +466,7 @@ func (p *Parser) parseClickHouseIndex(tc *ast.TableConstraint) (*ast.TableConstr return nil, p.expectedError("index name") } tc.IndexName = p.currentToken.Token.Value + tc.IndexNameQuote = p.currentNameQuote() p.advance() expr, err := p.parseExpression() if err != nil { diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 786e426e..b696c6ac 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -1095,6 +1095,16 @@ func (p *Parser) parseIdentAsString() string { return ident.Name } +// parseIdentAsStringQuoted is parseIdentAsString that also returns how the +// name was delimited (see ast.NameQuote). +func (p *Parser) parseIdentAsStringQuoted() (string, ast.NameQuote) { + ident := p.parseIdent() + if ident == nil { + return "", 0 + } + return ident.Name, ident.Quotes +} + // parseBareWordAsString parses any word-like token (identifier or keyword) and // returns its value as a string. This is used in contexts where arbitrary // user-defined names may collide with SQL keywords (e.g. DCPROPERTIES keys). From 4fd2a9a8ad2962f2e34733f1a0a6d3893e06ef76 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 23:13:06 +0530 Subject: [PATCH 52/95] fix(formatter,parser,cli): dialect-aware Format, hoist WITH over set operations, place nested hints - formatter.Format tokenizes and parses with Options.Dialect, so dialect-only literals and syntax (PostgreSQL 'C:\dir', MySQL 'it\'s', ClickHouse ENGINE) no longer fail in the no-dialect front end. An unknown dialect is ignored. - A tree parsed with equal set-operator precedence (Oracle, SQLite) whose first SELECT has a WITH clause changed meaning when written for standard precedence (A UNION B INTERSECT C regrouped, or the CTEs went out of scope). Renderers now write the WITH clause before the whole chain and parenthesize the left operand (ast.HoistLeadingWith). The parser accepts WITH ... (query) ... and records it in SetOperation.LeftWithHoisted so it round-trips. - Optimizer hints after a nested SELECT/INSERT/UPDATE/DELETE/MERGE keyword (CTE bodies, subqueries, union branches) are put back after the same keyword in the rendered statement when its occurrence count matches; otherwise the CLI still refuses. Fixes realworld/oracle/08_complex_nested.sql. - CLI string-literal guard understands that MySQL, MariaDB and PostgreSQL read adjacent literals ('a' 'b') as one. Fixes realworld/mysql/10_misc_functions.sql. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- cmd/gosqlx/cmd/cli_e2e_test.go | 2 +- cmd/gosqlx/cmd/format_corpus_test.go | 5 +- cmd/gosqlx/cmd/formatter.go | 44 +++++ pkg/formatter/comments.go | 221 +++++++++++++++++++++--- pkg/formatter/comments_test.go | 44 ++++- pkg/formatter/formatter.go | 13 ++ pkg/formatter/formatter_dialect_test.go | 51 ++++++ pkg/formatter/render.go | 13 ++ pkg/formatter/setop_with_hoist_test.go | 142 +++++++++++++++ pkg/gosqlx/format_comments_test.go | 12 +- pkg/sql/ast/ast_statements.go | 5 + pkg/sql/ast/doc.go | 1 + pkg/sql/ast/set_operation_render.go | 75 ++++++-- pkg/sql/ast/sql.go | 15 ++ pkg/sql/ast/table_factor_test.go | 67 ++++++- pkg/sql/parser/cte.go | 69 ++++++++ 16 files changed, 733 insertions(+), 46 deletions(-) create mode 100644 pkg/formatter/formatter_dialect_test.go create mode 100644 pkg/formatter/setop_with_hoist_test.go diff --git a/cmd/gosqlx/cmd/cli_e2e_test.go b/cmd/gosqlx/cmd/cli_e2e_test.go index ecafa3bd..af536e33 100644 --- a/cmd/gosqlx/cmd/cli_e2e_test.go +++ b/cmd/gosqlx/cmd/cli_e2e_test.go @@ -229,7 +229,7 @@ func TestCLI_FormatKeepsCommentsAndHints(t *testing.T) { } // A hint that cannot be put back refuses the file and leaves it alone. - original := "SELECT * FROM (SELECT /*+ FULL(t) */ a FROM t) s -- keep\n" + original := "SELECT a FROM /*+ FULL(t) */ t -- keep\n" path := writeTemp(t, "hint.sql", original) res = runCLI(t, nil, "format", "-i", path) if res.err == nil { diff --git a/cmd/gosqlx/cmd/format_corpus_test.go b/cmd/gosqlx/cmd/format_corpus_test.go index e1f310c5..b624e12f 100644 --- a/cmd/gosqlx/cmd/format_corpus_test.go +++ b/cmd/gosqlx/cmd/format_corpus_test.go @@ -29,10 +29,7 @@ import ( // formatted output does not yet round-trip because of a pkg/formatter or SQL() // rendering gap. For these the CLI must refuse to format (return an error) // rather than emit changed SQL; once the gap is fixed they simply pass. -var corpusFormatKnownGaps = map[string]string{ - "realworld/mysql/10_misc_functions.sql": "follow-up: a string literal is rewritten by the formatter", - "realworld/oracle/08_complex_nested.sql": "follow-up: an optimizer hint inside a CTE body (/*+ MATERIALIZE */) has no place in the re-rendered statement, so the CLI refuses rather than move it", -} +var corpusFormatKnownGaps = map[string]string{} // corpusDialect maps a testdata path to the dialect its SQL is written in. func corpusDialect(rel string) string { diff --git a/cmd/gosqlx/cmd/formatter.go b/cmd/gosqlx/cmd/formatter.go index c6b22ba0..ba96e5a7 100644 --- a/cmd/gosqlx/cmd/formatter.go +++ b/cmd/gosqlx/cmd/formatter.go @@ -416,6 +416,9 @@ func checkQuotedTokens(input, output []models.TokenWithSpan, dialect string) err } defer release() + input = mergeAdjacentStrings(input, dialect) + output = mergeAdjacentStrings(output, dialect) + pool := make(map[string]int) for _, t := range output { if isStringLiteralToken(t.Token) || bw.isQuotedIdent(t.Token) { @@ -447,6 +450,47 @@ func checkQuotedTokens(input, output []models.TokenWithSpan, dialect string) err return nil } +// mergeAdjacentStrings returns toks with each run of adjacent single-quoted +// string tokens merged into one token, for the dialects whose parser reads +// 'a' 'b' as the literal 'ab' (MySQL, MariaDB, PostgreSQL). The formatter +// writes the merged literal, which means the same. Other dialects, and toks +// without such a run, are returned as they are. +func mergeAdjacentStrings(toks []models.TokenWithSpan, dialect string) []models.TokenWithSpan { + switch strings.ToLower(dialect) { + case "mysql", "mariadb", "postgresql", "postgres": + default: + return toks + } + plain := func(t models.Token) bool { + return t.Type == models.TokenTypeString || t.Type == models.TokenTypeSingleQuotedString + } + var out []models.TokenWithSpan + for i := 0; i < len(toks); i++ { + if !plain(toks[i].Token) || i+1 >= len(toks) || !plain(toks[i+1].Token) { + if out != nil { + out = append(out, toks[i]) + } + continue + } + if out == nil { + out = append(make([]models.TokenWithSpan, 0, len(toks)), toks[:i]...) + } + merged := toks[i] + var sb strings.Builder + sb.WriteString(merged.Token.Value) + for i+1 < len(toks) && plain(toks[i+1].Token) { + i++ + sb.WriteString(toks[i].Token.Value) + } + merged.Token.Value = sb.String() + out = append(out, merged) + } + if out == nil { + return toks + } + return out +} + // checkMySQLDoubleQuotes requires MySQL/MariaDB output to keep exactly the // input's double-quoted tokens. MySQL reads "..." as a string literal unless // ANSI_QUOTES is set, while the parser treats it as an identifier, so the diff --git a/pkg/formatter/comments.go b/pkg/formatter/comments.go index 2af2d3b8..17d843e0 100644 --- a/pkg/formatter/comments.go +++ b/pkg/formatter/comments.go @@ -17,6 +17,7 @@ package formatter import ( "errors" "fmt" + "sort" "strings" "github.com/ajitpratap0/GoSQLX/pkg/models" @@ -58,8 +59,10 @@ type CommentLayout struct { // source order. The renderer lays statements out afresh, so there is no // position inside them to return to. // - A comment after the last statement stays after it. -// - An optimizer hint directly after the statement's first keyword -// (SELECT /*+ ... */) stays there. A hint anywhere else, and a MySQL +// - An optimizer hint directly after a SELECT, INSERT, UPDATE, DELETE, +// MERGE or REPLACE keyword (SELECT /*+ ... */, also one inside a CTE, +// subquery or union branch) stays there, provided the rendered statement +// has the same number of that keyword. A hint anywhere else, and a MySQL // executable comment (/*! ... */) inside a statement, make the call fail // with ErrUnplaceableComment. An executable comment followed by ';' on its // own is a statement of its own and is kept verbatim with its ';'. @@ -89,7 +92,7 @@ func JoinWithComments(stmts []string, tokens []models.TokenWithSpan, comments [] type attach struct { leading []string trailing []string - hints []string + hints []placedHint } att := make([]attach, len(items)) var tail []string @@ -113,9 +116,11 @@ func JoinWithComments(stmts []string, tokens []models.TokenWithSpan, comments [] case roleInner: it := items[idx] if sig { - if isHint(c.Text) && it.hintSlot(tokens, c) { - att[idx].hints = append(att[idx].hints, c.Text) - continue + if isHint(c.Text) { + if h, ok := it.hintSlot(tokens, c); ok { + att[idx].hints = append(att[idx].hints, h) + continue + } } return "", fmt.Errorf("%w: %s", ErrUnplaceableComment, abbreviate(c.Text)) } @@ -159,7 +164,7 @@ func JoinWithComments(stmts []string, tokens []models.TokenWithSpan, comments [] si++ if len(a.hints) > 0 { var err error - if stmt, err = insertHints(stmt, tokens[it.first].Token.Value, a.hints); err != nil { + if stmt, err = insertHints(stmt, a.hints); err != nil { return "", err } } @@ -287,21 +292,86 @@ func splitItems(tokens []models.TokenWithSpan) []item { return items } -// hintSlot reports whether c lies between the statement's first token and the -// token after it, and that first token is a keyword that can carry a hint. -func (it item) hintSlot(tokens []models.TokenWithSpan, c models.Comment) bool { - if it.empty || it.first < 0 || it.first+1 >= len(tokens) { - return false +// placedHint is an optimizer hint written right after a statement keyword: +// the ordinal-th occurrence (0-based) of keyword among the total occurrences +// of that word in the statement, e.g. the hint in +// "WITH c AS (SELECT /*+ MATERIALIZE */ ...) SELECT ..." is the first of two +// SELECT keywords. +type placedHint struct { + text string + keyword string + ordinal int + total int +} + +// hintSlot reports whether c lies directly after a keyword that can carry a +// hint (SELECT, INSERT, UPDATE, DELETE, MERGE, REPLACE) of the statement, and +// where that keyword is among the statement's occurrences of the same word. +func (it item) hintSlot(tokens []models.TokenWithSpan, c models.Comment) (placedHint, bool) { + if it.empty || it.first < 0 || it.first == it.last { + return placedHint{}, false + } + for j := it.first; j < it.last; j++ { + kw, ok := hintKeyword(tokens[j].Token) + if !ok || before(c.Start, tokens[j].End) { + continue + } + if !before(c.Start, tokens[j+1].Start) { + continue + } + h := placedHint{text: c.Text, keyword: kw} + for k := it.first; k <= it.last; k++ { + if w, isWord := wordToken(tokens[k].Token); isWord && strings.EqualFold(w, kw) { + if k < j { + h.ordinal++ + } + h.total++ + } + } + return h, true } - switch strings.ToUpper(tokens[it.first].Token.Value) { + return placedHint{}, false +} + +// hintKeyword returns the upper-case keyword of tok when it is an unquoted +// statement keyword that can carry an optimizer hint. +func hintKeyword(tok models.Token) (string, bool) { + w, ok := wordToken(tok) + if !ok { + return "", false + } + switch kw := strings.ToUpper(w); kw { case "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE": - default: - return false + return kw, true + } + return "", false +} + +// wordToken returns the text of tok when it is an unquoted word (a keyword or +// bare identifier), not a string literal or quoted name. +func wordToken(tok models.Token) (string, bool) { + if tok.Quote != 0 || tok.Value == "" { + return "", false } - if it.first == it.last { - return false + switch tok.Type { + case models.TokenTypeString, models.TokenTypeSingleQuotedString, + models.TokenTypeTripleSingleQuotedString, models.TokenTypeTripleDoubleQuotedString, + models.TokenTypeDollarQuotedString, models.TokenTypeByteStringLiteral, + models.TokenTypeNationalStringLiteral, models.TokenTypeEscapedStringLiteral, + models.TokenTypeUnicodeStringLiteral, models.TokenTypeHexStringLiteral, + models.TokenTypeNumber: + return "", false } - return !before(c.Start, tokens[it.first].End) && before(c.Start, tokens[it.first+1].Start) + for i := 0; i < len(tok.Value); i++ { + if !isWordByte(tok.Value[i]) { + return "", false + } + } + return tok.Value, true +} + +func isWordByte(b byte) bool { + return b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '_' || b == '$' || b >= 0x80 } type commentRole int @@ -366,9 +436,57 @@ func isSignificantComment(text string) bool { return isHint(text) || isLineHint(text) || isExecutable(text) } -// insertHints writes hints after the first word of the rendered statement: -// "SELECT a FROM t" becomes "SELECT /*+ H */ a FROM t". -func insertHints(stmt, keyword string, hints []string) (string, error) { +// insertHints writes each hint after the keyword it followed in the source: +// "SELECT a FROM t" becomes "SELECT /*+ H */ a FROM t". A hint on the first +// word of the statement needs the rendered statement to start with that word. +// A hint on a later keyword (a SELECT inside a CTE or subquery) needs the +// rendered statement to have as many occurrences of the word as the source, +// and goes after the same one; otherwise the hint has no place to go and the +// call fails with ErrUnplaceableComment. +func insertHints(stmt string, hints []placedHint) (string, error) { + type insertion struct { + at int // byte offset just after the keyword + text string + } + var ins []insertion + words := map[string][]int{} // upper-case word -> end offsets in stmt + scanned := false + for _, h := range hints { + if h.ordinal == 0 && h.total >= 1 { + if end, ok := leadingWordEnd(stmt, h.keyword); ok { + ins = append(ins, insertion{end, h.text}) + continue + } + } + if !scanned { + words = wordEnds(stmt) + scanned = true + } + ends := words[h.keyword] + if len(ends) != h.total || h.ordinal >= len(ends) { + return "", fmt.Errorf("%w: %s (rendered statement does not keep the %s it follows)", ErrUnplaceableComment, abbreviate(h.text), h.keyword) + } + ins = append(ins, insertion{ends[h.ordinal], h.text}) + } + // Insert from the end so earlier offsets stay valid; hints at one offset + // keep their source order. + sort.SliceStable(ins, func(a, b int) bool { return ins[a].at > ins[b].at }) + for i := 0; i < len(ins); { + j := i + var group []string + for j < len(ins) && ins[j].at == ins[i].at { + group = append(group, ins[j].text) + j++ + } + stmt = stmt[:ins[i].at] + " " + strings.Join(group, " ") + stmt[ins[i].at:] + i = j + } + return stmt, nil +} + +// leadingWordEnd returns the end offset of the first word of stmt when it is +// keyword. +func leadingWordEnd(stmt, keyword string) (int, bool) { i := 0 for i < len(stmt) && (stmt[i] == ' ' || stmt[i] == '\t' || stmt[i] == '\n' || stmt[i] == '\r') { i++ @@ -377,10 +495,65 @@ func insertHints(stmt, keyword string, hints []string) (string, error) { for j < len(stmt) && (stmt[j] >= 'A' && stmt[j] <= 'Z' || stmt[j] >= 'a' && stmt[j] <= 'z') { j++ } - if !strings.EqualFold(stmt[i:j], keyword) { - return "", fmt.Errorf("%w: %s (rendered statement does not start with %s)", ErrUnplaceableComment, abbreviate(hints[0]), keyword) + return j, strings.EqualFold(stmt[i:j], keyword) +} + +// wordEnds scans rendered SQL and returns, for each hint-carrying keyword, the +// end offsets of its occurrences as a bare word, skipping string literals, +// quoted names and comments, and words that follow '.', '@' or ':'. +func wordEnds(stmt string) map[string][]int { + out := map[string][]int{} + n := len(stmt) + for i := 0; i < n; { + c := stmt[i] + switch { + case c == '\'' || c == '"' || c == '`': + // Quoted text; a doubled quote or a backslash escape stays inside. + i++ + for i < n { + if stmt[i] == '\\' && c == '\'' && i+1 < n { + i += 2 + continue + } + if stmt[i] == c { + if i+1 < n && stmt[i+1] == c { + i += 2 + continue + } + break + } + i++ + } + i++ + case c == '-' && i+1 < n && stmt[i+1] == '-': + for i < n && stmt[i] != '\n' { + i++ + } + case c == '/' && i+1 < n && stmt[i+1] == '*': + end := strings.Index(stmt[i+2:], "*/") + if end < 0 { + i = n + } else { + i += end + 4 + } + case isWordByte(c): + j := i + for j < n && isWordByte(stmt[j]) { + j++ + } + if i == 0 || (stmt[i-1] != '.' && stmt[i-1] != '@' && stmt[i-1] != ':') { + w := strings.ToUpper(stmt[i:j]) + switch w { + case "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE": + out[w] = append(out[w], j) + } + } + i = j + default: + i++ + } } - return stmt[:j] + " " + strings.Join(hints, " ") + stmt[j:], nil + return out } func abbreviate(s string) string { diff --git a/pkg/formatter/comments_test.go b/pkg/formatter/comments_test.go index c5482279..4b61edf8 100644 --- a/pkg/formatter/comments_test.go +++ b/pkg/formatter/comments_test.go @@ -75,6 +75,15 @@ func TestJoinWithComments(t *testing.T) { {"hint after first keyword stays", "select /*+ FULL(t) */ a from t", "SELECT /*+ FULL(t) */ a FROM t"}, {"hint plus comment", "select /*+ FULL(t) */ a from t -- note", "SELECT /*+ FULL(t) */ a FROM t -- note"}, {"hint on insert", "insert /*+ APPEND */ into t (a) values (1)", "INSERT /*+ APPEND */ INTO t (a) VALUES (1)"}, + {"hint in subquery", "select * from (select /*+ x */ 1) s", "SELECT * FROM (SELECT /*+ x */ 1) s"}, + {"hint in union branch", "select 1 union select /*+ x */ 2", "SELECT 1 union SELECT /*+ x */ 2"}, + {"hint inside a CTE body", + "with c as (select /*+ MATERIALIZE */ * from big where f = 'select') select count(*) from c", + "WITH c AS (SELECT /*+ MATERIALIZE */ * FROM big WHERE f = 'select') SELECT count(*) FROM c"}, + {"hints on the CTE and the main query", + "with c as (select /*+ a */ 1) select /*+ b */ * from c", + "WITH c AS (SELECT /*+ a */ 1) SELECT /*+ b */ * FROM c"}, + {"two hints on one keyword keep their order", "select /*+ a */ /*+ b */ 1", "SELECT /*+ a */ /*+ b */ 1"}, {"hint on second statement", "select 1; select /*+ x */ 2", "SELECT 1;\nSELECT /*+ x */ 2"}, {"executable comment statement kept with its semicolon", "/*!40101 SET x = 1 */;\nselect 1", "/*!40101 SET x = 1 */;\nSELECT 1"}, @@ -97,9 +106,7 @@ func TestJoinWithComments(t *testing.T) { func TestJoinWithComments_RefusesWhatItCannotPlace(t *testing.T) { layout := CommentLayout{Terminator: ";", Gap: "\n"} for name, sql := range map[string]string{ - "hint in subquery": "select * from (select /*+ x */ 1) s", "hint after from": "select a from /*+ x */ t", - "hint in union branch": "select 1 union select /*+ x */ 2", "executable in stmt": "select 1 /*!50000 + 1 */", "executable before stmt": "/*!40101 SET x = 1 */ select 1", "oracle line hint": "select --+ FULL(t)\n a from t", @@ -203,3 +210,36 @@ func TestJoinWithComments_NoComments(t *testing.T) { t.Fatalf("got %q", got) } } + +// TestInsertHints_RefusesWhenKeywordMoved checks that a hint on a nested +// keyword is only placed when the rendered statement has the same number of +// that keyword, so it can never land on a different one. +func TestInsertHints_RefusesWhenKeywordMoved(t *testing.T) { + h := []placedHint{{text: "/*+ x */", keyword: "SELECT", ordinal: 1, total: 2}} + tests := []struct { + name string + stmt string + want string + ok bool + }{ + {"same count", "WITH c AS (SELECT 1) SELECT 2", "WITH c AS (SELECT 1) SELECT /*+ x */ 2", true}, + {"words in strings and comments are skipped", "WITH c AS (SELECT 'select') /* select */ SELECT 2", "WITH c AS (SELECT 'select') /* select */ SELECT /*+ x */ 2", true}, + {"quoted names are skipped", `WITH "select" AS (SELECT 1) SELECT 2`, `WITH "select" AS (SELECT 1) SELECT /*+ x */ 2`, true}, + {"fewer keywords", "SELECT 2", "", false}, + {"more keywords", "SELECT 1 UNION SELECT 2 UNION SELECT 3", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := insertHints(tc.stmt, h) + if !tc.ok { + if !errors.Is(err, ErrUnplaceableComment) { + t.Fatalf("err = %v (output %q), want ErrUnplaceableComment", err, got) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("insertHints = %q, %v; want %q", got, err, tc.want) + } + }) + } +} diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index eb5da4c5..1b8dc15c 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -95,6 +95,7 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" ) @@ -126,8 +127,17 @@ func (f *Formatter) Format(sql string) (string, error) { return "", nil } + // The dialect selects the tokenizer and parser rules (string escapes, + // quoting, dialect-only syntax) as well as the rendering. An unknown + // dialect name is ignored for tokenizing and parsing, as before. + dialect := keywords.SQLDialect(strings.ToLower(f.opts.Dialect)) + useDialect := dialect != "" && keywords.IsValidDialect(string(dialect)) + tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) + if useDialect { + tkz.SetDialect(dialect) + } tokens, err := tkz.TokenizeString(sql) if err != nil { @@ -143,6 +153,9 @@ func (f *Formatter) Format(sql string) (string, error) { p := parser.GetParser() defer parser.PutParser(p) + if useDialect { + p.ApplyOptions(parser.WithDialect(string(dialect))) + } parsedAST, err := p.ParseFromModelTokens(tokens) if err != nil { return "", fmt.Errorf("parsing failed: %w", err) diff --git a/pkg/formatter/formatter_dialect_test.go b/pkg/formatter/formatter_dialect_test.go new file mode 100644 index 00000000..95dc3b25 --- /dev/null +++ b/pkg/formatter/formatter_dialect_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/formatter" +) + +// TestFormatter_Format_UsesDialect checks that Formatter.Format tokenizes and +// parses with Options.Dialect, not only renders with it: strings and syntax +// that are valid in the dialect must not fail in the no-dialect front end. +func TestFormatter_Format_UsesDialect(t *testing.T) { + tests := []struct { + name string + dialect string + in string + want string + }{ + {"postgres backslash literal", "postgresql", `SELECT 'C:\dir'`, `SELECT 'C:\dir'`}, + {"mysql escaped quote", "mysql", `SELECT 'it\'s'`, `SELECT 'it''s'`}, + {"mysql dialect name case", "MySQL", `SELECT 'it\'s'`, `SELECT 'it''s'`}, + {"clickhouse engine", "clickhouse", `CREATE TABLE t (a Int32) ENGINE = MergeTree ORDER BY a`, `CREATE TABLE t (a Int32) ENGINE=MergeTree ORDER BY a`}, + {"unknown dialect ignored", "nosuchdb", `SELECT a FROM t`, `SELECT a FROM t`}, + {"no dialect", "", `SELECT a FROM t`, `SELECT a FROM t`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := formatter.New(formatter.Options{Compact: true, Dialect: tc.dialect}).Format(tc.in) + if err != nil { + t.Fatalf("Format(%q) with dialect %q: %v", tc.in, tc.dialect, err) + } + if got != tc.want { + t.Errorf("Format(%q) with dialect %q\n got: %s\nwant: %s", tc.in, tc.dialect, got, tc.want) + } + }) + } +} diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 7668bdcd..ef64e275 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -583,6 +583,19 @@ func renderSetOperation(s *ast.SetOperation, opts ast.FormatOptions) string { if s == nil { return "" } + // A WITH clause that scopes over the whole operation but would end up + // inside the parentheses of a left operand is written before the chain. + if with, body := ast.HoistLeadingWith(s, opts.Dialect); with != nil { + f := newNodeFormatter(opts) + f.sb.WriteString(renderWith(with, f)) + f.sb.WriteString(f.clauseSep()) + f.sb.WriteString(renderSetOperationBody(body, opts)) + return f.result() + } + return renderSetOperationBody(s, opts) +} + +func renderSetOperationBody(s *ast.SetOperation, opts ast.FormatOptions) string { f := newNodeFormatter(opts) sb := f.sb inner := nestedOpts(opts) diff --git a/pkg/formatter/setop_with_hoist_test.go b/pkg/formatter/setop_with_hoist_test.go new file mode 100644 index 00000000..527668fd --- /dev/null +++ b/pkg/formatter/setop_with_hoist_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatter + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" +) + +// TestSetOpWithHoist checks that a WITH clause scoping over a whole set +// operation stays in front of it when the left operand needs parentheses in +// the target dialect: the clause is hoisted out of the parentheses, so the +// other operands still see its CTEs and the grouping is kept. +func TestSetOpWithHoist(t *testing.T) { + const with = "WITH c AS (SELECT 1 AS a) " + tests := []struct { + name string + parse keywords.SQLDialect + in string + render string // target dialect + want string + reparse bool // the output parses back with the target dialect to the same text + }{ + { + name: "equal precedence tree written for standard precedence", + parse: keywords.DialectSQLite, render: "postgresql", + in: with + "SELECT a FROM c UNION SELECT a FROM c INTERSECT SELECT a FROM c", + want: with + "(SELECT a FROM c UNION SELECT a FROM c) INTERSECT SELECT a FROM c", + reparse: true, + }, + { + name: "equal precedence tree written for its own dialect", + parse: keywords.DialectSQLite, render: "sqlite", + in: with + "SELECT a FROM c UNION SELECT a FROM c INTERSECT SELECT a FROM c", + want: with + "SELECT a FROM c UNION SELECT a FROM c INTERSECT SELECT a FROM c", + reparse: true, + }, + { + name: "no WITH keeps plain parentheses", + parse: keywords.DialectSQLite, render: "postgresql", + in: "SELECT a FROM c UNION SELECT a FROM c INTERSECT SELECT a FROM c", + want: "(SELECT a FROM c UNION SELECT a FROM c) INTERSECT SELECT a FROM c", + reparse: true, + }, + { + name: "WITH written before a parenthesized operand", + parse: keywords.DialectPostgreSQL, render: "postgresql", + in: with + "(SELECT a FROM c UNION SELECT a FROM c) INTERSECT SELECT a FROM c", + want: with + "(SELECT a FROM c UNION SELECT a FROM c) INTERSECT SELECT a FROM c", + reparse: true, + }, + { + name: "WITH before a single parenthesized operand", + parse: keywords.DialectPostgreSQL, render: "postgresql", + in: with + "(SELECT a FROM c) UNION SELECT a FROM c", + want: with + "(SELECT a FROM c) UNION SELECT a FROM c", + reparse: true, + }, + { + name: "WITH before a parenthesized query without set operation", + parse: keywords.DialectPostgreSQL, render: "postgresql", + in: with + "(SELECT a FROM c)", + want: with + "SELECT a FROM c", + }, + { + name: "WITH inside the parentheses stays inside", + parse: keywords.DialectPostgreSQL, render: "postgresql", + in: "(" + with + "SELECT a FROM c UNION SELECT 2) INTERSECT SELECT 3", + want: "(" + with + "SELECT a FROM c UNION SELECT 2) INTERSECT SELECT 3", + reparse: true, + }, + { + name: "chain with standard precedence keeps its WITH in place", + parse: keywords.DialectPostgreSQL, render: "postgresql", + in: with + "SELECT a FROM c UNION SELECT a FROM c INTERSECT SELECT a FROM c", + want: with + "SELECT a FROM c UNION SELECT a FROM c INTERSECT SELECT a FROM c", + reparse: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := parser.ParseWithDialect(tc.in, tc.parse) + if err != nil { + t.Fatalf("parse %q: %v", tc.in, err) + } + defer ast.ReleaseAST(tree) + opts := ast.CompactStyle() + opts.Dialect = tc.render + if got := FormatAST(tree, opts); got != tc.want { + t.Errorf("FormatAST(%q)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + if tc.render == "postgresql" { + // SQL() writes with standard precedence. + if got := tree.SQL(); got != tc.want { + t.Errorf("tree.SQL(%q)\n got: %s\nwant: %s", tc.in, got, tc.want) + } + } + if !tc.reparse { + return + } + out := FormatAST(tree, opts) + tree2, err := parser.ParseWithDialect(out, keywords.SQLDialect(tc.render)) + if err != nil { + t.Fatalf("output %q does not re-parse: %v", out, err) + } + defer ast.ReleaseAST(tree2) + if got := FormatAST(tree2, opts); got != out { + t.Errorf("output %q re-renders as %q", out, got) + } + }) + } +} + +// TestParseWithParenthesizedQuery_Rejects checks the forms the parser refuses +// instead of misplacing a WITH clause. +func TestParseWithParenthesizedQuery_Rejects(t *testing.T) { + for _, in := range []string{ + "WITH c AS (SELECT 1) (WITH d AS (SELECT 2) SELECT 3) UNION SELECT 4", + "WITH c AS (SELECT 1) (VALUES (1)) UNION SELECT 4", + } { + tree, err := parser.ParseWithDialect(in, keywords.DialectPostgreSQL) + if err == nil { + ast.ReleaseAST(tree) + t.Errorf("parse %q: expected an error", in) + } + } +} diff --git a/pkg/gosqlx/format_comments_test.go b/pkg/gosqlx/format_comments_test.go index d33d95dc..b98fba28 100644 --- a/pkg/gosqlx/format_comments_test.go +++ b/pkg/gosqlx/format_comments_test.go @@ -56,12 +56,22 @@ func TestFormat_PreservesCommentsAndHints(t *testing.T) { } func TestFormat_RefusesToDropAnOptimizerHint(t *testing.T) { - _, err := Format("select * from (select /*+ FULL(t) */ a from t) s", DefaultFormatOptions()) + _, err := Format("select a from /*+ FULL(t) */ t", DefaultFormatOptions()) if !errors.Is(err, formatter.ErrUnplaceableComment) { t.Fatalf("err = %v, want it to wrap formatter.ErrUnplaceableComment", err) } } +func TestFormat_KeepsHintInSubquery(t *testing.T) { + got, err := Format("select * from (select /*+ FULL(t) */ a from t) s", DefaultFormatOptions()) + if err != nil { + t.Fatalf("Format: %v", err) + } + if !strings.Contains(got, "SELECT /*+ FULL(t) */ a") { + t.Fatalf("got %q, lost the hint after the inner SELECT", got) + } +} + func TestFormat_AddSemicolonWithComments(t *testing.T) { opts := DefaultFormatOptions() opts.AddSemicolon = true diff --git a/pkg/sql/ast/ast_statements.go b/pkg/sql/ast/ast_statements.go index add17cdf..bba0331b 100644 --- a/pkg/sql/ast/ast_statements.go +++ b/pkg/sql/ast/ast_statements.go @@ -48,6 +48,11 @@ type SetOperation struct { All bool // UNION ALL vs UNION // LeftParenthesized reports that Left was written as "(query)". LeftParenthesized bool + // LeftWithHoisted reports that the WITH clause of Left's first SELECT was + // written before the parenthesized Left ("WITH c AS (...) (A UNION B) + // INTERSECT C"), so it scopes over the whole set operation; see + // HoistLeadingWith. + LeftWithHoisted bool // RightParenthesized reports that Right was written as "(query)". RightParenthesized bool // Distinct reports an explicit DISTINCT quantifier (UNION DISTINCT, diff --git a/pkg/sql/ast/doc.go b/pkg/sql/ast/doc.go index 69ca5153..707fd302 100644 --- a/pkg/sql/ast/doc.go +++ b/pkg/sql/ast/doc.go @@ -347,6 +347,7 @@ // Right Statement // Right statement // All bool // ALL modifier (UNION ALL vs UNION) // LeftParenthesized bool // Left was written as "(query)" +// LeftWithHoisted bool // WITH was written before the parenthesized Left // RightParenthesized bool // Right was written as "(query)" // } // diff --git a/pkg/sql/ast/set_operation_render.go b/pkg/sql/ast/set_operation_render.go index 2aa34638..9b1544a6 100644 --- a/pkg/sql/ast/set_operation_render.go +++ b/pkg/sql/ast/set_operation_render.go @@ -45,9 +45,9 @@ func SetOperatorsEqualPrecedence(dialect string) bool { // the precedence of the target dialect, as for a tree parsed with // equal-precedence rules (Oracle, SQLite: (A UNION B) INTERSECT C) and // written for another dialect. The parser builds left-deep trees for -// operators of equal precedence. A WITH clause on the first SELECT keeps the -// operand unparenthesized, since the parentheses would move it inside and -// narrow its scope. +// operators of equal precedence. A WITH clause on the first SELECT would end +// up inside those parentheses and narrow its scope, so renderers write it +// before the whole chain instead (see HoistLeadingWith). func SetOperandNeedsParens(s *SetOperation, right bool, dialect string) bool { if s == nil { return false @@ -60,8 +60,7 @@ func SetOperandNeedsParens(s *SetOperation, right bool, dialect string) bool { if !ok || child == nil || SetOperatorsEqualPrecedence(dialect) { return false } - return setOperatorPrecedence(child.Operator) < setOperatorPrecedence(s.Operator) && - !setOperationLeadsWithCTE(child) + return setOperatorPrecedence(child.Operator) < setOperatorPrecedence(s.Operator) } if s.RightParenthesized { return true @@ -77,19 +76,69 @@ func SetOperandNeedsParens(s *SetOperation, right bool, dialect string) bool { return setOperatorPrecedence(child.Operator) <= setOperatorPrecedence(s.Operator) } -// setOperationLeadsWithCTE reports whether the first SELECT of s carries a -// WITH clause. -func setOperationLeadsWithCTE(s *SetOperation) bool { - var left Statement = s +// HoistLeadingWith returns the WITH clause of the first SELECT of s together +// with a copy of s whose first SELECT has no WITH clause, when renderers must +// write the clause before the whole set operation: some left operand of s is +// written in parentheses (it was written as "WITH c ... (A UNION B) INTERSECT +// C" and recorded in LeftWithHoisted, or it needs them under the precedence +// of dialect, as for a tree parsed with the equal-precedence rules of Oracle +// and SQLite), and the clause scopes over the whole set operation. Writing +// it inside the parentheses would hide its CTEs from the other operands. +// +// A WITH clause that was written inside parentheses in the source +// ("(WITH c ... A) UNION B"), or a set operation without a WITH clause or +// without a parenthesized left operand, yields (nil, s). The copy shares all +// other nodes with s; s is not modified. +func HoistLeadingWith(s *SetOperation, dialect string) (*WithClause, *SetOperation) { + if s == nil { + return nil, s + } + var first Statement = s for { - op, ok := left.(*SetOperation) + op, ok := first.(*SetOperation) + if !ok || op == nil { + break + } + first = op.Left + } + sel, ok := first.(*SelectStatement) + if !ok || sel == nil || sel.With == nil { + return nil, s + } + // Levels of the left spine, outermost first. + var spine []*SetOperation + for cur := Statement(s); ; { + op, ok := cur.(*SetOperation) if !ok || op == nil { break } - left = op.Left + spine = append(spine, op) + cur = op.Left + } + needs := false + for i := len(spine) - 1; i >= 0; i-- { + level := spine[i] + if level.LeftParenthesized && !level.LeftWithHoisted { + return nil, s // the WITH clause is scoped by the source's parentheses + } + if level.LeftParenthesized || SetOperandNeedsParens(level, false, dialect) { + needs = true + } + } + if !needs { + return nil, s + } + copies := make([]SetOperation, len(spine)) + for i, level := range spine { + copies[i] = *level + if i > 0 { + copies[i-1].Left = &copies[i] + } } - sel, ok := left.(*SelectStatement) - return ok && sel != nil && sel.With != nil + bare := *sel + bare.With = nil + copies[len(copies)-1].Left = &bare + return sel.With, &copies[0] } // SetOperationKeyword returns the operator of s as written by the renderers, diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 8e88887d..720dc499 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1674,10 +1674,25 @@ func (s *SetOperation) writeSQL(buf *bytes.Buffer) { if s == nil { return } + if with, body := HoistLeadingWith(s, ""); with != nil { + with.writeSQL(buf) + buf.WriteByte(' ') + body.writeBody(buf) + return + } + s.writeBody(buf) +} + +// writeBody writes the operation without looking for a WITH clause to hoist; +// a left operand that is a set operation is a link of the same chain, not a +// new one, unless it is parenthesized. +func (s *SetOperation) writeBody(buf *bytes.Buffer) { if SetOperandNeedsParens(s, false, "") { buf.WriteByte('(') writeStmt(buf, s.Left) buf.WriteByte(')') + } else if left, ok := s.Left.(*SetOperation); ok && left != nil { + left.writeBody(buf) } else { writeStmt(buf, s.Left) } diff --git a/pkg/sql/ast/table_factor_test.go b/pkg/sql/ast/table_factor_test.go index 9ead058c..98c57907 100644 --- a/pkg/sql/ast/table_factor_test.go +++ b/pkg/sql/ast/table_factor_test.go @@ -108,7 +108,7 @@ func TestSetOperandNeedsParens(t *testing.T) { {"right parenthesized select", &SetOperation{Left: sel, Operator: "UNION", Right: sel, RightParenthesized: true}, true, "", true}, {"left looser set operation", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel}, false, "", true}, {"left looser set operation oracle", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel}, false, "oracle", false}, - {"left looser set operation with CTE", &SetOperation{Left: &SetOperation{Left: &SelectStatement{With: &WithClause{}}, Operator: "UNION", Right: sel}, Operator: "INTERSECT", Right: sel}, false, "", false}, + {"left looser set operation with CTE (hoisted by HoistLeadingWith)", &SetOperation{Left: &SetOperation{Left: &SelectStatement{With: &WithClause{}}, Operator: "UNION", Right: sel}, Operator: "INTERSECT", Right: sel}, false, "", true}, {"left equal set operation", &SetOperation{Left: union, Operator: "EXCEPT", Right: sel}, false, "", false}, {"left tighter set operation", &SetOperation{Left: inter, Operator: "UNION", Right: sel}, false, "", false}, {"left parenthesized", &SetOperation{Left: union, Operator: "INTERSECT", Right: sel, LeftParenthesized: true}, false, "", true}, @@ -168,3 +168,68 @@ func TestReleaseTableReference_PivotExtensions(t *testing.T) { t.Errorf("PivotExtensions not cleared: %+v", pivot.PivotExtensions) } } + +// TestHoistLeadingWith pins when the WITH clause of the first SELECT is moved +// in front of a set operation, and that the original tree is not changed. +func TestHoistLeadingWith(t *testing.T) { + with := &WithClause{} + cte := func() *SelectStatement { return &SelectStatement{With: with} } + sel := &SelectStatement{} + tests := []struct { + name string + s *SetOperation + dialect string + want bool + }{ + {"nil", nil, "", false}, + {"no WITH", &SetOperation{Left: &SetOperation{Left: sel, Operator: "UNION", Right: sel}, Operator: "INTERSECT", Right: sel}, "", false}, + {"chain keeps WITH in place", &SetOperation{Left: cte(), Operator: "UNION", Right: sel}, "", false}, + {"looser left operand", &SetOperation{Left: &SetOperation{Left: cte(), Operator: "UNION", Right: sel}, Operator: "INTERSECT", Right: sel}, "", true}, + {"looser left operand, equal-precedence dialect", &SetOperation{Left: &SetOperation{Left: cte(), Operator: "UNION", Right: sel}, Operator: "INTERSECT", Right: sel}, "oracle", false}, + {"source parentheses scope the WITH", &SetOperation{Left: cte(), Operator: "UNION", Right: sel, LeftParenthesized: true}, "", false}, + {"WITH written before the parentheses", &SetOperation{Left: cte(), Operator: "UNION", Right: sel, LeftParenthesized: true, LeftWithHoisted: true}, "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotWith, body := HoistLeadingWith(tc.s, tc.dialect) + if (gotWith != nil) != tc.want { + t.Fatalf("hoisted = %v, want %v", gotWith != nil, tc.want) + } + if !tc.want { + if body != tc.s { + t.Errorf("body is not the input when nothing is hoisted") + } + return + } + if gotWith != with { + t.Errorf("returned WITH is not the clause of the first SELECT") + } + var first Statement = body + for { + op, ok := first.(*SetOperation) + if !ok { + break + } + first = op.Left + } + sel, ok := first.(*SelectStatement) + if !ok { + t.Fatalf("first operand is %T, want *SelectStatement", first) + } + if sel.With != nil { + t.Errorf("hoisted copy still has the WITH clause") + } + var orig Statement = tc.s + for { + op, ok := orig.(*SetOperation) + if !ok { + break + } + orig = op.Left + } + if os, ok := orig.(*SelectStatement); !ok || os.With != with { + t.Errorf("the input tree was modified") + } + }) + } +} diff --git a/pkg/sql/parser/cte.go b/pkg/sql/parser/cte.go index e3fb4364..3f2a4890 100644 --- a/pkg/sql/parser/cte.go +++ b/pkg/sql/parser/cte.go @@ -68,6 +68,12 @@ func (p *Parser) parseWithStatement() (ast.Statement, error) { Pos: withPos, } + // A parenthesized query after the WITH clause: WITH c AS (...) (A UNION B) + // INTERSECT C. The clause scopes over the whole query expression. + if p.isType(models.TokenTypeLParen) { + return p.parseWithParenthesizedQuery(withClause) + } + // Parse the main statement that follows the WITH clause mainStmt, err := p.parseMainStatementAfterWith() if err != nil { @@ -304,3 +310,66 @@ func (p *Parser) parseMainStatementAfterWith() (ast.Statement, error) { } return nil, p.expectedError("SELECT, INSERT, UPDATE, or DELETE after WITH") } + +// parseWithParenthesizedQuery parses the query expression after a WITH clause +// when it starts with "(", as in +// +// WITH c AS (SELECT 1 AS a) (SELECT a FROM c UNION SELECT a FROM c) INTERSECT SELECT a FROM c +// +// The WITH clause is attached to the first SELECT of the query expression, as +// for an unparenthesized one, and the set operation that owns the +// parenthesized operand records that the clause was written outside the +// parentheses (SetOperation.LeftWithHoisted), so that renderers keep it in +// scope of the other operands. A first SELECT that has a WITH clause of its +// own is rejected. +func (p *Parser) parseWithParenthesizedQuery(with *ast.WithClause) (ast.Statement, error) { + wrap := func(err error) error { + return goerrors.InvalidCTEError( + fmt.Sprintf("error parsing statement after WITH clause: %v", err), + p.currentLocation(), + "", + ) + } + q, err := p.parseParenthesizedQuery("SELECT after WITH") + if err != nil { + return nil, wrap(err) + } + stmt, err := p.parseSetOperationChain(q, true) + if err != nil { + return nil, wrap(err) + } + + // The first SELECT of the whole expression takes the WITH clause. + var first ast.Statement = stmt + for { + op, ok := first.(*ast.SetOperation) + if !ok || op == nil { + break + } + first = op.Left + } + sel, ok := first.(*ast.SelectStatement) + if !ok || sel == nil || sel.With != nil { + return nil, goerrors.UnsupportedFeatureError( + "WITH clause before a parenthesized query that is not a SELECT or already has its own WITH clause", + p.currentLocation(), + "", + ) + } + sel.With = with + + // The operation whose left operand is the parenthesized query is the + // innermost one of the left spine that was written with parentheses. + for cur := stmt; ; { + op, ok := cur.(*ast.SetOperation) + if !ok || op == nil { + break + } + if op.LeftParenthesized { + op.LeftWithHoisted = true + break + } + cur = op.Left + } + return stmt, nil +} From f237faaab5a47d5d5eef1b235fd7efe898f89ffe Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 23:17:20 +0530 Subject: [PATCH 53/95] perf(tokenizer,parser,gosqlx): tokenize internal parse paths into a reusable scratch buffer The package-level parse/validate/format entry points tokenized into a fresh []TokenWithSpan (plus fresh Word chunks) on every call, only for the parser to copy the tokens into its pooled tokenBuf during preprocessing. That slice was ~43% of alloc_space in the real-world parse+format profile. Add internal/tokscratch: GoSQLX's own tokenize-then-parse paths (gosqlx Parse/ParseBytes/ParseWithContext/ParseMultiple/ValidateMultiple/Format/ ParseWithRecovery/ParseTree, parser Validate*/ParseBytes/ParseWithDialect*) now tokenize into a tokenizer-owned slice and Word slab that live only until the next tokenize call, Reset or PutTokenizer. Reset zeroes the used parts and drops a token buffer above 4096 tokens (as Parser.tokenBuf does). The public Tokenize* methods still return caller-owned slices and Words. The string entry points (Validate, ValidateWithDialect, ParseWithDialect) also tokenize the caller's string in place instead of round-tripping through []byte. RealWorldParse: 12.4 MB -> 4.95 MB/op, 58.6k -> 47.7k allocs/op. ParseBytesWithDialect: 7682 -> 3192 B/op, 26 -> 24 allocs/op. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- internal/tokscratch/tokscratch.go | 65 +++++ pkg/gosqlx/gosqlx.go | 20 +- pkg/gosqlx/scratch_tokens_test.go | 139 ++++++++++ pkg/gosqlx/tree.go | 3 +- pkg/sql/parser/error_context_issue616_test.go | 5 +- pkg/sql/parser/validate.go | 113 ++++++--- pkg/sql/parser/validate_string_test.go | 120 +++++++++ pkg/sql/tokenizer/pool.go | 19 ++ pkg/sql/tokenizer/scratch.go | 38 +++ pkg/sql/tokenizer/scratch_test.go | 237 ++++++++++++++++++ pkg/sql/tokenizer/tokenizer.go | 90 ++++++- 11 files changed, 794 insertions(+), 55 deletions(-) create mode 100644 internal/tokscratch/tokscratch.go create mode 100644 pkg/gosqlx/scratch_tokens_test.go create mode 100644 pkg/sql/parser/validate_string_test.go create mode 100644 pkg/sql/tokenizer/scratch.go create mode 100644 pkg/sql/tokenizer/scratch_test.go diff --git a/internal/tokscratch/tokscratch.go b/internal/tokscratch/tokscratch.go new file mode 100644 index 00000000..6c6be6c9 --- /dev/null +++ b/internal/tokscratch/tokscratch.go @@ -0,0 +1,65 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tokscratch lets GoSQLX's own tokenize-then-parse entry points +// tokenize into a buffer owned by the pooled tokenizer instead of a fresh +// slice, without adding that borrowed-result contract to the public +// tokenizer API. +// +// Package tokenizer registers the implementation in its init. Every function +// here takes the *tokenizer.Tokenizer as t (typed any only to avoid an import +// cycle) and returns the same tokens and errors as the matching public method +// (Tokenize, TokenizeString, TokenizeStringContext), with one difference: the +// returned slice, and the Word values its tokens point to, are owned by t. +// They are valid only until the next tokenize call on t, t.Reset or +// tokenizer.PutTokenizer(t). Callers must copy what they keep (the parser's +// preprocess step does) and must never return the slice to a user. Token +// strings are substrings of the input as with the public methods, so they +// stay valid. +package tokscratch + +import ( + "context" + + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +// Func is the registered implementation. When isString is true, src is the +// SQL and input is ignored; otherwise input is the SQL and is copied once +// into a string as Tokenize does. +type Func func(ctx context.Context, t any, input []byte, src string, isString bool) ([]models.TokenWithSpan, error) + +var impl Func + +// Register installs the implementation. It is called once by package +// tokenizer's init and must not be called by anyone else. +func Register(f Func) { impl = f } + +// Bytes is t.Tokenize(input) into t's scratch buffer. +func Bytes(t any, input []byte) ([]models.TokenWithSpan, error) { + return impl(context.Background(), t, input, "", false) +} + +// String is t.TokenizeString(s) into t's scratch buffer. +func String(t any, s string) ([]models.TokenWithSpan, error) { + return impl(context.Background(), t, nil, s, true) +} + +// StringContext is t.TokenizeStringContext(ctx, s) into t's scratch buffer. +func StringContext(ctx context.Context, t any, s string) ([]models.TokenWithSpan, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return impl(ctx, t, nil, s, true) +} diff --git a/pkg/gosqlx/gosqlx.go b/pkg/gosqlx/gosqlx.go index a8f48ec2..6ce7da08 100644 --- a/pkg/gosqlx/gosqlx.go +++ b/pkg/gosqlx/gosqlx.go @@ -20,6 +20,7 @@ import ( "strings" "time" + "github.com/ajitpratap0/GoSQLX/internal/tokscratch" "github.com/ajitpratap0/GoSQLX/pkg/fingerprint" "github.com/ajitpratap0/GoSQLX/pkg/formatter" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" @@ -111,8 +112,9 @@ func Parse(sql string) (*ast.AST, error) { tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) - // Step 2: Tokenize SQL - tokens, err := tkz.TokenizeString(sql) + // Step 2: Tokenize SQL into the tokenizer's scratch buffer: the parser + // copies the tokens while preprocessing and they are not used after. + tokens, err := tokscratch.String(tkz, sql) if err != nil { return nil, fmt.Errorf("%w: %w", ErrTokenize, err) } @@ -199,7 +201,7 @@ func ParseWithContext(ctx context.Context, sql string) (*ast.AST, error) { defer tokenizer.PutTokenizer(tkz) // Step 2: Tokenize SQL with context support - tokens, err := tkz.TokenizeStringContext(ctx, sql) + tokens, err := tokscratch.StringContext(ctx, tkz, sql) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, fmt.Errorf("%w: %w", ErrTimeout, ctxErr) @@ -277,7 +279,7 @@ func Validate(sql string) error { } // Use the dedicated validation fast-path that avoids building a full AST - err := parser.ValidateBytes([]byte(sql)) + err := parser.Validate(sql) if err != nil { return fmt.Errorf("%w: %w", ErrSyntax, err) } @@ -303,7 +305,7 @@ func ParseBytes(sql []byte) (*ast.AST, error) { tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) - tokens, err := tkz.Tokenize(sql) + tokens, err := tokscratch.Bytes(tkz, sql) if err != nil { return nil, fmt.Errorf("%w: %w", ErrTokenize, err) } @@ -427,7 +429,7 @@ func ParseMultiple(queries []string) ([]*ast.AST, error) { p.Reset() // Tokenize - tokens, err := tkz.TokenizeString(sql) + tokens, err := tokscratch.String(tkz, sql) if err != nil { releaseASTs(results) return nil, fmt.Errorf("query %d: %w: %w", i, ErrTokenize, err) @@ -472,7 +474,7 @@ func ValidateMultiple(queries []string) error { p.Reset() // Tokenize - tokens, err := tkz.TokenizeString(sql) + tokens, err := tokscratch.String(tkz, sql) if err != nil { return fmt.Errorf("query %d: %w: %w", i, ErrTokenize, err) } @@ -642,7 +644,7 @@ func formatSQL(sql string, options FormatOptions, dialect keywords.SQLDialect) ( tkz.SetDialect(dialect) } - tokens, err := tkz.TokenizeString(sql) + tokens, err := tokscratch.String(tkz, sql) if err != nil { return "", fmt.Errorf("cannot format invalid SQL: %w: %w", ErrTokenize, err) } @@ -708,7 +710,7 @@ func ParseWithRecovery(sql string) ([]ast.Statement, []error) { tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) - tokens, err := tkz.TokenizeString(sql) + tokens, err := tokscratch.String(tkz, sql) if err != nil { return nil, []error{fmt.Errorf("%w: %w", ErrTokenize, err)} } diff --git a/pkg/gosqlx/scratch_tokens_test.go b/pkg/gosqlx/scratch_tokens_test.go new file mode 100644 index 00000000..0a8b1df4 --- /dev/null +++ b/pkg/gosqlx/scratch_tokens_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosqlx + +import ( + "context" + "fmt" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +// TestEntryPoints_ASTOutlivesTokenizerReuse checks every entry point that +// tokenizes into the pooled tokenizer's scratch buffer: the AST it returns +// must stay intact after the same tokenizer is reused by every other such +// entry point, i.e. nothing in the AST may point into the scratch tokens. +func TestEntryPoints_ASTOutlivesTokenizerReuse(t *testing.T) { + const sql = `/* lead */ SELECT "Mixed", b AS x, COUNT(*) FROM s."T" t ` + + `LEFT JOIN u ON t.id = u.id WHERE c > 1 AND d = 'v' GROUP BY 1, 2 ORDER BY 3 DESC LIMIT 5` + ctx := context.Background() + tests := []struct { + name string + parse func() (*ast.AST, error) + }{ + {"Parse", func() (*ast.AST, error) { return Parse(sql) }}, + {"ParseBytes", func() (*ast.AST, error) { return ParseBytes([]byte(sql)) }}, + {"ParseWithContext", func() (*ast.AST, error) { return ParseWithContext(ctx, sql) }}, + {"ParseWithDialect", func() (*ast.AST, error) { return ParseWithDialect(sql, "postgresql") }}, + {"ParseMultiple", func() (*ast.AST, error) { + trees, err := ParseMultiple([]string{sql}) + if err != nil { + return nil, err + } + return trees[0], nil + }}, + {"ParseTree", func() (*ast.AST, error) { + tree, err := ParseTree(ctx, sql, WithDialect("postgresql"), WithComments()) + if err != nil { + return nil, err + } + return tree.Raw(), nil + }}, + {"ParseTreeRecovery", func() (*ast.AST, error) { + tree, err := ParseTree(ctx, sql, WithRecovery()) + if err != nil { + return nil, err + } + return tree.Raw(), nil + }}, + {"ParseWithRecovery", func() (*ast.AST, error) { + stmts, errs := ParseWithRecovery(sql) + if len(errs) > 0 { + return nil, errs[0] + } + return &ast.AST{Statements: stmts}, nil + }}, + } + churn := func(i int) error { + other := fmt.Sprintf("SELECT zz%d, yy FROM qq%d -- c%d\nWHERE ww = 'v%d'", i, i, i, i) + if err := Validate(other); err != nil { + return err + } + if err := ValidateMultiple([]string{other, other}); err != nil { + return err + } + if _, err := Format(other, DefaultFormatOptions()); err != nil { + return err + } + for _, tc := range tests { + if _, err := tc.parse(); err != nil { + return err + } + } + return nil + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := tc.parse() + if err != nil { + t.Fatalf("parse: %v", err) + } + want := FormatAST(tree) + for i := 0; i < 20; i++ { + if err := churn(i); err != nil { + t.Fatalf("churn %d: %v", i, err) + } + } + if got := FormatAST(tree); got != want { + t.Errorf("AST changed after tokenizer reuse:\n got %s\nwant %s", got, want) + } + }) + } +} + +// TestFormat_CommentsWithScratchTokens checks that Format, which reads the +// scratch tokens again after parsing to place comments, keeps placing them +// the same way when the tokenizer is reused between calls. +func TestFormat_CommentsWithScratchTokens(t *testing.T) { + tests := []struct { + name string + sql string + }{ + {"leading", "-- head\nSELECT a FROM t"}, + {"between statements", "SELECT a FROM t; /* mid */ SELECT b FROM u"}, + {"trailing", "SELECT a FROM t -- tail"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + first, err := Format(tc.sql, DefaultFormatOptions()) + if err != nil { + t.Fatalf("Format: %v", err) + } + for i := 0; i < 5; i++ { + if _, err := Format(fmt.Sprintf("SELECT x%d /* c */ FROM y", i), DefaultFormatOptions()); err != nil { + t.Fatalf("Format churn: %v", err) + } + again, err := Format(tc.sql, DefaultFormatOptions()) + if err != nil { + t.Fatalf("Format: %v", err) + } + if again != first { + t.Errorf("Format changed on reuse:\n got %q\nwant %q", again, first) + } + } + }) + } +} diff --git a/pkg/gosqlx/tree.go b/pkg/gosqlx/tree.go index 532d3fc4..bbafaebe 100644 --- a/pkg/gosqlx/tree.go +++ b/pkg/gosqlx/tree.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" + "github.com/ajitpratap0/GoSQLX/internal/tokscratch" "github.com/ajitpratap0/GoSQLX/pkg/formatter" "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" @@ -319,7 +320,7 @@ func parseWithConfig(ctx context.Context, sql string, cfg parseOptions) (*ast.AS if cfg.dialect != "" { tkz.SetDialect(keywords.SQLDialect(cfg.dialect)) } - tokens, tokErr := tkz.TokenizeStringContext(ctx, sql) + tokens, tokErr := tokscratch.StringContext(ctx, tkz, sql) if tokErr != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, wrapContextErr(ctxErr) diff --git a/pkg/sql/parser/error_context_issue616_test.go b/pkg/sql/parser/error_context_issue616_test.go index 139520dc..17d9a8bc 100644 --- a/pkg/sql/parser/error_context_issue616_test.go +++ b/pkg/sql/parser/error_context_issue616_test.go @@ -213,8 +213,9 @@ func TestIssue616_StrictModeEmptyStatementContext(t *testing.T) { func TestIssue616_NoLiteralSQLArgInParserErrorBuilders(t *testing.T) { const errorsImport = "github.com/ajitpratap0/GoSQLX/pkg/errors" // realSourceSQLArgs are `sql` argument expressions that carry the real - // query source (validate.go receives the raw input bytes). - realSourceSQLArgs := map[string]bool{"string(input)": true} + // query source (validate.go receives the raw input as bytes or a string; + // sqlText.source returns it). + realSourceSQLArgs := map[string]bool{"string(input)": true, "in.source()": true} fset := token.NewFileSet() // builder name -> flattened index of its `sql` parameter diff --git a/pkg/sql/parser/validate.go b/pkg/sql/parser/validate.go index c58b7054..ddd98fc1 100644 --- a/pkg/sql/parser/validate.go +++ b/pkg/sql/parser/validate.go @@ -20,6 +20,7 @@ import ( "fmt" "strings" + "github.com/ajitpratap0/GoSQLX/internal/tokscratch" goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/models" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" @@ -32,28 +33,26 @@ import ( // returned AST is immediately released. This is significantly faster than // Parse() when you only need to know if the SQL is valid. func Validate(sql string) error { - return ValidateBytes([]byte(sql)) + return validateSQL(sqlText{s: sql, isString: true}) } // ValidateBytes is like Validate but accepts []byte to avoid a string copy. // Empty or whitespace-only input is rejected as invalid SQL. func ValidateBytes(input []byte) error { - if len(trimBytes(input)) == 0 { + return validateSQL(sqlText{b: input}) +} + +func validateSQL(in sqlText) error { + if in.blank() { return goerrors.IncompleteStatementError(models.Location{}, "") } tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) - tokens, err := tkz.Tokenize(input) + tokens, err := in.tokenize(tkz) if err != nil { - return goerrors.WrapError( - goerrors.ErrCodeInvalidSyntax, - fmt.Sprintf("tokenization error: %v", err), - models.Location{}, - string(input), - err, - ) + return wrapTokenizeError(in, err) } if len(tokens) == 0 { @@ -71,6 +70,55 @@ func ValidateBytes(input []byte) error { return nil } +// sqlText is SQL passed to a package-level entry point either as []byte or +// as a string. String entry points tokenize the caller's string in place +// instead of converting it to []byte and having the tokenizer copy it back. +type sqlText struct { + b []byte + s string + isString bool +} + +// blank reports whether the SQL is empty or only ASCII spaces, tabs, CRs and +// LFs (the trimBytes set). +func (in sqlText) blank() bool { + if in.isString { + return strings.Trim(in.s, " \t\n\r") == "" + } + return len(trimBytes(in.b)) == 0 +} + +// source returns the SQL as a string for error context. +func (in sqlText) source() string { + if in.isString { + return in.s + } + return string(in.b) +} + +// tokenize tokenizes the SQL into tkz's scratch buffer. The tokens are valid +// only until tkz is reused or returned to the pool, so callers must hand +// them straight to a parse method that preprocesses (copies) them and must +// never return them. +func (in sqlText) tokenize(tkz *tokenizer.Tokenizer) ([]models.TokenWithSpan, error) { + if in.isString { + return tokscratch.String(tkz, in.s) + } + return tokscratch.Bytes(tkz, in.b) +} + +// wrapTokenizeError wraps a tokenizer error the way every package-level +// entry point reports it. +func wrapTokenizeError(in sqlText, err error) error { + return goerrors.WrapError( + goerrors.ErrCodeInvalidSyntax, + fmt.Sprintf("tokenization error: %v", err), + models.Location{}, + in.source(), + err, + ) +} + // trimBytes returns input with leading/trailing whitespace removed. func trimBytes(b []byte) []byte { start, end := 0, len(b) @@ -96,15 +144,10 @@ func ParseBytes(input []byte) (*ast.AST, error) { tkz := tokenizer.GetTokenizer() defer tokenizer.PutTokenizer(tkz) - tokens, err := tkz.Tokenize(input) + in := sqlText{b: input} + tokens, err := in.tokenize(tkz) if err != nil { - return nil, goerrors.WrapError( - goerrors.ErrCodeInvalidSyntax, - fmt.Sprintf("tokenization error: %v", err), - models.Location{}, - string(input), - err, - ) + return nil, wrapTokenizeError(in, err) } if len(tokens) == 0 { @@ -155,12 +198,16 @@ func ParseBytesWithTokens(input []byte) (*ast.AST, []models.TokenWithSpan, error // ValidateWithDialect checks whether the given SQL string is syntactically valid // using the specified SQL dialect for keyword recognition. func ValidateWithDialect(sql string, dialect keywords.SQLDialect) error { - return ValidateBytesWithDialect([]byte(sql), dialect) + return validateWithDialect(sqlText{s: sql, isString: true}, dialect) } // ValidateBytesWithDialect is like ValidateWithDialect but accepts []byte. func ValidateBytesWithDialect(input []byte, dialect keywords.SQLDialect) error { - if len(trimBytes(input)) == 0 { + return validateWithDialect(sqlText{b: input}, dialect) +} + +func validateWithDialect(in sqlText, dialect keywords.SQLDialect) error { + if in.blank() { return nil } @@ -178,15 +225,9 @@ func ValidateBytesWithDialect(input []byte, dialect keywords.SQLDialect) error { defer tokenizer.PutTokenizer(tkz) tkz.SetDialect(dialect) - tokens, err := tkz.Tokenize(input) + tokens, err := in.tokenize(tkz) if err != nil { - return goerrors.WrapError( - goerrors.ErrCodeInvalidSyntax, - fmt.Sprintf("tokenization error: %v", err), - models.Location{}, - string(input), - err, - ) + return wrapTokenizeError(in, err) } if len(tokens) == 0 { @@ -210,11 +251,15 @@ func ValidateBytesWithDialect(input []byte, dialect keywords.SQLDialect) error { // ParseWithDialect parses SQL using the specified dialect for keyword recognition. // This is a convenience function combining dialect-aware tokenization and parsing. func ParseWithDialect(sql string, dialect keywords.SQLDialect) (*ast.AST, error) { - return ParseBytesWithDialect([]byte(sql), dialect) + return parseWithDialect(sqlText{s: sql, isString: true}, dialect) } // ParseBytesWithDialect is like ParseWithDialect but accepts []byte. func ParseBytesWithDialect(input []byte, dialect keywords.SQLDialect) (*ast.AST, error) { + return parseWithDialect(sqlText{b: input}, dialect) +} + +func parseWithDialect(in sqlText, dialect keywords.SQLDialect) (*ast.AST, error) { if !keywords.IsValidDialect(string(dialect)) { return nil, goerrors.InvalidSyntaxError( fmt.Sprintf("unknown SQL dialect %q; valid dialects: %s", dialect, validDialectList()), @@ -227,15 +272,9 @@ func ParseBytesWithDialect(input []byte, dialect keywords.SQLDialect) (*ast.AST, defer tokenizer.PutTokenizer(tkz) tkz.SetDialect(dialect) - tokens, err := tkz.Tokenize(input) + tokens, err := in.tokenize(tkz) if err != nil { - return nil, goerrors.WrapError( - goerrors.ErrCodeInvalidSyntax, - fmt.Sprintf("tokenization error: %v", err), - models.Location{}, - string(input), - err, - ) + return nil, wrapTokenizeError(in, err) } if len(tokens) == 0 { diff --git a/pkg/sql/parser/validate_string_test.go b/pkg/sql/parser/validate_string_test.go new file mode 100644 index 00000000..47fefd1b --- /dev/null +++ b/pkg/sql/parser/validate_string_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "fmt" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestStringAndBytesEntryPointsAgree checks that the string entry points, +// which tokenize the caller's string in place into the tokenizer's scratch +// buffer, report exactly what their []byte counterparts report. +func TestStringAndBytesEntryPointsAgree(t *testing.T) { + inputs := []string{ + "SELECT id, name FROM users WHERE active = true", + "", + " \t\r\n ", + "\v", + "SELECT 'unterminated", + "SELECT FROM", + "SELECT `a` FROM `t` LIMIT 5", + "-- only a comment", + "SELECT 1; SELECT 2", + } + errText := func(err error) string { + if err == nil { + return "" + } + return err.Error() + } + astText := func(tree *ast.AST) string { + if tree == nil { + return "" + } + defer ast.ReleaseAST(tree) + out := "" + for _, s := range tree.Statements { + out += fmt.Sprintf("%T|", s) + } + return out + } + for _, sql := range inputs { + t.Run(fmt.Sprintf("%q", sql), func(t *testing.T) { + if got, want := errText(Validate(sql)), errText(ValidateBytes([]byte(sql))); got != want { + t.Errorf("Validate = %s, ValidateBytes = %s", got, want) + } + for _, d := range []keywords.SQLDialect{keywords.DialectPostgreSQL, keywords.DialectMySQL} { + if got, want := errText(ValidateWithDialect(sql, d)), errText(ValidateBytesWithDialect([]byte(sql), d)); got != want { + t.Errorf("%s: ValidateWithDialect = %s, ValidateBytesWithDialect = %s", d, got, want) + } + st, serr := ParseWithDialect(sql, d) + bt, berr := ParseBytesWithDialect([]byte(sql), d) + if got, want := errText(serr), errText(berr); got != want { + t.Errorf("%s: ParseWithDialect err = %s, ParseBytesWithDialect err = %s", d, got, want) + } + if got, want := astText(st), astText(bt); got != want { + t.Errorf("%s: ParseWithDialect = %s, ParseBytesWithDialect = %s", d, got, want) + } + } + }) + } +} + +// TestParseWithDialect_ASTOutlivesTokenizerReuse checks that an AST built +// from the tokenizer's scratch tokens stays intact after the pooled +// tokenizer and parser are reused for other input. +func TestParseWithDialect_ASTOutlivesTokenizerReuse(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + sql string + }{ + {"postgres", keywords.DialectPostgreSQL, `SELECT "Mixed", b AS x FROM s."T" t JOIN u ON t.id = u.id WHERE c > 1`}, + {"mysql", keywords.DialectMySQL, "SELECT `col`, COUNT(*) FROM `db`.`tbl` GROUP BY `col` LIMIT 3"}, + {"sqlserver", keywords.DialectSQLServer, "SELECT TOP 2 [a b], c FROM dbo.t ORDER BY c DESC"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tree, err := ParseWithDialect(tc.sql, tc.dialect) + if err != nil { + t.Fatalf("ParseWithDialect: %v", err) + } + defer ast.ReleaseAST(tree) + sqler, ok := tree.Statements[0].(interface{ SQL() string }) + if !ok { + t.Fatalf("%T has no SQL method", tree.Statements[0]) + } + want := sqler.SQL() + for i := 0; i < 50; i++ { + other := fmt.Sprintf("SELECT zz%d, yy FROM qq%d WHERE ww = 'v%d'", i, i, i) + if err := Validate(other); err != nil { + t.Fatalf("Validate(%q): %v", other, err) + } + o, err := ParseBytesWithDialect([]byte(other), tc.dialect) + if err != nil { + t.Fatalf("ParseBytesWithDialect(%q): %v", other, err) + } + ast.ReleaseAST(o) + } + if got := sqler.SQL(); got != want { + t.Errorf("AST changed after tokenizer reuse:\n got %s\nwant %s", got, want) + } + }) + } +} diff --git a/pkg/sql/tokenizer/pool.go b/pkg/sql/tokenizer/pool.go index 5d96ef7a..c5fa7c28 100644 --- a/pkg/sql/tokenizer/pool.go +++ b/pkg/sql/tokenizer/pool.go @@ -40,6 +40,12 @@ const maxRetainedComments = 1024 // for later callers; load re-grows it for the next large input. const maxRetainedLineStarts = 8 << 10 +// maxRetainedTokBuf is the largest scratch token slice capacity (in tokens, +// about 288 KiB at 72 B per token) Reset keeps, matching the parser's +// maxPooledTokenBuf. A larger slice is dropped so one huge query does not pin +// memory in the tokenizer pool. +const maxRetainedTokBuf = 4096 + // tokenizerPool provides object pooling for Tokenizer instances. // This dramatically reduces allocations in high-throughput scenarios. // @@ -236,4 +242,17 @@ func (t *Tokenizer) Reset() { t.Comments = t.Comments[:0] } t.emitted = 0 + + // Zero the scratch tokens and the Words handed out from the scratch slab + // (their strings belong to the previous input) and keep the backing + // arrays unless the token slice has grown large. useWordSlab never grows + // wordSlab past maxRetainedWordSlab. + if cap(t.tokBuf) > maxRetainedTokBuf { + t.tokBuf = nil + } else { + clear(t.tokBuf) + t.tokBuf = t.tokBuf[:0] + } + clear(t.wordSlab[:t.wordSlabUsed]) + t.wordSlabUsed = 0 } diff --git a/pkg/sql/tokenizer/scratch.go b/pkg/sql/tokenizer/scratch.go new file mode 100644 index 00000000..613fc688 --- /dev/null +++ b/pkg/sql/tokenizer/scratch.go @@ -0,0 +1,38 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import ( + "context" + "fmt" + + "github.com/ajitpratap0/GoSQLX/internal/tokscratch" + "github.com/ajitpratap0/GoSQLX/pkg/models" +) + +func init() { tokscratch.Register(tokenizeScratch) } + +// tokenizeScratch implements tokscratch.Func: the public tokenize paths, but +// building the result in the tokenizer-owned t.tokBuf. +func tokenizeScratch(ctx context.Context, tv any, input []byte, src string, isString bool) ([]models.TokenWithSpan, error) { + t, ok := tv.(*Tokenizer) + if !ok || t == nil { + return nil, fmt.Errorf("tokscratch: want a non-nil *tokenizer.Tokenizer, got %T", tv) + } + if isString { + return t.tokenize(ctx, stringBytes(src), src, true, true) + } + return t.tokenize(ctx, input, "", false, true) +} diff --git a/pkg/sql/tokenizer/scratch_test.go b/pkg/sql/tokenizer/scratch_test.go new file mode 100644 index 00000000..b688f1ae --- /dev/null +++ b/pkg/sql/tokenizer/scratch_test.go @@ -0,0 +1,237 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tokenizer + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/internal/tokscratch" + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +var scratchCases = []struct { + name string + dialect keywords.SQLDialect + sql string +}{ + {"simple", "", "SELECT id, name FROM users WHERE active = true"}, + {"multiline comments", "", "-- lead\nSELECT a /* mid */, \"Q\"\nFROM t\nORDER BY 1"}, + {"literals", "", "SELECT 'it''s', E'a\\nb', 1.5e3, $1 FROM t"}, + {"mysql", keywords.DialectMySQL, "SELECT `a`, 'x\\'y' FROM `db`.`t` LIMIT 5"}, + {"sqlserver", keywords.DialectSQLServer, "SELECT TOP 3 [a b] FROM dbo.t"}, + {"bom", "", "\xEF\xBB\xBFSELECT 1"}, + {"empty", "", ""}, +} + +// TestTokScratch_MatchesPublic checks that every scratch entry point returns +// exactly what the matching public method returns. +func TestTokScratch_MatchesPublic(t *testing.T) { + for _, tc := range scratchCases { + t.Run(tc.name, func(t *testing.T) { + pub := GetTokenizer() + defer PutTokenizer(pub) + scr := GetTokenizer() + defer PutTokenizer(scr) + if tc.dialect != "" { + pub.SetDialect(tc.dialect) + scr.SetDialect(tc.dialect) + } + + want, err := pub.TokenizeString(tc.sql) + if err != nil { + t.Fatalf("TokenizeString: %v", err) + } + + calls := []struct { + name string + run func() ([]models.TokenWithSpan, error) + }{ + {"String", func() ([]models.TokenWithSpan, error) { return tokscratch.String(scr, tc.sql) }}, + {"Bytes", func() ([]models.TokenWithSpan, error) { return tokscratch.Bytes(scr, []byte(tc.sql)) }}, + {"StringContext", func() ([]models.TokenWithSpan, error) { + return tokscratch.StringContext(context.Background(), scr, tc.sql) + }}, + } + for _, c := range calls { + got, err := c.run() + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s tokens differ from TokenizeString:\n got %v\nwant %v", c.name, got, want) + } + if (len(scr.Comments) != 0 || len(pub.Comments) != 0) && !reflect.DeepEqual(scr.Comments, pub.Comments) { + t.Errorf("%s comments = %v, want %v", c.name, scr.Comments, pub.Comments) + } + } + }) + } +} + +// TestTokScratch_ReusesBuffer checks that the scratch path reuses the +// tokenizer's buffer while the public methods keep returning fresh, +// caller-owned slices that later calls never overwrite. +func TestTokScratch_ReusesBuffer(t *testing.T) { + tkz := GetTokenizer() + defer PutTokenizer(tkz) + + first, err := tokscratch.String(tkz, "SELECT a, b, c FROM t WHERE x = 1") + if err != nil { + t.Fatalf("scratch: %v", err) + } + second, err := tokscratch.String(tkz, "SELECT d FROM u") + if err != nil { + t.Fatalf("scratch: %v", err) + } + if &first[0] != &second[0] { + t.Errorf("second scratch call did not reuse the buffer") + } + + if first[1].Token.Word == nil || second[1].Token.Word == nil { + t.Fatalf("precondition: identifier tokens carry no Word") + } + if first[1].Token.Word != second[1].Token.Word { + t.Errorf("second scratch call did not reuse the Word slab") + } + + pub, err := tkz.TokenizeString("SELECT p FROM q") + if err != nil { + t.Fatalf("TokenizeString: %v", err) + } + snapshot := projectWords(pub) + if &pub[0] == &second[0] { + t.Fatalf("public TokenizeString returned the scratch buffer") + } + if pub[1].Token.Word == second[1].Token.Word { + t.Fatalf("public TokenizeString handed out a Word from the scratch slab") + } + if _, err := tokscratch.String(tkz, "UPDATE z SET y = 2 WHERE w = 3"); err != nil { + t.Fatalf("scratch: %v", err) + } + if got := projectWords(pub); !reflect.DeepEqual(got, snapshot) { + t.Errorf("scratch call changed an earlier public result:\n got %v\nwant %v", got, snapshot) + } +} + +// wordProjection is a token with its Word copied by value, so a snapshot does +// not share Words with the tokens it was taken from. +type wordProjection struct { + Tok models.TokenWithSpan + Word models.Word +} + +func projectWords(toks []models.TokenWithSpan) []wordProjection { + out := make([]wordProjection, len(toks)) + for i, tk := range toks { + out[i].Tok = tk + out[i].Tok.Token.Word = nil + if tk.Token.Word != nil { + out[i].Word = *tk.Token.Word + } + } + return out +} + +// TestTokScratch_ResetClearsBuffer checks that Reset (and so PutTokenizer) +// drops every reference into the previous SQL, including after an error, and +// drops an oversized buffer. +func TestTokScratch_ResetClearsBuffer(t *testing.T) { + tests := []struct { + name string + sql string + wantErr bool + dropped bool + }{ + {"success", "SELECT a, b FROM t", false, false}, + {"tokenize error", "SELECT a, 'unterminated", true, false}, + {"oversized", "SELECT " + strings.Repeat("a, ", maxRetainedTokBuf) + "b", false, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tkz, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = tokscratch.String(tkz, tc.sql) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) + } + if len(tkz.tokBuf) == 0 { + t.Fatalf("precondition: tokBuf is empty after the call") + } + buf := tkz.tokBuf[:cap(tkz.tokBuf)] + slab := tkz.wordSlab[:cap(tkz.wordSlab)] + // The oversized input has more words than the slab may hold, + // so it never gets one. + if !tc.dropped && tkz.wordSlabUsed == 0 { + t.Fatalf("precondition: no Words handed out from the slab") + } + tkz.Reset() + for i := range slab { + if slab[i] != (models.Word{}) { + t.Fatalf("wordSlab[%d] = %v after Reset, want zero", i, slab[i]) + } + } + if tkz.wordSlabUsed != 0 { + t.Errorf("wordSlabUsed = %d after Reset, want 0", tkz.wordSlabUsed) + } + if tc.dropped { + if tkz.tokBuf != nil { + t.Errorf("oversized tokBuf (cap %d) kept after Reset", cap(buf)) + } + return + } + if len(tkz.tokBuf) != 0 || cap(tkz.tokBuf) != cap(buf) { + t.Errorf("tokBuf after Reset: len %d cap %d, want len 0 cap %d", len(tkz.tokBuf), cap(tkz.tokBuf), cap(buf)) + } + for i := range buf { + if buf[i] != (models.TokenWithSpan{}) { + t.Fatalf("tokBuf[%d] = %v after Reset, want zero", i, buf[i]) + } + } + }) + } +} + +// TestTokScratch_Errors checks error parity with the public methods and the +// guard against a wrong tokenizer argument. +func TestTokScratch_Errors(t *testing.T) { + tkz := GetTokenizer() + defer PutTokenizer(tkz) + + const bad = "SELECT 'oops" + _, wantErr := tkz.TokenizeString(bad) + _, gotErr := tokscratch.String(tkz, bad) + if wantErr == nil || gotErr == nil || gotErr.Error() != wantErr.Error() { + t.Errorf("scratch error = %v, want %v", gotErr, wantErr) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := tokscratch.StringContext(ctx, tkz, "SELECT 1"); !errors.Is(err, context.Canceled) { + t.Errorf("StringContext on cancelled ctx: err = %v, want context.Canceled", err) + } + + for _, arg := range []any{nil, "not a tokenizer", (*Tokenizer)(nil)} { + if _, err := tokscratch.String(arg, "SELECT 1"); err == nil { + t.Errorf("tokscratch.String(%T) succeeded, want an error", arg) + } + } +} diff --git a/pkg/sql/tokenizer/tokenizer.go b/pkg/sql/tokenizer/tokenizer.go index fcf4cc08..7d3f34f2 100644 --- a/pkg/sql/tokenizer/tokenizer.go +++ b/pkg/sql/tokenizer/tokenizer.go @@ -22,6 +22,7 @@ import ( "strings" "time" "unicode/utf8" + "unsafe" "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/metrics" @@ -343,6 +344,23 @@ type Tokenizer struct { inlLine int // index into lineStarts of the cursor's line, plus one; 0 means unset inlScan int // byte offset the line has been scanned up to inlCode bool // whether a non-whitespace byte was seen in [lineStart, inlScan) + + // tokBuf is the token slice of the internal scratch path (package + // internal/tokscratch), which GoSQLX's own parse entry points use + // because they only read the tokens until the parser has copied them. + // It is reused across scratch calls, so its tokens are valid only until + // the next tokenize call, Reset or PutTokenizer. The public Tokenize + // methods never use it. INVARIANT: tokBuf[len:cap] is zero; Reset zeroes + // [0:len) and truncates it, or drops it when it has grown past + // maxRetainedTokBuf. + tokBuf []models.TokenWithSpan + // wordSlab is the Word chunk of the scratch path: scratch calls hand out + // Words from it first instead of allocating a fresh chunk, so it carries + // the same validity rule as tokBuf. wordSlabUsed is how many of its + // leading elements the last scratch call handed out; Reset zeroes them. + // INVARIANT: wordSlab[wordSlabUsed:cap] is zero. + wordSlab []models.Word + wordSlabUsed int } // New creates a new Tokenizer with default configuration and keyword support. @@ -496,7 +514,7 @@ func NewWithKeywords(kw *keywords.Keywords) (*Tokenizer, error) { // tokens, _ := tkz.Tokenize([]byte(sql)) // // Correctly tokenizes Unicode identifiers and string literals func (t *Tokenizer) Tokenize(input []byte) ([]models.TokenWithSpan, error) { - return t.tokenize(context.Background(), input, "", false) + return t.tokenize(context.Background(), input, "", false, false) } // TokenizeContext processes the input and returns tokens with context support for cancellation. @@ -521,7 +539,7 @@ func (t *Tokenizer) TokenizeContext(ctx context.Context, input []byte) ([]models if err := ctx.Err(); err != nil { return nil, err } - return t.tokenize(ctx, input, "", false) + return t.tokenize(ctx, input, "", false, false) } // TokenizeString is Tokenize for SQL that is already held as a string. @@ -541,7 +559,7 @@ func (t *Tokenizer) TokenizeContext(ctx context.Context, input []byte) ([]models // defer PutTokenizer(tkz) // tokens, err := tkz.TokenizeString("SELECT id FROM users") func (t *Tokenizer) TokenizeString(s string) ([]models.TokenWithSpan, error) { - return t.tokenize(context.Background(), stringBytes(s), s, true) + return t.tokenize(context.Background(), stringBytes(s), s, true, false) } // TokenizeStringContext is TokenizeContext for SQL that is already held as a @@ -554,7 +572,7 @@ func (t *Tokenizer) TokenizeStringContext(ctx context.Context, s string) ([]mode if err := ctx.Err(); err != nil { return nil, err } - return t.tokenize(ctx, stringBytes(s), s, true) + return t.tokenize(ctx, stringBytes(s), s, true, false) } // load resets t and installs the input for one call. Invariant: t.src and @@ -582,7 +600,11 @@ func (t *Tokenizer) load(input []byte, src string) { // // ctx is checked every 100 tokens unless ctx.Done() is nil (as for // context.Background()), in which case no per-token check is made. -func (t *Tokenizer) tokenize(ctx context.Context, input []byte, src string, haveSrc bool) ([]models.TokenWithSpan, error) { +// +// When scratch is false the returned slice is freshly allocated and owned by +// the caller. When scratch is true (the internal tokscratch path) it is built +// in t.tokBuf and stays owned by t; see tokBuf. +func (t *Tokenizer) tokenize(ctx context.Context, input []byte, src string, haveSrc, scratch bool) ([]models.TokenWithSpan, error) { // Record start time for metrics startTime := time.Now() @@ -610,7 +632,18 @@ func (t *Tokenizer) tokenize(ctx context.Context, input []byte, src string, have t.lineStarts[0] = 3 } - tokens := make([]models.TokenWithSpan, 0, estimatedTokens) + var tokens []models.TokenWithSpan + if scratch { + // load ran Reset, so tokBuf and wordSlab are empty and all zero. + if cap(t.tokBuf) >= estimatedTokens { + tokens = t.tokBuf[:0] + } else { + tokens = make([]models.TokenWithSpan, 0, estimatedTokens) + } + t.useWordSlab() + } else { + tokens = make([]models.TokenWithSpan, 0, estimatedTokens) + } done := ctx.Done() @@ -674,6 +707,13 @@ func (t *Tokenizer) tokenize(ctx context.Context, input []byte, src string, have } }() + if scratch { + // Keep the slice, partial or not, so the next Reset zeroes it and + // the next scratch call can reuse it. + t.tokBuf = tokens + t.noteWordSlabUse() + } + if tokenErr != nil { // Record metrics for failed tokenization duration := time.Since(startTime) @@ -687,6 +727,10 @@ func (t *Tokenizer) tokenize(ctx context.Context, input []byte, src string, have Start: t.getCurrentPosition(), End: t.getCurrentPosition(), }) + if scratch { + t.tokBuf = tokens + t.noteWordSlabUse() + } // Record metrics for successful tokenization duration := time.Since(startTime) @@ -808,6 +852,40 @@ func (t *Tokenizer) nextWordChunk() { t.wordChunk = make([]models.Word, 0, n) } +// maxRetainedWordSlab is the largest wordSlab capacity (in Words, 32 B each +// on 64-bit platforms, so 128 KiB) the scratch path keeps. +const maxRetainedWordSlab = 4096 + +// useWordSlab makes the retained wordSlab the first Word chunk of a scratch +// call, first growing it to prescan's word hint when that fits under +// maxRetainedWordSlab. Words beyond the slab come from nextWordChunk as usual. +// It must run after prescan and before the first newWord. +func (t *Tokenizer) useWordSlab() { + if need := t.wordHint; cap(t.wordSlab) < need && need <= maxRetainedWordSlab { + t.wordSlab = make([]models.Word, 0, need) + } + if c := cap(t.wordSlab); c > 0 { + t.wordChunk = t.wordSlab[:0] + t.wordHint = max(0, t.wordHint-c) + } +} + +// noteWordSlabUse records in wordSlabUsed how much of wordSlab the current +// scratch call handed out, so Reset zeroes exactly that prefix. The slab is +// either still the current chunk or was filled before a later chunk replaced +// it. +func (t *Tokenizer) noteWordSlabUse() { + c := cap(t.wordSlab) + if c == 0 { + return + } + if cap(t.wordChunk) == c && unsafe.SliceData(t.wordChunk) == unsafe.SliceData(t.wordSlab) { + t.wordSlabUsed = len(t.wordChunk) + } else { + t.wordSlabUsed = c + } +} + // skipWhitespace advances past any whitespace // Optimized with ASCII fast-path since >99% of SQL whitespace is ASCII func (t *Tokenizer) skipWhitespace() { From efeef372e63f80d29b85d9204b890a1d954b9cfa Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Thu, 22 Oct 2026 23:18:56 +0530 Subject: [PATCH 54/95] fix(parser): statement boundaries in scripts without semicolons; whole routine bodies T-SQL scripts and migrations often omit ';'. A statement now ends where the next one starts: - The statement loops (Parse, ParseContext, ParseWithRecovery) mark a following statement start as a boundary, so COMMIT, SET, PRINT, EXEC ... after a DML statement parse ("BEGIN TRANSACTION / UPDATE ... / COMMIT", "SET NOCOUNT ON / SELECT 1 / SET NOCOUNT OFF"). GO lines separate batches. Other dialects accept only a transaction statement on a line of its own; "SELECT 1 FROM t garbage garbage2" stays an error. - T-SQL statement words are never implicit aliases. - T-SQL table variables (@t) are INSERT / UPDATE / DELETE / MERGE targets and FROM / JOIN items. - Verbatim CREATE / ALTER / DROP and ALTER TABLE text actions end at the next statement start in T-SQL (CREATE SCHEMA keeps its elements, routine bodies run to the end of the batch). - IF / WHILE bodies are parsed to find their end, including "IF ... stmt; ELSE stmt"; GRANT / REVOKE / DENY end after their principal list; T-SQL DENY is a utility statement. - Transaction statements in other dialects end at a statement keyword starting the next line (START TRANSACTION / UPDATE / COMMIT). Routine, trigger and PL/SQL bodies are one statement: verbatim statements track BEGIN / CASE / IF / LOOP ... END nesting with the same rules as gosqlx.SplitStatements, so "CREATE TRIGGER ... BEGIN UPDATE ...; END" and Oracle "BEGIN NULL; END;" no longer split at the inner ';'. An unclosed block falls back to ';' boundaries, as the splitter does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- pkg/sql/parser/alter_dialect.go | 4 + pkg/sql/parser/alter_table.go | 15 +- pkg/sql/parser/dml_common.go | 4 +- pkg/sql/parser/dml_insert.go | 2 +- pkg/sql/parser/parser.go | 14 +- pkg/sql/parser/recovery.go | 5 + pkg/sql/parser/select_clauses.go | 4 +- pkg/sql/parser/select_subquery.go | 8 +- pkg/sql/parser/statement_blocks.go | 289 +++++++++++++++++ pkg/sql/parser/statement_boundary.go | 180 +++++++++++ pkg/sql/parser/statement_boundary_test.go | 331 ++++++++++++++++++++ pkg/sql/parser/tsql_control_flow.go | 165 ++++++++++ pkg/sql/parser/utility_implicit_end.go | 213 ++++++++++++- pkg/sql/parser/utility_implicit_end_test.go | 3 +- pkg/sql/parser/utility_stmt.go | 85 ++++- pkg/sql/parser/utility_stmt_test.go | 5 +- 16 files changed, 1282 insertions(+), 45 deletions(-) create mode 100644 pkg/sql/parser/statement_blocks.go create mode 100644 pkg/sql/parser/statement_boundary.go create mode 100644 pkg/sql/parser/statement_boundary_test.go create mode 100644 pkg/sql/parser/tsql_control_flow.go diff --git a/pkg/sql/parser/alter_dialect.go b/pkg/sql/parser/alter_dialect.go index 44d1f7cb..7615169d 100644 --- a/pkg/sql/parser/alter_dialect.go +++ b/pkg/sql/parser/alter_dialect.go @@ -273,10 +273,14 @@ func (p *Parser) parseAlterVerbatim(word string) (ast.Statement, error) { if start < 0 || p.tokens[start].Token.Type != models.TokenTypeAlter { return nil, p.expectedError("ALTER") } + // Scan from ALTER so that the statement's kind decides where it ends. + objPos := p.currentPos + p.seek(start) end, err := p.utilityStatementEnd() if err != nil { return nil, err } + p.seek(objPos) // ALTER alone is not a statement. if end <= p.currentPos+1 { p.seek(p.currentPos + 1) diff --git a/pkg/sql/parser/alter_table.go b/pkg/sql/parser/alter_table.go index cd4cceef..9fd618e0 100644 --- a/pkg/sql/parser/alter_table.go +++ b/pkg/sql/parser/alter_table.go @@ -73,7 +73,7 @@ func (p *Parser) parseAlterTableStatement(stmt *ast.AlterStatement) (*ast.AlterS // The statement ends at ';', EOF, or (as for every other statement, and // as T-SQL scripts without semicolons rely on) the next statement keyword. if !p.isType(models.TokenTypeSemicolon) && !p.isType(models.TokenTypeEOF) && - !p.isStatementStartingKeyword() { + !p.isStatementStartingKeyword() && !p.statementMayEndAt(p.currentPos) { return nil, p.expectedError("end of ALTER TABLE action") } return stmt, nil @@ -836,7 +836,20 @@ func (p *Parser) tokenIsEq(i int) bool { func (p *Parser) alterActionEnd(commaInside bool) (int, error) { depth := 0 i := p.currentPos + // T-SQL scripts need no ';': the action also ends where the next + // statement starts (ALTER TABLE t NOCHECK CONSTRAINT ALL ALTER TABLE u ...). + // The action's keyword was consumed, so the statement began before it. + tsql := p.IsSQLServer() for ; i < len(p.tokens); i++ { + if tsql && depth == 0 && i > 0 { + if _, ok := p.batchSeparatorEnd(i); ok { + return i, nil + } + if p.startsNewDDLStatement(i, p.currentPos-1, false) { + p.markImplicitEnd(i) + return i, nil + } + } switch p.tokens[i].Token.Type { case models.TokenTypeLParen: depth++ diff --git a/pkg/sql/parser/dml_common.go b/pkg/sql/parser/dml_common.go index 6bc7eb2e..0b7eeb9f 100644 --- a/pkg/sql/parser/dml_common.go +++ b/pkg/sql/parser/dml_common.go @@ -143,7 +143,7 @@ func (p *Parser) isDMLTargetAlias() bool { if !p.isIdentifier() { return false } - return !p.isReturningKeyword() && !p.isDMLOutputKeyword() + return !p.isReturningKeyword() && !p.isDMLOutputKeyword() && !p.isTSQLStatementStart() } // parseDMLTargetRef parses one UPDATE/DELETE target: a possibly qualified @@ -153,7 +153,7 @@ func (p *Parser) parseDMLTargetRef() (ast.TableReference, error) { if p.isType(models.TokenTypeLParen) { return p.parseFromTableReference() } - name, nameQuote, err := p.parseQualifiedNameQuoted() + name, nameQuote, err := p.parseTableNameQuoted() if err != nil { return ast.TableReference{}, p.qualifiedNameError(err, "table name") } diff --git a/pkg/sql/parser/dml_insert.go b/pkg/sql/parser/dml_insert.go index cdde9c95..872257dc 100644 --- a/pkg/sql/parser/dml_insert.go +++ b/pkg/sql/parser/dml_insert.go @@ -65,7 +65,7 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { stmt.TableFunction = fn } else { // Parse table name (supports schema.table qualification and double-quoted identifiers) - tableName, tableQuote, err := p.parseQualifiedNameQuoted() + tableName, tableQuote, err := p.parseTableNameQuoted() if err != nil { return nil, p.qualifiedNameError(err, "table name") } diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 786e426e..90004c99 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -437,6 +437,9 @@ func (p *Parser) parseTokens(tokens []models.TokenWithSpan) (*ast.AST, error) { p.advance() continue } + if p.skipBatchSeparator() { + continue + } stmt, err := p.parseStatement() if err != nil { @@ -449,6 +452,8 @@ func (p *Parser) parseTokens(tokens []models.TokenWithSpan) (*ast.AST, error) { // Optionally consume semicolon after statement if p.isType(models.TokenTypeSemicolon) { p.advance() + } else { + p.noteStatementEnd() } } @@ -632,6 +637,9 @@ func (p *Parser) parseContextTokens(ctx context.Context, tokens []models.TokenWi p.advance() continue } + if p.skipBatchSeparator() { + continue + } stmt, err := p.parseStatement() if err != nil { @@ -644,6 +652,8 @@ func (p *Parser) parseContextTokens(ctx context.Context, tokens []models.TokenWi // Optionally consume semicolon after statement if p.isType(models.TokenTypeSemicolon) { p.advance() + } else { + p.noteStatementEnd() } } @@ -1169,7 +1179,7 @@ func (p *Parser) parseQualifiedName() (string, error) { // Accepts IDENT or non-reserved keywords that can be used as table names func (p *Parser) parseTableReference() (*ast.TableReference, error) { - name, q, err := p.parseQualifiedNameQuoted() + name, q, err := p.parseTableNameQuoted() if err != nil { return nil, err } @@ -1325,7 +1335,7 @@ func (p *Parser) nextStartsTableClause() bool { // but NOT contextual clause keywords that would be consumed as aliases by mistake // (e.g. MINUS in Snowflake/Oracle, QUALIFY in Snowflake/BigQuery, WINDOW). func (p *Parser) canBeAlias() bool { - if p.isMinusSetOp() || p.isQualifyKeyword() || p.isWindowClauseKeyword() || p.isSettingsKeyword() || p.isDMLReturningKeyword() || p.isSelectExtClauseStart() { + if p.isMinusSetOp() || p.isQualifyKeyword() || p.isWindowClauseKeyword() || p.isSettingsKeyword() || p.isDMLReturningKeyword() || p.isSelectExtClauseStart() || p.isTSQLStatementStart() { return false } return p.isIdentifier() || p.isNonReservedKeyword() diff --git a/pkg/sql/parser/recovery.go b/pkg/sql/parser/recovery.go index f7a5d007..d88c90d0 100644 --- a/pkg/sql/parser/recovery.go +++ b/pkg/sql/parser/recovery.go @@ -194,6 +194,9 @@ func (p *Parser) parseWithRecovery(tokens []models.TokenWithSpan) ([]ast.Stateme p.advance() continue } + if p.skipBatchSeparator() { + continue + } stmtStartPos := p.currentPos stmt, err := p.parseStatement() @@ -223,6 +226,8 @@ func (p *Parser) parseWithRecovery(tokens []models.TokenWithSpan) ([]ast.Stateme // Optionally consume semicolon after statement if p.isType(models.TokenTypeSemicolon) { p.advance() + } else { + p.noteStatementEnd() } } } diff --git a/pkg/sql/parser/select_clauses.go b/pkg/sql/parser/select_clauses.go index 36705ec4..6fc11c2e 100644 --- a/pkg/sql/parser/select_clauses.go +++ b/pkg/sql/parser/select_clauses.go @@ -69,7 +69,9 @@ func (p *Parser) parseFromClause() (tableName string, tables []ast.TableReferenc !p.isSelectTailStart() && // A DDL tail after a FROM-less query: CREATE ... AS SELECT 1 // WITH [NO] DATA | WITH CHECK OPTION | WITH READ ONLY. - !p.isType(models.TokenTypeWith) { + !p.isType(models.TokenTypeWith) && + // The next statement of a script without semicolons. + !p.statementMayEndAt(p.currentPos) { return "", nil, nil, p.expectedError("FROM, semicolon, or end of statement") } return "", nil, nil, nil diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index 9802a9b3..100c1c5d 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -119,7 +119,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } else { only := p.parseTableOnly() // Parse regular table name (supports schema.table qualification) - qualifiedName, nameQuote, err := p.parseQualifiedNameQuoted() + qualifiedName, nameQuote, err := p.parseTableNameQuoted() if err != nil { return tableRef, err } @@ -154,7 +154,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { // Similarly, START followed by WITH is a hierarchical query seed, not an alias. // Don't consume PIVOT/UNPIVOT as a table alias — they are contextual // keywords in SQL Server/Oracle and must reach the pivot-clause parser below. - if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() && !p.isSampleKeyword() && !p.isMatchRecognizeKeyword() && !p.isWindowClauseKeyword() && !p.isSettingsKeyword() && !p.isDMLReturningKeyword() && !p.isSelectExtClauseStart() && !p.isTableModifierAfterAliasStart() { + if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() && !p.isSampleKeyword() && !p.isMatchRecognizeKeyword() && !p.isWindowClauseKeyword() && !p.isSettingsKeyword() && !p.isDMLReturningKeyword() && !p.isSelectExtClauseStart() && !p.isTableModifierAfterAliasStart() && !p.isTSQLStatementStart() { if p.isType(models.TokenTypeAs) { p.advance() // Consume AS if !p.isIdentifier() { @@ -294,7 +294,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error } } else { only := p.parseTableOnly() - joinedName, joinedQuote, err := p.parseQualifiedNameQuoted() + joinedName, joinedQuote, err := p.parseTableNameQuoted() if isQualifiedNameTooLong(err) { return ref, err } @@ -325,7 +325,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error // Similarly, START followed by WITH is a hierarchical query seed, not an alias. // Don't consume PIVOT/UNPIVOT as a table alias — they are contextual // keywords in SQL Server/Oracle and must reach the pivot-clause parser below. - if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() && !p.isSampleKeyword() && !p.isMatchRecognizeKeyword() && !p.isWindowClauseKeyword() && !p.isSettingsKeyword() && !p.isDMLReturningKeyword() && !p.isSelectExtClauseStart() && !p.isTableModifierAfterAliasStart() { + if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() && !p.isPivotKeyword() && !p.isUnpivotKeyword() && !p.isQualifyKeyword() && !p.isMinusSetOp() && !p.isSnowflakeTimeTravelStart() && !p.isSampleKeyword() && !p.isMatchRecognizeKeyword() && !p.isWindowClauseKeyword() && !p.isSettingsKeyword() && !p.isDMLReturningKeyword() && !p.isSelectExtClauseStart() && !p.isTableModifierAfterAliasStart() && !p.isTSQLStatementStart() { if p.isType(models.TokenTypeAs) { p.advance() if !p.isIdentifier() { diff --git a/pkg/sql/parser/statement_blocks.go b/pkg/sql/parser/statement_blocks.go new file mode 100644 index 00000000..36ec6fde --- /dev/null +++ b/pkg/sql/parser/statement_blocks.go @@ -0,0 +1,289 @@ +// Copyright 2026 GoSQLX 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, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// statement_blocks.go keeps compound statements whole. A routine or trigger +// body (BEGIN ... END), a PL/SQL anonymous block (DECLARE ... BEGIN ... END) +// or a T-SQL control-flow block contains ';' that do not end the statement. +// blockScan tracks BEGIN / CASE / IF / LOOP ... END nesting over the tokens +// of a statement kept verbatim, following the same rules as the block +// tracker of gosqlx.SplitStatements (pkg/gosqlx/splitter_proc.go), so that +// every piece the splitter returns parses as exactly one statement. + +package parser + +import "github.com/ajitpratap0/GoSQLX/pkg/models" + +// blockScan is the nesting state of one statement. depth counts open +// blocks; declMask marks (for levels 1..64) the Oracle declaration +// sections still waiting for their BEGIN. +type blockScan struct { + tsql, oracle bool + depth int + declMask uint64 + // stmtStart is true while the next word begins a statement (after ';', + // BEGIN, THEN, ELSE, LOOP, a label, ...). IF / WHILE / FOR / REPEAT only + // open a block there, which keeps "DROP TABLE IF EXISTS" and IF() out. + stmtStart bool + loopHeader bool // WHILE / FOR seen, its LOOP / DO opener not yet + createN int // >0 while within the first words of an Oracle CREATE + routineHdr bool // Oracle PROCEDURE / FUNCTION / PACKAGE header awaiting IS / AS + trigHdr bool // inside an Oracle CREATE TRIGGER header (DECLARE may follow) + // closed is set when a block closes at the top level and cleared by a + // top-level ELSE: a T-SQL IF / WHILE / BEGIN statement may end there. + closed bool +} + +func (p *Parser) newBlockScan() blockScan { + return blockScan{tsql: p.IsSQLServer(), oracle: p.IsOracle(), stmtStart: true} +} + +func (s *blockScan) push(decl bool) { + s.depth++ + if decl && s.depth <= 64 { + s.declMask |= 1 << uint(s.depth-1) + } +} + +func (s *blockScan) pop() { + if s.depth == 0 { + return + } + if s.depth <= 64 { + s.declMask &^= 1 << uint(s.depth-1) + } + s.depth-- + if s.depth == 0 { + s.closed = true + } +} + +// absorbBegin consumes the BEGIN that follows an Oracle declaration +// section: the section and its body are one level. +func (s *blockScan) absorbBegin() bool { + if s.depth == 0 || s.depth > 64 { + return false + } + bit := uint64(1) << uint(s.depth-1) + if s.declMask&bit == 0 { + return false + } + s.declMask &^= bit + return true +} + +func (s *blockScan) semicolon() { + s.stmtStart = true + s.routineHdr = false + s.loopHeader = false + if s.depth == 0 { + s.createN = 0 + s.trigHdr = false + } +} + +// token processes the token at index i and returns the index of the last +// token it consumed (i, or i+1 when END took its trailing keyword). +func (s *blockScan) token(p *Parser, i int) int { + tok := &p.tokens[i] + switch tok.Token.Type { + case models.TokenTypeSemicolon: + s.semicolon() + return i + case models.TokenTypeColon: + // A label (lbl: LOOP) starts a statement inside a block. + if s.depth > 0 { + s.stmtStart = true + } + return i + case models.TokenTypeShiftRight: + // The closing >> of a <