From b31f7cedd7bece8c89b522f7e0479cca2ab0ad87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:39:33 +0000 Subject: [PATCH 1/4] core: rewrite sqlc.arg() to native bind parameters before parsing sqlc.arg(), sqlc.narg(), sqlc.slice(), sqlc.embed() and @name are sqlc syntax, not SQL, but they used to survive all the way through every engine's parser. The pipeline only worked because each converter happened to round-trip them as an ast.FuncCall with Func.Schema == "sqlc", and that knowledge was scattered across named.IsParamFunc, three validators, two AST rewriters and the PostgreSQL engine. Add internal/sql/preprocess, which rewrites the query text to native SQL before any engine parser runs. A dialect-parameterized lexer skips comments, string literals, quoted identifiers and dollar-quoted strings, then replaces each construct with the engine's placeholder ($1, ?, ?1 or @name) and records what it replaced: the parameter set, embed spans, slice spans, placeholder numbers and an offset map back to the original text. The compiler consumes that side table instead of walking the AST: - parameter numbering comes from the preprocessor in source order, and is applied to the parsed AST by placeholder location, so an engine that converts nodes out of order no longer reorders a query's params - embeds are matched by location rather than node identity, which tells a rewritten "t.*" apart from one the user wrote - placeholder-style validation (mixed styles, numbering gaps) and sqlc.* validation move into the preprocessor, where they can report the span of the actual call Deleted: internal/sql/rewrite, named.IsParamFunc/IsParamSign, validate.SqlcFunctions, validate.In, validate.ParamRef, and the sqlc.arg awareness in the PostgreSQL engine. A missed astutils container node can no longer silently drop a parameter or an embed. Engine changes are limited to what the removal exposed: GoogleSQL now converts @name to a ParamRef like every other engine, and ClickHouse tags binary/unary operators as A_Expr_Kind_OP and seeds comparison and arithmetic operators so the analysis core can type a WHERE clause. With those in place sqlc.arg/narg work for ClickHouse with no sqlc-specific engine code at all. Error messages for invalid sqlc.* calls now name the offending text rather than an AST type, and point at the call instead of the statement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015BSM9xGoosq58r1Mz6B7YP --- internal/compiler/analyze.go | 25 +- internal/compiler/compile.go | 22 +- internal/compiler/expand.go | 37 +- internal/compiler/output_columns.go | 2 +- internal/compiler/parse.go | 55 ++- internal/compiler/parse_core.go | 41 +- internal/compiler/query_catalog.go | 6 +- internal/compiler/resolve.go | 4 +- internal/core/analyzer/bench_test.go | 24 +- .../analyze_sqlc_args/clickhouse/exec.json | 5 + .../analyze_sqlc_args/clickhouse/query.sql | 5 + .../analyze_sqlc_args/clickhouse/schema.sql | 7 + .../analyze_sqlc_args/clickhouse/stdout.txt | 76 ++++ .../sqlc_arg_invalid/mysql/stderr.txt | 6 +- .../sqlc_arg_invalid/postgresql/stderr.txt | 7 +- .../sqlc_arg_invalid/sqlite/stderr.txt | 6 +- internal/engine/clickhouse/convert.go | 2 + internal/engine/clickhouse/seed.go | 44 +- internal/engine/dolphin/CLAUDE.md | 9 +- internal/engine/googlesql/convert.go | 28 +- internal/engine/postgresql/utils.go | 10 - internal/sql/ast/CLAUDE.md | 8 +- internal/sql/astutils/CLAUDE.md | 13 +- internal/sql/named/CLAUDE.md | 112 ++--- internal/sql/named/is.go | 26 -- internal/sql/preprocess/CLAUDE.md | 86 ++++ internal/sql/preprocess/dialect.go | 120 ++++++ internal/sql/preprocess/lexer.go | 284 +++++++++++++ internal/sql/preprocess/preprocess.go | 385 ++++++++++++++++++ internal/sql/preprocess/preprocess_test.go | 264 ++++++++++++ internal/sql/preprocess/scan.go | 209 ++++++++++ .../testdata/clickhouse/arg/input.sql | 1 + .../testdata/clickhouse/arg/output.sql | 1 + .../at_sign_is_not_a_parameter/input.sql | 1 + .../at_sign_is_not_a_parameter/output.sql | 1 + .../testdata/clickhouse/embed/input.sql | 1 + .../testdata/clickhouse/embed/output.sql | 1 + .../testdata/clickhouse/slice/input.sql | 1 + .../testdata/clickhouse/slice/output.sql | 1 + .../arg_keeps_named_parameters/input.sql | 1 + .../arg_keeps_named_parameters/output.sql | 1 + .../testdata/googlesql/embed/input.sql | 1 + .../testdata/googlesql/embed/output.sql | 1 + .../testdata/googlesql/slice/input.sql | 1 + .../testdata/googlesql/slice/output.sql | 1 + .../preprocess/testdata/mysql/arg/input.sql | 1 + .../preprocess/testdata/mysql/arg/output.sql | 1 + .../preprocess/testdata/mysql/embed/input.sql | 1 + .../testdata/mysql/embed/output.sql | 1 + .../input.sql | 1 + .../output.sql | 1 + .../mysql/on_duplicate_key_update/input.sql | 2 + .../mysql/on_duplicate_key_update/output.sql | 2 + .../mysql/skip_backslash_escape/input.sql | 1 + .../mysql/skip_backslash_escape/output.sql | 1 + .../mysql/skip_backtick_identifier/input.sql | 1 + .../mysql/skip_backtick_identifier/output.sql | 1 + .../mysql/skip_double_quoted_string/input.sql | 1 + .../skip_double_quoted_string/output.sql | 1 + .../mysql/skip_hash_comment/input.sql | 2 + .../mysql/skip_hash_comment/output.sql | 2 + .../preprocess/testdata/mysql/slice/input.sql | 1 + .../testdata/mysql/slice/output.sql | 1 + .../input.sql | 1 + .../output.sql | 1 + .../testdata/postgresql/arg/input.sql | 2 + .../testdata/postgresql/arg/output.sql | 2 + .../testdata/postgresql/at_sign/input.sql | 1 + .../testdata/postgresql/at_sign/output.sql | 1 + .../postgresql/at_sign_cast/input.sql | 1 + .../postgresql/at_sign_cast/output.sql | 1 + .../postgresql/comment_inside_call/input.sql | 1 + .../postgresql/comment_inside_call/output.sql | 1 + .../testdata/postgresql/embed/input.sql | 1 + .../testdata/postgresql/embed/output.sql | 1 + .../embed_quoted_identifier/input.sql | 1 + .../embed_quoted_identifier/output.sql | 1 + .../postgresql/embed_with_args/input.sql | 1 + .../postgresql/embed_with_args/output.sql | 1 + .../error_is_per_statement/input.sql | 2 + .../error_is_per_statement/output.sql | 2 + .../error_is_per_statement/stderr.txt | 1 + .../postgresql/error_nested_call/input.sql | 1 + .../postgresql/error_nested_call/output.sql | 1 + .../postgresql/error_nested_call/stderr.txt | 1 + .../postgresql/error_no_arguments/input.sql | 1 + .../postgresql/error_no_arguments/output.sql | 1 + .../postgresql/error_no_arguments/stderr.txt | 1 + .../error_placeholder_argument/input.sql | 1 + .../error_placeholder_argument/output.sql | 1 + .../error_placeholder_argument/stderr.txt | 1 + .../error_too_many_arguments/input.sql | 1 + .../error_too_many_arguments/output.sql | 1 + .../error_too_many_arguments/stderr.txt | 1 + .../error_unknown_function/input.sql | 1 + .../error_unknown_function/output.sql | 1 + .../error_unknown_function/stderr.txt | 1 + .../postgresql/existing_placeholder/input.sql | 1 + .../existing_placeholder/output.sql | 1 + .../postgresql/extra_whitespace/input.sql | 1 + .../postgresql/extra_whitespace/output.sql | 1 + .../postgresql/multiline_call/input.sql | 3 + .../postgresql/multiline_call/output.sql | 1 + .../testdata/postgresql/narg/input.sql | 2 + .../testdata/postgresql/narg/output.sql | 2 + .../input.sql | 2 + .../output.sql | 2 + .../postgresql/quoted_names/input.sql | 1 + .../postgresql/quoted_names/output.sql | 1 + .../postgresql/repeated_name/input.sql | 1 + .../postgresql/repeated_name/output.sql | 1 + .../postgresql/skip_block_comment/input.sql | 3 + .../postgresql/skip_block_comment/output.sql | 3 + .../postgresql/skip_dollar_quoted/input.sql | 1 + .../postgresql/skip_dollar_quoted/output.sql | 1 + .../postgresql/skip_escape_string/input.sql | 1 + .../postgresql/skip_escape_string/output.sql | 1 + .../postgresql/skip_jsonb_operators/input.sql | 1 + .../skip_jsonb_operators/output.sql | 1 + .../postgresql/skip_line_comment/input.sql | 2 + .../postgresql/skip_line_comment/output.sql | 2 + .../skip_nested_block_comment/input.sql | 1 + .../skip_nested_block_comment/output.sql | 1 + .../postgresql/skip_other_schemas/input.sql | 1 + .../postgresql/skip_other_schemas/output.sql | 1 + .../skip_quoted_identifier/input.sql | 1 + .../skip_quoted_identifier/output.sql | 1 + .../postgresql/skip_string_literal/input.sql | 1 + .../postgresql/skip_string_literal/output.sql | 1 + .../skip_tagged_dollar_quoted/input.sql | 1 + .../skip_tagged_dollar_quoted/output.sql | 1 + .../postgresql/slice_is_an_array/input.sql | 1 + .../postgresql/slice_is_an_array/output.sql | 1 + .../statement_terminator_in_string/input.sql | 2 + .../statement_terminator_in_string/output.sql | 2 + .../testdata/postgresql/uppercase/input.sql | 1 + .../testdata/postgresql/uppercase/output.sql | 1 + .../preprocess/testdata/sqlite/arg/input.sql | 1 + .../preprocess/testdata/sqlite/arg/output.sql | 1 + .../testdata/sqlite/at_sign/input.sql | 1 + .../testdata/sqlite/at_sign/output.sql | 1 + .../testdata/sqlite/embed/input.sql | 1 + .../testdata/sqlite/embed/output.sql | 1 + .../existing_numbered_placeholder/input.sql | 1 + .../existing_numbered_placeholder/output.sql | 1 + .../testdata/sqlite/repeated_name/input.sql | 1 + .../testdata/sqlite/repeated_name/output.sql | 1 + .../testdata/sqlite/slice/input.sql | 1 + .../testdata/sqlite/slice/output.sql | 1 + internal/sql/rewrite/CLAUDE.md | 104 ----- internal/sql/rewrite/embeds.go | 91 ----- internal/sql/rewrite/parameters.go | 205 ---------- internal/sql/validate/cmd.go | 10 +- internal/sql/validate/func_call.go | 4 - internal/sql/validate/in.go | 86 ---- internal/sql/validate/param_ref.go | 48 --- internal/sql/validate/param_style.go | 68 ---- internal/sql/validate/slice.go | 70 ++++ 158 files changed, 1897 insertions(+), 857 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_sqlc_args/clickhouse/exec.json create mode 100644 internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql create mode 100644 internal/endtoend/testdata/analyze_sqlc_args/clickhouse/schema.sql create mode 100644 internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt delete mode 100644 internal/sql/named/is.go create mode 100644 internal/sql/preprocess/CLAUDE.md create mode 100644 internal/sql/preprocess/dialect.go create mode 100644 internal/sql/preprocess/lexer.go create mode 100644 internal/sql/preprocess/preprocess.go create mode 100644 internal/sql/preprocess/preprocess_test.go create mode 100644 internal/sql/preprocess/scan.go create mode 100644 internal/sql/preprocess/testdata/clickhouse/arg/input.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/arg/output.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/embed/input.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/embed/output.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/slice/input.sql create mode 100644 internal/sql/preprocess/testdata/clickhouse/slice/output.sql create mode 100644 internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql create mode 100644 internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql create mode 100644 internal/sql/preprocess/testdata/googlesql/embed/input.sql create mode 100644 internal/sql/preprocess/testdata/googlesql/embed/output.sql create mode 100644 internal/sql/preprocess/testdata/googlesql/slice/input.sql create mode 100644 internal/sql/preprocess/testdata/googlesql/slice/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/arg/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/arg/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/embed/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/embed/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_backslash_escape/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_backslash_escape/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_hash_comment/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/skip_hash_comment/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/slice/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/slice/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/arg/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/arg/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign_cast/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign_cast/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/comment_inside_call/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/comment_inside_call/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_with_args/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_with_args/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_is_per_statement/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_is_per_statement/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_is_per_statement/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/error_nested_call/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_nested_call/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_nested_call/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/error_no_arguments/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_no_arguments/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_no_arguments/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/error_unknown_function/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_unknown_function/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_unknown_function/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/extra_whitespace/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/extra_whitespace/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/multiline_call/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/multiline_call/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/narg/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/narg/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/quoted_names/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/quoted_names/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/repeated_name/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/repeated_name/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_block_comment/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_block_comment/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_escape_string/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_escape_string/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_line_comment/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_line_comment/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_other_schemas/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_other_schemas/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_string_literal/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_string_literal/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/slice_is_an_array/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/slice_is_an_array/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/uppercase/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/uppercase/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/arg/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/arg/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/at_sign/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/at_sign/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/embed/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/embed/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/repeated_name/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/repeated_name/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/slice/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/slice/output.sql delete mode 100644 internal/sql/rewrite/CLAUDE.md delete mode 100644 internal/sql/rewrite/embeds.go delete mode 100644 internal/sql/rewrite/parameters.go delete mode 100644 internal/sql/validate/in.go delete mode 100644 internal/sql/validate/param_ref.go delete mode 100644 internal/sql/validate/param_style.go create mode 100644 internal/sql/validate/slice.go diff --git a/internal/compiler/analyze.go b/internal/compiler/analyze.go index 264dbef6f5..020f7865aa 100644 --- a/internal/compiler/analyze.go +++ b/internal/compiler/analyze.go @@ -8,7 +8,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/source" "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/named" - "github.com/sqlc-dev/sqlc/internal/sql/rewrite" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" "github.com/sqlc-dev/sqlc/internal/sql/validate" ) @@ -114,15 +114,15 @@ func combineAnalysis(prev *analysis, a *analyzer.Analysis) *analysis { return prev } -func (c *Compiler) analyzeQuery(raw *ast.RawStmt, query string) (*analysis, error) { - return c._analyzeQuery(raw, query, true) +func (c *Compiler) analyzeQuery(raw *ast.RawStmt, query string, pre *preprocess.Statement) (*analysis, error) { + return c._analyzeQuery(raw, query, pre, true) } -func (c *Compiler) inferQuery(raw *ast.RawStmt, query string) (*analysis, error) { - return c._analyzeQuery(raw, query, false) +func (c *Compiler) inferQuery(raw *ast.RawStmt, query string, pre *preprocess.Statement) (*analysis, error) { + return c._analyzeQuery(raw, query, pre, false) } -func (c *Compiler) _analyzeQuery(raw *ast.RawStmt, query string, failfast bool) (*analysis, error) { +func (c *Compiler) _analyzeQuery(raw *ast.RawStmt, query string, pre *preprocess.Statement, failfast bool) (*analysis, error) { errors := make([]error, 0) check := func(err error) error { if failfast { @@ -134,12 +134,12 @@ func (c *Compiler) _analyzeQuery(raw *ast.RawStmt, query string, failfast bool) return nil } - numbers, dollar, err := validate.ParamRef(raw) - if err := check(err); err != nil { + dollar := pre.Dollar + if err := check(pre.ParamErr); err != nil { return nil, err } - raw, namedParams, edits := rewrite.NamedParameters(c.conf.Engine, raw, numbers, dollar) + namedParams := pre.Params var table *ast.TableName switch n := raw.Stmt.(type) { @@ -158,7 +158,7 @@ func (c *Compiler) _analyzeQuery(raw *ast.RawStmt, query string, failfast bool) return nil, err } - if err := check(validate.In(c.catalog, raw)); err != nil { + if err := check(validate.Slice(raw, pre.Slices)); err != nil { return nil, err } rvs := rangeVars(raw.Stmt) @@ -175,7 +175,7 @@ func (c *Compiler) _analyzeQuery(raw *ast.RawStmt, query string, failfast bool) } else { sort.Slice(refs, func(i, j int) bool { return refs[i].ref.Number < refs[j].ref.Number }) } - raw, embeds := rewrite.Embeds(raw) + embeds := pre.Embeds qc, err := c.buildQueryCatalog(c.catalog, raw.Stmt, embeds) if err := check(err); err != nil { return nil, err @@ -190,11 +190,10 @@ func (c *Compiler) _analyzeQuery(raw *ast.RawStmt, query string, failfast bool) return nil, err } - expandEdits, err := c.expand(qc, raw) + edits, err := c.expand(qc, raw) if check(err); err != nil { return nil, err } - edits = append(edits, expandEdits...) expanded, err := source.Mutate(query, edits) if err != nil { return nil, err diff --git a/internal/compiler/compile.go b/internal/compiler/compile.go index 61b3301f0b..3db4b05d3a 100644 --- a/internal/compiler/compile.go +++ b/internal/compiler/compile.go @@ -16,6 +16,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/rpc" "github.com/sqlc-dev/sqlc/internal/source" "github.com/sqlc-dev/sqlc/internal/sql/ast" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" "github.com/sqlc-dev/sqlc/internal/sql/sqlpath" ) @@ -103,19 +104,34 @@ func (c *Compiler) parseQueries(o opts.Parser) (*Result, error) { continue } src := string(blob) - stmts, err := c.parser.Parse(strings.NewReader(src)) + + // sqlc syntax is not SQL. Rewrite it to native placeholders before the + // engine parser ever sees the query, so parsers only handle SQL. + pp, err := preprocess.File(c.conf.Engine, src) + if err != nil { + merr.Add(filename, src, 0, err) + continue + } + + stmts, err := c.parser.Parse(strings.NewReader(pp.Text)) if err != nil { merr.Add(filename, src, 0, err) continue } for _, stmt := range stmts { - query, err := c.parseQuery(stmt.Raw, src, o) + query, err := c.parseQuery(stmt.Raw, pp, o) if err != nil { var e *sqlerr.Error loc := stmt.Raw.Pos() if errors.As(err, &e) && e.Location != 0 { loc = e.Location } + // Locations are reported against the rewritten query; map them + // back so errors point at what the user wrote. + loc = pp.Origin(loc) + if e != nil && e.Location != 0 { + e.Location = loc + } merr.Add(filename, src, loc, err) // If this rpc unauthenticated error bubbles up, then all future parsing/analysis will fail if errors.Is(err, rpc.ErrUnauthenticated) { @@ -130,7 +146,7 @@ func (c *Compiler) parseQueries(o opts.Parser) (*Result, error) { queryName := query.Metadata.Name if queryName != "" { if _, exists := set[queryName]; exists { - merr.Add(filename, src, stmt.Raw.Pos(), fmt.Errorf("duplicate query name: %s", queryName)) + merr.Add(filename, src, pp.Origin(stmt.Raw.Pos()), fmt.Errorf("duplicate query name: %s", queryName)) continue } set[queryName] = struct{}{} diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index c60b7618b2..98dd82cbdc 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -166,34 +166,27 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node) } } - var oldString string - var oldFunc func(string) int - - // use the sqlc.embed string instead - if embed, ok := qc.embeds.Find(ref); ok { - oldString = embed.Orig() - } else { - oldFunc = func(s string) int { - length := 0 - for i, o := range old { - if hasSeparator := i > 0; hasSeparator { - length++ - } - if strings.HasPrefix(s[length:], o) { - length += len(o) - } else if quoted := c.quote(o); strings.HasPrefix(s[length:], quoted) { - length += len(quoted) - } else { - length += len(o) - } + // An embed was rewritten to "table.*" in the query text, so it is + // measured the same way as a star reference the user wrote. + oldFunc := func(s string) int { + length := 0 + for i, o := range old { + if hasSeparator := i > 0; hasSeparator { + length++ + } + if strings.HasPrefix(s[length:], o) { + length += len(o) + } else if quoted := c.quote(o); strings.HasPrefix(s[length:], quoted) { + length += len(quoted) + } else { + length += len(o) } - return length } + return length } edits = append(edits, source.Edit{ Location: res.Location - raw.StmtLocation, - Old: oldString, OldFunc: oldFunc, New: strings.Join(cols, ", "), }) diff --git a/internal/compiler/output_columns.go b/internal/compiler/output_columns.go index 6cc2567ebe..ef3d662057 100644 --- a/internal/compiler/output_columns.go +++ b/internal/compiler/output_columns.go @@ -258,7 +258,7 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er if hasStarRef(n) { // add a column with a reference to an embedded table - if embed, ok := qc.embeds.Find(n); ok { + if embed, ok := qc.embeds.Find(n.Location, res.Location); ok { cols = append(cols, &Column{ Name: embed.Table.Name, EmbedTable: embed.Table, diff --git a/internal/compiler/parse.go b/internal/compiler/parse.go index be510d71d4..796f1a6fb1 100644 --- a/internal/compiler/parse.go +++ b/internal/compiler/parse.go @@ -12,15 +12,32 @@ import ( "github.com/sqlc-dev/sqlc/internal/source" "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/astutils" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" "github.com/sqlc-dev/sqlc/internal/sql/validate" "github.com/sqlc-dev/sqlc/internal/sqlcdebug" ) var debugDumpAST = sqlcdebug.New("dumpast") -func (c *Compiler) parseQuery(stmt ast.Node, src string, o opts.Parser) (*Query, error) { +func (c *Compiler) parseQuery(stmt ast.Node, pp *preprocess.Result, o opts.Parser) (*Query, error) { + raw, ok := stmt.(*ast.RawStmt) + if !ok { + return nil, errors.New("node is not a statement") + } + + // The preprocessor already replaced every sqlc.* construct with native SQL + // and recorded what it replaced. + pre := pp.Statement(raw.StmtLocation) + if pre.Err != nil { + return nil, pre.Err + } + + // Engines number bind parameters in whatever order they convert the AST. + // Restore the numbering the preprocessor assigned in source order. + renumberParams(raw, pre.Numbers) + if c.coreCatalog != nil { - return c.parseQueryCore(stmt, src) + return c.parseQueryCore(raw, pp.Text, pre) } ctx := context.Background() @@ -29,18 +46,7 @@ func (c *Compiler) parseQuery(stmt ast.Node, src string, o opts.Parser) (*Query, debug.Dump(stmt) } - // validate sqlc-specific syntax - if err := validate.SqlcFunctions(stmt); err != nil { - return nil, err - } - - // rewrite queries to remove sqlc.* functions - - raw, ok := stmt.(*ast.RawStmt) - if !ok { - return nil, errors.New("node is not a statement") - } - rawSQL, err := source.Pluck(src, raw.StmtLocation, raw.StmtLen) + rawSQL, err := source.Pluck(pp.Text, raw.StmtLocation, raw.StmtLen) if err != nil { return nil, err } @@ -128,7 +134,7 @@ func (c *Compiler) parseQuery(stmt ast.Node, src string, o opts.Parser) (*Query, Query: expandedQuery, } } else if c.analyzer != nil { - inference, _ := c.inferQuery(raw, rawSQL) + inference, _ := c.inferQuery(raw, rawSQL, pre) if inference == nil { inference = &analysis{} } @@ -156,7 +162,7 @@ func (c *Compiler) parseQuery(stmt ast.Node, src string, o opts.Parser) (*Query, // FOOTGUN: combineAnalysis mutates inference anlys = combineAnalysis(inference, result) } else { - anlys, err = c.analyzeQuery(raw, rawSQL) + anlys, err = c.analyzeQuery(raw, rawSQL, pre) if err != nil { return nil, err } @@ -188,6 +194,23 @@ func (c *Compiler) parseQuery(stmt ast.Node, src string, o opts.Parser) (*Query, }, nil } +// renumberParams applies the parameter numbers the preprocessor assigned, +// matching each node by the location of its placeholder in the query text. +func renumberParams(raw *ast.RawStmt, numbers map[int]int) { + if len(numbers) == 0 { + return + } + astutils.Walk(astutils.VisitorFunc(func(node ast.Node) { + ref, ok := node.(*ast.ParamRef) + if !ok { + return + } + if n, ok := numbers[ref.Location]; ok { + ref.Number = n + } + }), raw) +} + func rangeVars(root ast.Node) []*ast.RangeVar { var vars []*ast.RangeVar find := astutils.VisitorFunc(func(node ast.Node) { diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 5cede4356b..2fc1c1272e 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -10,15 +10,11 @@ import ( "github.com/sqlc-dev/sqlc/internal/source" "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/named" - "github.com/sqlc-dev/sqlc/internal/sql/rewrite" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" "github.com/sqlc-dev/sqlc/internal/sql/validate" ) -func (c *Compiler) parseQueryCore(stmt ast.Node, src string) (*Query, error) { - raw, ok := stmt.(*ast.RawStmt) - if !ok { - return nil, errors.New("node is not a statement") - } +func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess.Statement) (*Query, error) { rawSQL, err := source.Pluck(src, raw.StmtLocation, raw.StmtLen) if err != nil { return nil, err @@ -48,21 +44,17 @@ func (c *Compiler) parseQueryCore(stmt ast.Node, src string) (*Query, error) { return nil, err } - numbers, dollar, err := validate.ParamRef(raw) - if err != nil { - return nil, err - } - rewritten, namedParams, edits := rewrite.NamedParameters(c.conf.Engine, raw, numbers, dollar) - expanded, err := source.Mutate(rawSQL, edits) - if err != nil { - return nil, err + if pre.ParamErr != nil { + return nil, pre.ParamErr } + namedParams := pre.Params + expanded := rawSQL var cols []*Column var params []Parameter - switch rewritten.Stmt.(type) { + switch raw.Stmt.(type) { case *ast.SelectStmt, *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt: - res, err := coreanalyzer.Prepare(c.coreCatalog, rewritten) + res, err := coreanalyzer.Prepare(c.coreCatalog, raw) if err != nil { return nil, err } @@ -123,12 +115,17 @@ func coreParamColumn(p core.Parameter, params *named.ParamSet) *Column { col.Table = &ast.TableName{Schema: p.Source.Schema, Name: p.Source.Table} col.OriginalName = p.Source.Column } - if col.Name == "" { - if name, ok := params.NameFor(p.Number); ok && name != "" { - col.Name = name - } else if p.Source != nil { - col.Name = p.Source.Column - } + if col.Name == "" && p.Source != nil { + col.Name = p.Source.Column + } + // Merge in what the user asked for: sqlc.narg() makes the parameter + // nullable and sqlc.slice() marks it as a slice, whichever way the + // analyzer typed it. + if param, isNamed := params.FetchMerge(p.Number, named.NewInferredParam(col.Name, p.NotNull)); isNamed { + col.Name = param.Name() + col.NotNull = param.NotNull() + col.IsSqlcSlice = param.IsSqlcSlice() + col.IsNamedParam = true } return col } diff --git a/internal/compiler/query_catalog.go b/internal/compiler/query_catalog.go index 80b59d876c..c4972415ad 100644 --- a/internal/compiler/query_catalog.go +++ b/internal/compiler/query_catalog.go @@ -5,16 +5,16 @@ import ( "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/catalog" - "github.com/sqlc-dev/sqlc/internal/sql/rewrite" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" ) type QueryCatalog struct { catalog *catalog.Catalog ctes map[string]*Table - embeds rewrite.EmbedSet + embeds preprocess.EmbedSet } -func (comp *Compiler) buildQueryCatalog(c *catalog.Catalog, node ast.Node, embeds rewrite.EmbedSet) (*QueryCatalog, error) { +func (comp *Compiler) buildQueryCatalog(c *catalog.Catalog, node ast.Node, embeds preprocess.EmbedSet) (*QueryCatalog, error) { var with *ast.WithClause switch n := node.(type) { case *ast.DeleteStmt: diff --git a/internal/compiler/resolve.go b/internal/compiler/resolve.go index d926f2b1fc..f47a01d7a8 100644 --- a/internal/compiler/resolve.go +++ b/internal/compiler/resolve.go @@ -9,7 +9,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/sql/astutils" "github.com/sqlc-dev/sqlc/internal/sql/catalog" "github.com/sqlc-dev/sqlc/internal/sql/named" - "github.com/sqlc-dev/sqlc/internal/sql/rewrite" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" ) @@ -21,7 +21,7 @@ func dataType(n *ast.TypeName) string { } } -func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar, args []paramRef, params *named.ParamSet, embeds rewrite.EmbedSet) ([]Parameter, error) { +func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar, args []paramRef, params *named.ParamSet, embeds preprocess.EmbedSet) ([]Parameter, error) { c := comp.catalog aliasMap := map[string]*ast.TableName{} diff --git a/internal/core/analyzer/bench_test.go b/internal/core/analyzer/bench_test.go index fa4a67dad4..d2846a0419 100644 --- a/internal/core/analyzer/bench_test.go +++ b/internal/core/analyzer/bench_test.go @@ -10,8 +10,7 @@ import ( coreschema "github.com/sqlc-dev/sqlc/internal/core/schema" "github.com/sqlc-dev/sqlc/internal/engine/googlesql" "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/rewrite" - "github.com/sqlc-dev/sqlc/internal/sql/validate" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" ) const benchSchema = ` @@ -50,23 +49,14 @@ func parseAll(t testing.TB, src string) []ast.Node { return out } -// parseQueries mirrors the compiler pipeline: parse, then rewrite named -// parameters into ParamRef nodes before handing the statement to the analyzer. +// parseQueries mirrors the compiler pipeline: rewrite sqlc syntax to native SQL, +// then parse. func parseQueries(t testing.TB, src string) []ast.Node { - out := make([]ast.Node, 0) - for _, n := range parseAll(t, src) { - raw, ok := n.(*ast.RawStmt) - if !ok { - t.Fatalf("not a raw statement: %T", n) - } - numbers, dollar, err := validate.ParamRef(raw) - if err != nil { - t.Fatal(err) - } - rewritten, _, _ := rewrite.NamedParameters(config.EngineGoogleSQL, raw, numbers, dollar) - out = append(out, rewritten) + res, err := preprocess.File(config.EngineGoogleSQL, src) + if err != nil { + t.Fatal(err) } - return out + return parseAll(t, res.Text) } func newBenchCatalog(t testing.TB) (*core.Catalog, []ast.Node) { diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/exec.json b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/exec.json new file mode 100644 index 0000000000..c92bfa3dab --- /dev/null +++ b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "clickhouse", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql new file mode 100644 index 0000000000..19ea7c7c43 --- /dev/null +++ b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql @@ -0,0 +1,5 @@ +-- name: GetEvent :one +SELECT id, name FROM events WHERE id = sqlc.arg(event_id); + +-- name: FilterEvents :many +SELECT id, name FROM events WHERE name = sqlc.arg(name) AND amount > sqlc.narg(min_amount); diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/schema.sql b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/schema.sql new file mode 100644 index 0000000000..29960ee63d --- /dev/null +++ b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE events ( + id UInt64, + name String, + tag Nullable(String), + amount Float64, + created DateTime +) ENGINE = MergeTree ORDER BY id; diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt new file mode 100644 index 0000000000..d01537f993 --- /dev/null +++ b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt @@ -0,0 +1,76 @@ +[ + { + "name": "GetEvent", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "uint64", + "not_null": true, + "is_array": false, + "table": "events" + }, + { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "events" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "event_id", + "data_type": "uint64", + "not_null": true, + "is_array": false, + "table": "events" + } + } + ] + }, + { + "name": "FilterEvents", + "cmd": ":many", + "columns": [ + { + "name": "id", + "data_type": "uint64", + "not_null": true, + "is_array": false, + "table": "events" + }, + { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "events" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "events" + } + }, + { + "number": 2, + "column": { + "name": "min_amount", + "data_type": "float64", + "not_null": false, + "is_array": false, + "table": "events" + } + } + ] + } +] diff --git a/internal/endtoend/testdata/sqlc_arg_invalid/mysql/stderr.txt b/internal/endtoend/testdata/sqlc_arg_invalid/mysql/stderr.txt index 73966182fb..6e2cf1379b 100644 --- a/internal/endtoend/testdata/sqlc_arg_invalid/mysql/stderr.txt +++ b/internal/endtoend/testdata/sqlc_arg_invalid/mysql/stderr.txt @@ -1,6 +1,6 @@ # package querytest -query.sql:1:1: function "sqlc.argh" does not exist +query.sql:2:45: function "sqlc.argh" does not exist query.sql:5:45: expected 1 parameter to sqlc.arg; got 2 query.sql:8:45: expected 1 parameter to sqlc.arg; got 0 -query.sql:11:45: expected parameter to sqlc.arg to be string or reference; got *ast.FuncCall -query.sql:14:45: expected parameter to sqlc.arg to be string or reference; got *ast.ParamRef +query.sql:11:45: expected parameter to sqlc.arg to be string or reference; got "sqlc.arg(target_id)" +query.sql:14:45: expected parameter to sqlc.arg to be string or reference; got "?" diff --git a/internal/endtoend/testdata/sqlc_arg_invalid/postgresql/stderr.txt b/internal/endtoend/testdata/sqlc_arg_invalid/postgresql/stderr.txt index 8dbea1f949..2bc3407422 100644 --- a/internal/endtoend/testdata/sqlc_arg_invalid/postgresql/stderr.txt +++ b/internal/endtoend/testdata/sqlc_arg_invalid/postgresql/stderr.txt @@ -1,7 +1,6 @@ # package querytest -query.sql:1:1: function "sqlc.argh" does not exist +query.sql:2:45: function "sqlc.argh" does not exist query.sql:5:45: expected 1 parameter to sqlc.arg; got 2 query.sql:8:45: expected 1 parameter to sqlc.arg; got 0 -query.sql:11:45: expected parameter to sqlc.arg to be string or reference; got *ast.FuncCall -query.sql:14:45: expected parameter to sqlc.arg to be string or reference; got *ast.ParamRef - +query.sql:11:45: expected parameter to sqlc.arg to be string or reference; got "sqlc.arg(target_id)" +query.sql:14:45: expected parameter to sqlc.arg to be string or reference; got "$1" diff --git a/internal/endtoend/testdata/sqlc_arg_invalid/sqlite/stderr.txt b/internal/endtoend/testdata/sqlc_arg_invalid/sqlite/stderr.txt index be38c8b505..4cf898bcfb 100644 --- a/internal/endtoend/testdata/sqlc_arg_invalid/sqlite/stderr.txt +++ b/internal/endtoend/testdata/sqlc_arg_invalid/sqlite/stderr.txt @@ -1,6 +1,6 @@ # package querytest -query.sql:7:1: function "sqlc.argh" does not exist +query.sql:7:45: function "sqlc.argh" does not exist query.sql:10:45: expected 1 parameter to sqlc.arg; got 2 query.sql:13:45: expected 1 parameter to sqlc.arg; got 0 -query.sql:16:45: expected parameter to sqlc.arg to be string or reference; got *ast.FuncCall -query.sql:19:45: expected parameter to sqlc.arg to be string or reference; got *ast.ParamRef +query.sql:16:45: expected parameter to sqlc.arg to be string or reference; got "sqlc.arg(target_id)" +query.sql:19:45: expected parameter to sqlc.arg to be string or reference; got "?" diff --git a/internal/engine/clickhouse/convert.go b/internal/engine/clickhouse/convert.go index 79bf442cf5..ea4f342e2c 100644 --- a/internal/engine/clickhouse/convert.go +++ b/internal/engine/clickhouse/convert.go @@ -472,6 +472,7 @@ func (c *cc) convertBinaryExpr(n *chast.BinaryExpr) ast.Node { // Handle other operators return &ast.A_Expr{ + Kind: ast.A_Expr_Kind_OP, Name: &ast.List{ Items: []ast.Node{&ast.String{Str: n.Op}}, }, @@ -692,6 +693,7 @@ func (c *cc) convertUnaryExpr(n *chast.UnaryExpr) ast.Node { } return &ast.A_Expr{ + Kind: ast.A_Expr_Kind_OP, Name: &ast.List{ Items: []ast.Node{&ast.String{Str: n.Op}}, }, diff --git a/internal/engine/clickhouse/seed.go b/internal/engine/clickhouse/seed.go index 986838285c..a9e033d756 100644 --- a/internal/engine/clickhouse/seed.go +++ b/internal/engine/clickhouse/seed.go @@ -32,20 +32,60 @@ var clickhouseTypes = []chType{ {"Map", "U"}, {"Tuple", "U"}, {"Nested", "U"}, {"Nothing", "U"}, } +var comparisonOperators = []string{"=", "<>", "!=", "<", "<=", ">", ">="} + +var arithmeticOperators = []string{"+", "-", "*", "/"} + func Seed(cat *core.Catalog) error { dialectOID, err := cat.CreateDialect("clickhouse") if err != nil { return err } + oids := make(map[string]int64, len(clickhouseTypes)) for _, t := range clickhouseTypes { - if _, err := cat.CreateTypeSpec(core.TypeSpec{ + oid, err := cat.CreateTypeSpec(core.TypeSpec{ Name: t.name, Typtype: "b", Category: t.category, DialectOID: dialectOID, - }); err != nil { + }) + if err != nil { return fmt.Errorf("seed clickhouse type %q: %w", t.name, err) } + oids[t.name] = oid + } + + boolOID := oids["Bool"] + for _, t := range clickhouseTypes { + if t.category == "A" || t.category == "U" { + continue + } + for _, op := range comparisonOperators { + if _, err := cat.CreateOperator(core.OperatorSpec{ + Name: op, + DialectOID: dialectOID, + LeftTypeOID: oids[t.name], + RightTypeOID: oids[t.name], + ResultTypeOID: boolOID, + }); err != nil { + return fmt.Errorf("seed clickhouse operator %q(%s): %w", op, t.name, err) + } + } + if t.category != "N" { + continue + } + for _, op := range arithmeticOperators { + if _, err := cat.CreateOperator(core.OperatorSpec{ + Name: op, + DialectOID: dialectOID, + LeftTypeOID: oids[t.name], + RightTypeOID: oids[t.name], + ResultTypeOID: oids[t.name], + }); err != nil { + return fmt.Errorf("seed clickhouse operator %q(%s): %w", op, t.name, err) + } + } } + return nil } diff --git a/internal/engine/dolphin/CLAUDE.md b/internal/engine/dolphin/CLAUDE.md index 6b5ffad384..0a11012dab 100644 --- a/internal/engine/dolphin/CLAUDE.md +++ b/internal/engine/dolphin/CLAUDE.md @@ -199,13 +199,10 @@ func (p *Parser) Param(n int) string { **Cause**: New AST node type not handled in `astutils/walk.go` or `astutils/rewrite.go` **Solution**: Add case for the node type in both files -### Issue: sqlc.arg() not converted in ON DUPLICATE KEY UPDATE -**Cause**: `InsertStmt` case in `rewrite.go` didn't traverse `OnDuplicateKeyUpdate` -**Solution**: Add `a.apply(n, "OnDuplicateKeyUpdate", nil, n.OnDuplicateKeyUpdate)` - ### Issue: MySQL @variable being treated as parameter -**Cause**: Converting `VariableExpr` to `A_Expr` with `@` operator -**Solution**: Use `ast.VariableExpr` instead, which is not detected by `named.IsParamSign()` +**Cause**: The preprocessor's dialect said `@name` is sqlc syntax +**Solution**: MySQL sets `AtSign: false` in `internal/sql/preprocess/dialect.go`, so +`@user_id` is left alone before the parser ever sees it ### Issue: Type length appearing incorrectly (e.g., datetime(39)) **Cause**: Using internal `flen` for all types diff --git a/internal/engine/googlesql/convert.go b/internal/engine/googlesql/convert.go index acf0af4657..b9d75062ea 100644 --- a/internal/engine/googlesql/convert.go +++ b/internal/engine/googlesql/convert.go @@ -11,6 +11,9 @@ import ( type cc struct { paramCount int + // namedParams tracks the number assigned to each "@name" so repeated uses + // share a single parameter. + namedParams map[string]int } func (c *cc) convert(node zjast.Node) ast.Node { @@ -511,17 +514,24 @@ func (c *cc) convertIntLiteral(n *zjast.IntLiteral) ast.Node { // convertParameterExpr converts "@name" and "?" query parameters. // -// Named parameters are mapped to the shape the PostgreSQL and SQLite engines -// produce for "@name": an A_Expr with the operator "@" and the parameter name -// as the right operand. That is the representation sqlc's named-parameter -// rewriter (internal/sql/rewrite, named.IsParamSign) already understands. -// Positional "?" parameters become ParamRef nodes numbered by their 1-based -// position in the statement. +// Both become ParamRef nodes. GoogleSQL names its parameters, so repeated uses +// of a name share a number; positional parameters are numbered by their 1-based +// position in the statement. The compiler replaces these numbers with the ones +// the preprocessor assigned in source order, so they only matter when a +// statement is converted on its own. func (c *cc) convertParameterExpr(n *zjast.ParameterExpr) ast.Node { if n.Name != nil { - return &ast.A_Expr{ - Name: &ast.List{Items: []ast.Node{&ast.String{Str: "@"}}}, - Rexpr: &ast.String{Str: n.Name.Name}, + number, ok := c.namedParams[n.Name.Name] + if !ok { + c.paramCount++ + number = c.paramCount + if c.namedParams == nil { + c.namedParams = map[string]int{} + } + c.namedParams[n.Name.Name] = number + } + return &ast.ParamRef{ + Number: number, Location: n.Pos(), } } diff --git a/internal/engine/postgresql/utils.go b/internal/engine/postgresql/utils.go index b8d49b1a97..0f4be00634 100644 --- a/internal/engine/postgresql/utils.go +++ b/internal/engine/postgresql/utils.go @@ -29,16 +29,6 @@ func isNotNull(n *nodes.ColumnDef) bool { return false } -func IsNamedParamFunc(node *nodes.Node) bool { - fun, ok := node.Node.(*nodes.Node_FuncCall) - return ok && joinNodes(fun.FuncCall.Funcname, ".") == "sqlc.arg" -} - -func IsNamedParamSign(node *nodes.Node) bool { - expr, ok := node.Node.(*nodes.Node_AExpr) - return ok && joinNodes(expr.AExpr.Name, ".") == "@" -} - func makeByte(s string) byte { var b byte if s == "" { diff --git a/internal/sql/ast/CLAUDE.md b/internal/sql/ast/CLAUDE.md index e769fbfca6..ee62274d6a 100644 --- a/internal/sql/ast/CLAUDE.md +++ b/internal/sql/ast/CLAUDE.md @@ -105,9 +105,11 @@ case *ast.VariableExpr: ## Important Distinctions ### MySQL @variable vs sqlc @param -- MySQL user variables (`@user_id`) use `VariableExpr` - preserved as-is in output -- sqlc named parameters (`@param`) use `A_Expr` with `@` operator - replaced with `?` -- The `named.IsParamSign()` function checks for `A_Expr` with `@` operator +- sqlc named parameters (`@param`) never reach the AST: `internal/sql/preprocess` + replaces them with the dialect's native placeholder before parsing, so engines + only ever see a `ParamRef` +- `@name` is only sqlc syntax where the preprocessor's dialect says so. MySQL is + excluded, so `@user_id` there stays a user variable (`VariableExpr`) ### Type Modifiers - `TypeName.Typmods` holds type modifiers like `varchar(255)` diff --git a/internal/sql/astutils/CLAUDE.md b/internal/sql/astutils/CLAUDE.md index b7903542c5..7575ea9a12 100644 --- a/internal/sql/astutils/CLAUDE.md +++ b/internal/sql/astutils/CLAUDE.md @@ -88,17 +88,22 @@ astutils.Walk(&tv, stmt.FromClause) // tv.list now contains all RangeVar nodes ``` -### Replacing Named Parameters -The `rewrite/parameters.go` uses Apply to replace `sqlc.arg()` calls with `ParamRef`: +### Rewriting Nodes In Place +Apply replaces a node with a new one as the tree is walked: ```go astutils.Apply(root, func(cr *astutils.Cursor) bool { - if named.IsParamFunc(cr.Node()) { - cr.Replace(&ast.ParamRef{Number: nextParam()}) + if _, ok := cr.Node().(*ast.SomeType); ok { + cr.Replace(&ast.OtherType{}) } return true }, nil) ``` +Note that sqlc syntax (`sqlc.arg()`, `@name`) is *not* handled here. It is +rewritten to native SQL by `internal/sql/preprocess` before the engine parses a +query, so a node type missing from walk.go/rewrite.go can no longer silently +drop a parameter. + ## Node Types That Must Be Handled All node types in `internal/sql/ast/` must have cases in both walk.go and rewrite.go. Key MySQL-specific nodes: diff --git a/internal/sql/named/CLAUDE.md b/internal/sql/named/CLAUDE.md index 05ba358ee9..b1f1cae22e 100644 --- a/internal/sql/named/CLAUDE.md +++ b/internal/sql/named/CLAUDE.md @@ -1,94 +1,58 @@ # Named Parameters Package - Claude Code Guide -This package provides utilities for identifying sqlc's named parameter syntax. +This package models a query's parameters: their names, their nullability and +whether they came from `sqlc.slice()`. -## Named Parameter Styles +It no longer knows anything about sqlc's *syntax*. `sqlc.arg()`, `sqlc.narg()`, +`sqlc.slice()` and `@name` are rewritten to native placeholders by +`internal/sql/preprocess` before any engine parser runs, and that package builds +the `ParamSet` while it rewrites. -sqlc supports two styles of named parameters: +## Param -### 1. Function-style: `sqlc.arg(name)`, `sqlc.narg(name)`, `sqlc.slice(name)` -Identified by `IsParamFunc()`: -```go -func IsParamFunc(node ast.Node) bool { - call, ok := node.(*ast.FuncCall) - if !ok { - return false - } - return call.Func.Schema == "sqlc" && - (call.Func.Name == "arg" || call.Func.Name == "narg" || call.Func.Name == "slice") -} -``` +A `Param` carries the user-facing name plus a nullability that combines what was +inferred from the schema with what the user asked for: + +- `NewParam(name)` — unspecified nullability, from `sqlc.arg()` or `@name` +- `NewUserNullableParam(name)` — from `sqlc.narg()`, always nullable +- `NewSqlcSlice(name)` — from `sqlc.slice()` +- `NewInferredParam(name, notNull)` — what the compiler inferred + +A user-specified nullability outranks an inferred one. `mergeParam` ORs the +nullability bits together, so the order the two sources arrive in does not +matter. + +## ParamSet + +`ParamSet` maps placeholder numbers to `Param` values for a single statement. -### 2. At-sign style: `@param_name` (PostgreSQL only) -Identified by `IsParamSign()`: ```go -func IsParamSign(node ast.Node) bool { - expr, ok := node.(*ast.A_Expr) - return ok && astutils.Join(expr.Name, ".") == "@" -} +ps := named.NewParamSet(numbersAlreadyUsed, hasNamedSupport) +n := ps.Add(named.NewParam("author_id")) // returns the placeholder number ``` -## Important Distinction: sqlc @param vs MySQL @variable +`hasNamedSupport` is false for dialects that send one argument per `?` +(MySQL, ClickHouse). There, every occurrence of a name gets its own number. For +`$1`/`?1`/`@name` dialects repeated uses of a name share a number. + +The compiler reads the set back with: -**sqlc named parameters** (`@param` in PostgreSQL queries): -- Represented as `A_Expr` with `Kind=A_Expr_Kind_OP` and `Name=["@"]` -- Detected by `IsParamSign()` -- Replaced with positional parameters (`$1`, `$2` for PostgreSQL, `?` for MySQL) +- `NameFor(number)` — the user-facing name for a placeholder +- `FetchMerge(number, inferred)` — the merged param and whether it was named -**MySQL user variables** (`@user_id` in MySQL queries): -- Represented as `VariableExpr` -- NOT detected by `IsParamSign()` (it checks for `A_Expr`, not `VariableExpr`) -- Preserved as-is in the output SQL +## MySQL @variable vs sqlc @param + +`@name` is only sqlc syntax where the preprocessor's dialect says it is. MySQL +sets `AtSign: false`, so `@user_id` there is a user variable and reaches the +parser untouched (converted to `ast.VariableExpr`). PostgreSQL, SQLite and +GoogleSQL treat `@name` as a named parameter. -This distinction is critical: ```sql -- PostgreSQL with sqlc @param syntax: SELECT * FROM users WHERE id = @user_id --- Becomes: SELECT * FROM users WHERE id = $1 +-- Preprocessed to: SELECT * FROM users WHERE id = $1 -- MySQL with user variable: SELECT * FROM users WHERE id != @user_id -- Stays: SELECT * FROM users WHERE id != @user_id ``` - -## Usage in Parameter Rewriting - -The `rewrite/parameters.go` package uses these functions to find and replace named parameters: - -```go -// Find all named parameters -params := astutils.Search(root, func(node ast.Node) bool { - return named.IsParamFunc(node) || named.IsParamSign(node) -}) - -// Replace with positional parameters -astutils.Apply(root, func(cr *astutils.Cursor) bool { - if named.IsParamFunc(cr.Node()) || named.IsParamSign(cr.Node()) { - cr.Replace(&ast.ParamRef{Number: nextParam()}) - } - return true -}, nil) -``` - -## Converting MySQL @variable Correctly - -When converting TiDB's `VariableExpr` in `dolphin/convert.go`: - -```go -// CORRECT - preserves MySQL user variable as-is -func (c *cc) convertVariableExpr(n *pcast.VariableExpr) ast.Node { - return &ast.VariableExpr{ - Name: n.Name, - Location: n.OriginTextPosition(), - } -} - -// WRONG - would be treated as sqlc named parameter -func (c *cc) convertVariableExpr(n *pcast.VariableExpr) ast.Node { - return &ast.A_Expr{ - Kind: ast.A_Expr_Kind_OP, - Name: &ast.List{Items: []ast.Node{&ast.String{Str: "@"}}}, - Rexpr: &ast.String{Str: n.Name}, - } -} -``` diff --git a/internal/sql/named/is.go b/internal/sql/named/is.go deleted file mode 100644 index d53c1d9905..0000000000 --- a/internal/sql/named/is.go +++ /dev/null @@ -1,26 +0,0 @@ -package named - -import ( - "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/astutils" -) - -// IsParamFunc fulfills the astutils.Search -func IsParamFunc(node ast.Node) bool { - call, ok := node.(*ast.FuncCall) - if !ok { - return false - } - - if call.Func == nil { - return false - } - - isValid := call.Func.Schema == "sqlc" && (call.Func.Name == "arg" || call.Func.Name == "narg" || call.Func.Name == "slice") - return isValid -} - -func IsParamSign(node ast.Node) bool { - expr, ok := node.(*ast.A_Expr) - return ok && astutils.Join(expr.Name, ".") == "@" -} diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md new file mode 100644 index 0000000000..5f0a8ee31c --- /dev/null +++ b/internal/sql/preprocess/CLAUDE.md @@ -0,0 +1,86 @@ +# SQL Preprocess Package - Claude Code Guide + +This package rewrites sqlc's query syntax into native SQL **before** any engine +parser sees a query. After it runs, no sqlc syntax remains in the text, so +parsers, converters and `astutils` traversals only ever deal with SQL. + +## What it rewrites + +| syntax | becomes | +| --- | --- | +| `sqlc.arg(name)` / `sqlc.narg(name)` | the dialect's native placeholder | +| `sqlc.slice(name)` | the placeholder wrapped in `/*SLICE:name*/` | +| `sqlc.embed(table)` | `table.*` | +| `@name` | the native placeholder, on dialects where `@` is sqlc syntax | + +Native placeholders by dialect: + +| engine | placeholder | `@name` | +| --- | --- | --- | +| postgresql | `$1` | sqlc syntax | +| mysql | `?` | user variable, left alone | +| sqlite | `?1` | sqlc syntax | +| googlesql | `@name` | native, left in place | +| clickhouse | `?` | not a parameter | + +## How it works + +`lexer` is a dialect-parameterized scanner. It does not parse SQL — it knows +only enough to skip the regions where sqlc syntax must not be rewritten: +comments (`--`, `/* */`, `#`), string literals, quoted identifiers, backticks +and PostgreSQL dollar-quoted strings. `Dialect` (dialect.go) is the single +place those lexical rules live. + +`File(engine, src)` walks the whole file, splits it at top-level semicolons and, +for each statement: + +1. `scan` collects every sqlc construct **and** every native placeholder, in + source order. +2. `number` assigns placeholder numbers. Numbered dialects keep the numbers the + user wrote and fill in the gaps; `?` dialects renumber everything in source + order, because each `?` is its own argument. +3. The statement is rewritten into the output buffer and a `Statement` records + what changed. + +## The side table + +`Result.Statement(offset)` returns the `Statement` covering an offset in the +**rewritten** text: + +- `Params` — the `named.ParamSet` for the statement +- `Embeds` — each rewritten `table.*`, keyed by its location so the compiler can + tell it apart from a star reference the user wrote +- `Slices` — the location of each `sqlc.slice()` placeholder +- `Numbers` — location → parameter number, used by the compiler to override the + numbers an engine assigned in AST-conversion order +- `Dollar` / `ParamErr` — the placeholder-style validation that used to live in + `validate.ParamRef` +- `Err` — a sqlc syntax error (unknown `sqlc.*` function, wrong arity, an + argument that is not an identifier or string) + +`Result.Origin(offset)` maps an offset in the rewritten text back to the +original source, so errors point at what the user wrote. + +## Invariants + +- **Line structure is preserved.** A rewrite never adds or removes a newline, so + line numbers survive even before `Origin` is applied. +- **An invalid statement is copied through untouched.** The engine still parses + what the user wrote and the error is reported per statement, not per file. +- **Nothing inside a comment or literal is rewritten.** Query annotations like + `-- name: GetAuthor :one` are comments, so they are always safe. + +## Tests + +`testdata///` holds an `input.sql`, the expected `output.sql` and, +for invalid input, a `stderr.txt`. `TestRewrite` runs one subtest per directory. +Regenerate the goldens with: + +```bash +go test ./internal/sql/preprocess -update +``` + +## Adding a dialect + +Add an entry to `dialects` in dialect.go. Nothing else in the codebase needs to +change for `sqlc.arg`/`narg`/`slice`/`embed` to work for a new engine. diff --git a/internal/sql/preprocess/dialect.go b/internal/sql/preprocess/dialect.go new file mode 100644 index 0000000000..25f54206c8 --- /dev/null +++ b/internal/sql/preprocess/dialect.go @@ -0,0 +1,120 @@ +package preprocess + +import ( + "github.com/sqlc-dev/sqlc/internal/config" +) + +// Style is the native placeholder syntax a dialect uses for bind parameters. +type Style int + +const ( + // StyleDollar numbers parameters as $1, $2, ... (PostgreSQL) + StyleDollar Style = iota + // StyleQuestion uses an unnumbered ? for every parameter (MySQL, ClickHouse) + StyleQuestion + // StyleOrdinal numbers parameters as ?1, ?2, ... (SQLite) + StyleOrdinal + // StyleAtName keeps parameters named as @name (GoogleSQL) + StyleAtName +) + +// Dialect describes the lexical rules the preprocessor needs to walk a query +// without parsing it: how the dialect quotes strings and identifiers, how it +// writes comments and which placeholder syntax it accepts. +// +// The preprocessor never interprets SQL. It only needs to know enough to skip +// the regions of a query where sqlc syntax must not be rewritten. +type Dialect struct { + // Style is the native placeholder syntax used for rewritten parameters. + Style Style + + // AtSign reports whether a bare "@name" is sqlc named-parameter syntax. + // It is false for MySQL, where "@name" is a user variable. + AtSign bool + + // DollarQuote reports whether $tag$ ... $tag$ string literals are valid. + DollarQuote bool + + // DollarNumber reports whether $1 is a bind parameter. + DollarNumber bool + + // Question reports whether ? is a bind parameter. + Question bool + + // Backtick reports whether `ident` is a quoted identifier. + Backtick bool + + // HashComment reports whether # starts a line comment. + HashComment bool + + // DoubleQuoteString reports whether "..." is a string literal rather than + // a quoted identifier. MySQL treats it as a string unless ANSI_QUOTES is + // enabled. + DoubleQuoteString bool + + // NestedBlockComment reports whether /* ... /* ... */ ... */ nests. + NestedBlockComment bool + + // Backslash reports whether a backslash escapes the next character inside + // a string literal. + Backslash bool + + // FoldIdentifier reports whether the dialect lowercases unquoted + // identifiers. It decides the case of a parameter named by a bare + // reference, e.g. sqlc.arg(FooBar). + FoldIdentifier bool +} + +var dialects = map[config.Engine]Dialect{ + config.EnginePostgreSQL: { + Style: StyleDollar, + AtSign: true, + DollarQuote: true, + DollarNumber: true, + NestedBlockComment: true, + FoldIdentifier: true, + }, + config.EngineMySQL: { + Style: StyleQuestion, + Question: true, + Backtick: true, + HashComment: true, + DoubleQuoteString: true, + Backslash: true, + FoldIdentifier: true, + }, + config.EngineSQLite: { + Style: StyleOrdinal, + AtSign: true, + Question: true, + Backtick: true, + Backslash: true, + }, + config.EngineGoogleSQL: { + Style: StyleAtName, + AtSign: true, + Question: true, + Backtick: true, + Backslash: true, + }, + config.EngineClickHouse: { + Style: StyleQuestion, + Question: true, + Backtick: true, + HashComment: true, + Backslash: true, + }, +} + +// DialectFor returns the lexical rules for an engine. +func DialectFor(engine config.Engine) (Dialect, bool) { + d, ok := dialects[engine] + return d, ok +} + +// hasNamedSupport reports whether repeated uses of the same parameter name can +// share a single placeholder. MySQL and ClickHouse send an argument per "?", so +// every occurrence needs its own number. +func (d Dialect) hasNamedSupport() bool { + return d.Style != StyleQuestion +} diff --git a/internal/sql/preprocess/lexer.go b/internal/sql/preprocess/lexer.go new file mode 100644 index 0000000000..d17d80b077 --- /dev/null +++ b/internal/sql/preprocess/lexer.go @@ -0,0 +1,284 @@ +package preprocess + +import ( + "strings" +) + +// lexer is a dialect-parameterized scanner over a SQL file. It does not parse +// SQL; it knows just enough about comments, string literals, quoted identifiers +// and dollar-quoted strings to walk a query without ever looking inside a +// region where sqlc syntax must be left alone. +type lexer struct { + d Dialect + src string +} + +func isIdentStart(c byte) bool { + return c == '_' || + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + c >= 0x80 +} + +func isIdentPart(c byte) bool { + return isIdentStart(c) || (c >= '0' && c <= '9') || c == '$' +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func isSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v' +} + +func (l *lexer) at(i int) byte { + if i < 0 || i >= len(l.src) { + return 0 + } + return l.src[i] +} + +// skip returns the offset just past the comment, string literal, quoted +// identifier or dollar-quoted string that starts at i. It returns -1 when no +// such token starts at i. +func (l *lexer) skip(i int) int { + switch c := l.at(i); c { + case '\'': + return l.skipQuoted(i, '\'', l.d.Backslash || l.isEscapeString(i)) + case '"': + return l.skipQuoted(i, '"', l.d.DoubleQuoteString && l.d.Backslash) + case '`': + if !l.d.Backtick { + return -1 + } + return l.skipQuoted(i, '`', false) + case '-': + if l.at(i+1) == '-' { + return l.skipLine(i) + } + case '#': + if l.d.HashComment { + return l.skipLine(i) + } + case '/': + if l.at(i+1) == '*' { + return l.skipBlockComment(i) + } + case '$': + if l.d.DollarQuote { + if end := l.skipDollarQuoted(i); end >= 0 { + return end + } + } + } + return -1 +} + +// isEscapeString reports whether the string literal starting at i is a +// PostgreSQL escape string constant, written E'...', where a backslash escapes +// the following character. +func (l *lexer) isEscapeString(i int) bool { + if !l.d.DollarQuote { // PostgreSQL-only syntax + return false + } + c := l.at(i - 1) + if c != 'e' && c != 'E' { + return false + } + // The E must not be the tail of a longer identifier. + return !isIdentPart(l.at(i - 2)) +} + +func (l *lexer) skipQuoted(i int, quote byte, backslash bool) int { + for j := i + 1; j < len(l.src); j++ { + switch l.src[j] { + case '\\': + if backslash { + j++ + } + case quote: + // A doubled quote is an escaped quote, not the end. + if l.at(j+1) == quote { + j++ + continue + } + return j + 1 + } + } + return len(l.src) +} + +func (l *lexer) skipLine(i int) int { + if idx := strings.IndexByte(l.src[i:], '\n'); idx >= 0 { + return i + idx + 1 + } + return len(l.src) +} + +func (l *lexer) skipBlockComment(i int) int { + depth := 0 + for j := i; j < len(l.src)-1; j++ { + if l.src[j] == '/' && l.src[j+1] == '*' { + depth++ + j++ + continue + } + if l.src[j] == '*' && l.src[j+1] == '/' { + depth-- + j++ + if depth == 0 || !l.d.NestedBlockComment { + return j + 1 + } + } + } + return len(l.src) +} + +// skipDollarQuoted handles PostgreSQL's $tag$ ... $tag$ string literals. It +// returns -1 when i does not start a dollar quote. +func (l *lexer) skipDollarQuoted(i int) int { + tag := l.dollarTag(i) + if tag == "" { + return -1 + } + if idx := strings.Index(l.src[i+len(tag):], tag); idx >= 0 { + return i + len(tag) + idx + len(tag) + } + return len(l.src) +} + +// dollarTag returns the full opening delimiter ("$$" or "$tag$") when one +// starts at i, and "" otherwise. +func (l *lexer) dollarTag(i int) string { + if l.at(i) != '$' { + return "" + } + j := i + 1 + for j < len(l.src) && l.src[j] != '$' { + c := l.src[j] + if isDigit(c) && j == i+1 { + return "" // $1 is a bind parameter, not a tag + } + if !isIdentPart(c) || c == '$' { + return "" + } + j++ + } + if j >= len(l.src) { + return "" + } + return l.src[i : j+1] +} + +// identEnd returns the offset just past the identifier that starts at i. +func (l *lexer) identEnd(i int) int { + j := i + for j < len(l.src) && isIdentPart(l.src[j]) { + j++ + } + return j +} + +// skipSpace advances past whitespace and comments. +func (l *lexer) skipSpace(i int) int { + for i < len(l.src) { + if isSpace(l.src[i]) { + i++ + continue + } + if l.src[i] == '-' && l.at(i+1) == '-' { + i = l.skipLine(i) + continue + } + if l.src[i] == '/' && l.at(i+1) == '*' { + i = l.skipBlockComment(i) + continue + } + if l.src[i] == '#' && l.d.HashComment { + i = l.skipLine(i) + continue + } + break + } + return i +} + +// statements splits the source into statement spans at top-level semicolons. +// The spans cover the whole source, so every byte belongs to exactly one +// statement. +func (l *lexer) statements() [][2]int { + var out [][2]int + start := 0 + for i := 0; i < len(l.src); { + if end := l.skip(i); end >= 0 { + i = end + continue + } + if l.src[i] == ';' { + out = append(out, [2]int{start, i + 1}) + start = i + 1 + } + i++ + } + if start < len(l.src) { + out = append(out, [2]int{start, len(l.src)}) + } + return out +} + +// matchParen returns the offset of the ")" that closes the "(" at i, or -1. +func (l *lexer) matchParen(i int) int { + depth := 0 + for j := i; j < len(l.src); { + if end := l.skip(j); end >= 0 { + j = end + continue + } + switch l.src[j] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return j + } + } + j++ + } + return -1 +} + +// splitArgs splits the text between parentheses on top-level commas. Empty or +// whitespace-only input yields no arguments. +func (l *lexer) splitArgs(start, end int) []string { + var args []string + depth := 0 + last := start + for j := start; j < end; { + if stop := l.skip(j); stop >= 0 { + if stop > end { + stop = end + } + j = stop + continue + } + switch l.src[j] { + case '(': + depth++ + case ')': + depth-- + case ',': + if depth == 0 { + args = append(args, l.src[last:j]) + last = j + 1 + } + } + j++ + } + tail := l.src[last:end] + if len(args) == 0 && strings.TrimSpace(tail) == "" { + return nil + } + return append(args, tail) +} diff --git a/internal/sql/preprocess/preprocess.go b/internal/sql/preprocess/preprocess.go new file mode 100644 index 0000000000..d002a37115 --- /dev/null +++ b/internal/sql/preprocess/preprocess.go @@ -0,0 +1,385 @@ +// Package preprocess rewrites sqlc's query syntax into native SQL before any +// engine parser sees a query. +// +// sqlc.arg(), sqlc.narg(), sqlc.slice(), sqlc.embed() and @name are sqlc +// syntax, not SQL. Historically they survived all the way through each engine's +// parser and were replaced by walking the resulting AST, which meant every +// engine had to round-trip a schema-qualified function call it did not +// understand. This package removes that requirement: it scans the query text +// with a dialect-parameterized lexer, replaces each construct with the engine's +// native placeholder, and records what it replaced. Engines then only ever see +// valid SQL. +package preprocess + +import ( + "errors" + "fmt" + "strings" + + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/sql/ast" + "github.com/sqlc-dev/sqlc/internal/sql/named" + "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" +) + +// Embed is a rewritten sqlc.embed(table) call. +type Embed struct { + Table *ast.TableName + + // Location is the offset of the rewritten "table.*" in the preprocessed + // text. The compiler uses it to tell a rewritten star reference apart from + // one the user wrote. + Location int + + param string +} + +// Orig returns the sqlc syntax this embed was rewritten from. +func (e Embed) Orig() string { + return fmt.Sprintf("sqlc.embed(%s)", e.param) +} + +// EmbedSet is the set of embeds in a single statement. +type EmbedSet []*Embed + +// Find returns the embed recorded at any of the given locations. Engines do not +// all populate the same location fields, so callers pass every location that +// could identify the node. +func (es EmbedSet) Find(locations ...int) (*Embed, bool) { + for _, e := range es { + for _, loc := range locations { + if loc == e.Location { + return e, true + } + } + } + return nil, false +} + +// Statement holds everything the preprocessor learned about a single statement. +type Statement struct { + // Start and End delimit the statement in the rewritten text. + Start, End int + + // Params maps each placeholder number to its sqlc metadata. + Params *named.ParamSet + + // Embeds records the sqlc.embed() calls that were rewritten to "table.*". + Embeds EmbedSet + + // Slices holds the offset, in the rewritten text, of every placeholder that + // came from sqlc.slice(), mapped to the parameter name. + Slices map[int]string + + // Numbers maps the offset of every placeholder in the rewritten text to the + // number assigned to it. Engines number bind parameters in whatever order + // they happen to convert the AST, so the compiler uses this to restore + // source order. + Numbers map[int]int + + // Dollar reports whether the statement's own placeholders are numbered. + Dollar bool + + // ParamErr is set when the placeholders the user wrote are inconsistent: + // numbered and unnumbered styles mixed, or a gap in the numbering. + ParamErr error + + // Err is a validation error found while rewriting. The statement text is + // left untouched when this is set, so the engine still sees the original + // query and the error can be reported against it. + Err error +} + +// Result is the outcome of preprocessing a SQL file. +type Result struct { + // Text is the rewritten SQL. It has the same number of statements and the + // same line structure as the input. + Text string + + stmts []*Statement + edits []edit +} + +// edit records one rewritten span, in both coordinate systems. +type edit struct { + oldStart, oldEnd int + newStart, newEnd int +} + +// Statement returns the preprocessing results for the statement covering the +// given offset in the rewritten text. It never returns nil. +func (r *Result) Statement(location int) *Statement { + for _, s := range r.stmts { + if location < s.End { + return s + } + } + return &Statement{Params: named.NewParamSet(nil, true)} +} + +// Origin maps an offset in the rewritten text back to the offset in the +// original source, so errors can be reported against what the user wrote. +func (r *Result) Origin(location int) int { + delta := 0 + for _, e := range r.edits { + if location < e.newStart { + break + } + if location < e.newEnd { + return e.oldStart + } + delta += (e.oldEnd - e.oldStart) - (e.newEnd - e.newStart) + } + return location + delta +} + +type kind int + +const ( + kindArg kind = iota + kindNarg + kindSlice + kindEmbed + kindNative +) + +// occurrence is one construct found in the query text. +type occurrence struct { + kind kind + start int // offset in the original source + end int + name string + // ident is the argument exactly as written, used to rebuild "table.*" for + // embeds so quoting is preserved. + ident string + // number is the placeholder number assigned to this occurrence. + number int + // explicit reports whether a native placeholder carried its own number. + explicit bool + err *sqlerr.Error +} + +// File rewrites every sqlc construct in src to native SQL for the given engine. +func File(engine config.Engine, src string) (*Result, error) { + d, ok := DialectFor(engine) + if !ok { + return nil, fmt.Errorf("preprocess: unknown engine: %s", engine) + } + return Dialected(d, src) +} + +// Dialected rewrites src using an explicit dialect. +func Dialected(d Dialect, src string) (*Result, error) { + l := &lexer{d: d, src: src} + res := &Result{} + + var b strings.Builder + b.Grow(len(src)) + + for _, span := range l.statements() { + start, end := span[0], span[1] + occs := l.scan(start, end) + stmt := &Statement{Start: b.Len()} + + // A statement that fails validation is copied through unchanged so the + // engine parses what the user actually wrote and the error keeps its + // original shape. + if err := firstError(occs); err != nil { + b.WriteString(src[start:end]) + stmt.End = b.Len() + err.Location = stmt.Start + (err.Location - start) + stmt.Err = err + stmt.Params = named.NewParamSet(nil, d.hasNamedSupport()) + stmt.Dollar = true + res.stmts = append(res.stmts, stmt) + continue + } + + stmt.Dollar, stmt.ParamErr = validatePlaceholders(occs) + stmt.Params = number(d, occs) + stmt.Numbers = map[int]int{} + + prev := start + for i := range occs { + occ := &occs[i] + b.WriteString(src[prev:occ.start]) + newStart := b.Len() + replacement := d.replacement(occ) + b.WriteString(replacement) + newEnd := b.Len() + prev = occ.end + + if replacement != src[occ.start:occ.end] { + res.edits = append(res.edits, edit{ + oldStart: occ.start, oldEnd: occ.end, + newStart: newStart, newEnd: newEnd, + }) + } + + if occ.kind != kindEmbed { + stmt.Numbers[newStart+sliceMarkerLen(d, occ)] = occ.number + } + + switch occ.kind { + case kindEmbed: + stmt.Embeds = append(stmt.Embeds, &Embed{ + Table: &ast.TableName{Name: occ.name}, + Location: newStart, + param: occ.ident, + }) + case kindSlice: + if stmt.Slices == nil { + stmt.Slices = map[int]string{} + } + // The placeholder follows the /*SLICE:name*/ marker, so record + // the offset the engine will report for the parameter node. + stmt.Slices[newStart+sliceMarkerLen(d, occ)] = occ.name + } + } + b.WriteString(src[prev:end]) + stmt.End = b.Len() + res.stmts = append(res.stmts, stmt) + } + + res.Text = b.String() + return res, nil +} + +func firstError(occs []occurrence) *sqlerr.Error { + for _, occ := range occs { + if occ.err != nil { + return occ.err + } + } + return nil +} + +// sliceMarkerLen returns the length of the /*SLICE:name*/ prefix a slice +// placeholder is written with, or 0 when there is none. +func sliceMarkerLen(d Dialect, occ *occurrence) int { + if occ.kind != kindSlice || d.Style == StyleDollar { + return 0 + } + return len("/*SLICE:") + len(occ.name) + len("*/") +} + +// validatePlaceholders checks the placeholders the user wrote, reproducing the +// rules the AST-based validator applied before sqlc syntax was rewritten: a +// query may not mix numbered and unnumbered placeholders, and the numbers it +// does use may not have gaps. +func validatePlaceholders(occs []occurrence) (dollar bool, err error) { + var numbered, unnumbered bool + seen := map[int]bool{} + for _, occ := range occs { + if occ.kind != kindNative { + continue + } + if occ.explicit { + numbered = true + seen[occ.number] = true + } else { + unnumbered = true + } + } + if numbered && unnumbered { + return false, errors.New("can not mix $1 format with ? format") + } + for i := 1; i <= len(seen); i++ { + if !seen[i] { + return !unnumbered, &sqlerr.Error{ + Code: "42P18", + Message: fmt.Sprintf("could not determine data type of parameter $%d", i), + } + } + } + return !unnumbered, nil +} + +// number assigns a placeholder number to every occurrence and builds the +// parameter set for the statement. +func number(d Dialect, occs []occurrence) *named.ParamSet { + if d.Style == StyleQuestion { + // Every "?" is sent as its own argument, so existing and rewritten + // placeholders are numbered together in source order. + ps := named.NewParamSet(nil, false) + for i := range occs { + occ := &occs[i] + if occ.kind == kindEmbed { + continue + } + occ.number = ps.Add(param(occ)) + } + return ps + } + + // Numbered dialects keep the numbers the user wrote and fill in the gaps. + numbs := map[int]bool{} + count := 0 + for i := range occs { + occ := &occs[i] + if occ.kind != kindNative { + continue + } + count++ + if !occ.explicit { + occ.number = count + } + numbs[occ.number] = true + } + ps := named.NewParamSet(numbs, d.hasNamedSupport()) + for i := range occs { + occ := &occs[i] + if occ.kind == kindEmbed || occ.kind == kindNative { + continue + } + occ.number = ps.Add(param(occ)) + } + return ps +} + +func param(occ *occurrence) named.Param { + switch occ.kind { + case kindNarg: + return named.NewUserNullableParam(occ.name) + case kindSlice: + return named.NewSqlcSlice(occ.name) + default: + return named.NewParam(occ.name) + } +} + +// replacement returns the native SQL that stands in for an occurrence. +func (d Dialect) replacement(occ *occurrence) string { + switch occ.kind { + case kindEmbed: + return occ.ident + ".*" + case kindNative: + return occ.ident + } + switch d.Style { + case StyleAtName: + // GoogleSQL names its parameters, so "@name" is already native and a + // user-written one is rewritten to itself. + if occ.kind == kindSlice { + return fmt.Sprintf("/*SLICE:%s*/@%s", occ.name, occ.name) + } + return "@" + occ.name + case StyleDollar: + // PostgreSQL passes a slice as a single array parameter, so it needs no + // expansion marker. + return fmt.Sprintf("$%d", occ.number) + case StyleOrdinal: + if occ.kind == kindSlice { + // This sequence is also replicated in + // internal/codegen/golang.Field since it's needed during template + // generation for replacement. + return fmt.Sprintf("/*SLICE:%s*/?", occ.name) + } + return fmt.Sprintf("?%d", occ.number) + default: + if occ.kind == kindSlice { + return fmt.Sprintf("/*SLICE:%s*/?", occ.name) + } + return "?" + } +} diff --git a/internal/sql/preprocess/preprocess_test.go b/internal/sql/preprocess/preprocess_test.go new file mode 100644 index 0000000000..46f49e967a --- /dev/null +++ b/internal/sql/preprocess/preprocess_test.go @@ -0,0 +1,264 @@ +package preprocess_test + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/source" + "github.com/sqlc-dev/sqlc/internal/sql/named" + "github.com/sqlc-dev/sqlc/internal/sql/preprocess" + "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" +) + +var update = flag.Bool("update", false, "update the testdata golden files") + +// TestRewrite runs every case under testdata. Each case is a directory holding +// an input.sql, the expected output.sql and, when the input is invalid, a +// stderr.txt with the reported error. Cases are grouped by engine: +// +// testdata///input.sql +// testdata///output.sql +// testdata///stderr.txt (optional) +func TestRewrite(t *testing.T) { + for _, dir := range cases(t) { + t.Run(filepath.ToSlash(dir), func(t *testing.T) { + path := filepath.Join("testdata", dir) + engine := config.Engine(filepath.Dir(dir)) + + input := readFile(t, filepath.Join(path, "input.sql")) + res, err := preprocess.File(engine, input) + if err != nil { + t.Fatalf("preprocess.File: %s", err) + } + + compare(t, filepath.Join(path, "output.sql"), res.Text) + compare(t, filepath.Join(path, "stderr.txt"), errors(input, res)) + }) + } +} + +// cases returns every testdata directory that holds an input.sql, relative to +// testdata and in a stable order. +func cases(t *testing.T) []string { + t.Helper() + var dirs []string + err := filepath.Walk("testdata", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || info.Name() != "input.sql" { + return nil + } + rel, err := filepath.Rel("testdata", filepath.Dir(path)) + if err != nil { + return err + } + dirs = append(dirs, rel) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(dirs) == 0 { + t.Fatal("no test cases found in testdata") + } + sort.Strings(dirs) + return dirs +} + +// errors renders every validation error the preprocessor recorded, one per +// line, as "line:column: message" against the original source. +func errors(input string, res *preprocess.Result) string { + var out []string + for offset := 0; offset < len(res.Text); { + stmt := res.Statement(offset) + if stmt.End <= offset { + break + } + offset = stmt.End + if stmt.Err == nil { + continue + } + loc := 0 + if e, ok := stmt.Err.(*sqlerr.Error); ok { + loc = res.Origin(e.Location) + } + line, column := source.LineNumber(input, loc) + out = append(out, fmt.Sprintf("%d:%d: %s", line, column, stmt.Err)) + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + +func readFile(t *testing.T, path string) string { + t.Helper() + blob, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(blob) +} + +// compare checks actual against a golden file. An empty expectation means the +// file should not exist. +func compare(t *testing.T, path, actual string) { + t.Helper() + if *update { + if actual == "" { + os.Remove(path) + return + } + if err := os.WriteFile(path, []byte(actual), 0644); err != nil { + t.Fatal(err) + } + return + } + var expected string + if blob, err := os.ReadFile(path); err == nil { + expected = string(blob) + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("%s differed (-want +got):\n%s", filepath.Base(path), diff) + } +} + +func mustRewrite(t *testing.T, engine config.Engine, src string) *preprocess.Result { + t.Helper() + res, err := preprocess.File(engine, src) + if err != nil { + t.Fatalf("preprocess.File(%s): %s", engine, err) + } + return res +} + +func TestParamSet(t *testing.T) { + res := mustRewrite(t, config.EnginePostgreSQL, + "SELECT * FROM users WHERE a = sqlc.arg(alpha) AND b = sqlc.narg(beta) AND c = ANY(sqlc.slice(gamma));") + stmt := res.Statement(0) + + for num, want := range map[int]string{1: "alpha", 2: "beta", 3: "gamma"} { + got, ok := stmt.Params.NameFor(num) + if !ok { + t.Fatalf("no name for parameter %d", num) + } + if got != want { + t.Errorf("parameter %d: got %q, want %q", num, got, want) + } + } + + // sqlc.narg() marks the parameter nullable even when inference says + // otherwise. + beta, _ := stmt.Params.FetchMerge(2, named.NewInferredParam("beta", true)) + if beta.NotNull() { + t.Error("sqlc.narg parameter should be nullable") + } + gamma, _ := stmt.Params.FetchMerge(3, named.NewParam("gamma")) + if !gamma.IsSqlcSlice() { + t.Error("sqlc.slice parameter should be marked as a slice") + } +} + +func TestEmbedSpans(t *testing.T) { + src := "SELECT sqlc.embed(a), b.* FROM a, b;" + res := mustRewrite(t, config.EnginePostgreSQL, src) + stmt := res.Statement(0) + if len(stmt.Embeds) != 1 { + t.Fatalf("expected 1 embed, got %d", len(stmt.Embeds)) + } + e := stmt.Embeds[0] + if e.Table.Name != "a" { + t.Errorf("embed table: got %q, want %q", e.Table.Name, "a") + } + if want := strings.Index(res.Text, "a.*"); e.Location != want { + t.Errorf("embed location: got %d, want %d", e.Location, want) + } + // A star reference the user wrote must not be mistaken for an embed. + if _, ok := stmt.Embeds.Find(strings.Index(res.Text, "b.*")); ok { + t.Error("user-written b.* was reported as an embed") + } + if got, want := e.Orig(), "sqlc.embed(a)"; got != want { + t.Errorf("Orig: got %q, want %q", got, want) + } +} + +func TestSliceSpans(t *testing.T) { + src := "SELECT * FROM t WHERE a IN (sqlc.slice(ids)) AND b = sqlc.arg(x);" + res := mustRewrite(t, config.EngineMySQL, src) + stmt := res.Statement(0) + if len(stmt.Slices) != 1 { + t.Fatalf("expected 1 slice, got %d", len(stmt.Slices)) + } + // The recorded offset points at the placeholder itself, past the marker, + // which is where the engine reports the parameter node. + want := strings.Index(res.Text, "*/?") + len("*/") + if got, ok := stmt.Slices[want]; !ok { + t.Errorf("slice offsets %v do not contain %d", stmt.Slices, want) + } else if got != "ids" { + t.Errorf("slice name: got %q, want %q", got, "ids") + } +} + +func TestOriginMapping(t *testing.T) { + src := "SELECT * FROM t WHERE a = sqlc.arg(alpha) AND b = sqlc.arg(beta);" + res := mustRewrite(t, config.EnginePostgreSQL, src) + + // Text before the first rewrite maps to itself. + if got := res.Origin(3); got != 3 { + t.Errorf("Origin(3): got %d, want 3", got) + } + // Text after a rewrite maps back across the length change. + newB, oldB := strings.Index(res.Text, "b ="), strings.Index(src, "b =") + if got := res.Origin(newB); got != oldB { + t.Errorf("Origin(%d): got %d, want %d", newB, got, oldB) + } + // An offset inside a placeholder maps to the start of what it replaced. + newParam := strings.Index(res.Text, "$2") + oldParam := strings.Index(src, "sqlc.arg(beta)") + if got := res.Origin(newParam); got != oldParam { + t.Errorf("Origin(%d): got %d, want %d", newParam, got, oldParam) + } +} + +func TestLinesArePreserved(t *testing.T) { + // Downstream error reporting counts lines in the rewritten text, so a + // rewrite must never add or remove one. + src := "-- name: Get :one\nSELECT *\nFROM users\nWHERE id = sqlc.arg(id)\n AND name = @name;\n" + res := mustRewrite(t, config.EnginePostgreSQL, src) + if want, got := strings.Count(src, "\n"), strings.Count(res.Text, "\n"); want != got { + t.Errorf("line count changed: got %d, want %d", got, want) + } +} + +func TestStatementLookup(t *testing.T) { + src := "SELECT sqlc.arg(a);\nSELECT sqlc.arg(b), sqlc.arg(c);\n" + res := mustRewrite(t, config.EnginePostgreSQL, src) + first := res.Statement(0) + second := res.Statement(strings.Index(res.Text, "$1, $2")) + if first == second { + t.Fatal("expected two distinct statements") + } + if name, _ := first.Params.NameFor(1); name != "a" { + t.Errorf("first statement parameter 1: got %q, want %q", name, "a") + } + if name, _ := second.Params.NameFor(2); name != "c" { + t.Errorf("second statement parameter 2: got %q, want %q", name, "c") + } +} + +func TestUnknownEngine(t *testing.T) { + if _, err := preprocess.File(config.Engine("nope"), "SELECT 1;"); err == nil { + t.Fatal("expected an error for an unknown engine") + } +} diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go new file mode 100644 index 0000000000..b2b4b1f534 --- /dev/null +++ b/internal/sql/preprocess/scan.go @@ -0,0 +1,209 @@ +package preprocess + +import ( + "fmt" + "strconv" + "strings" + + "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" +) + +// scan walks a single statement and returns every sqlc construct and native +// placeholder it contains, in source order. +func (l *lexer) scan(start, end int) []occurrence { + var out []occurrence + for i := start; i < end; { + if stop := l.skip(i); stop >= 0 { + if stop <= i { + stop = i + 1 + } + i = stop + continue + } + c := l.src[i] + switch { + case isIdentStart(c): + j := l.identEnd(i) + if strings.EqualFold(l.src[i:j], "sqlc") { + if occ, next, ok := l.sqlcCall(i, j); ok { + out = append(out, occ) + i = next + continue + } + } + i = j + + case c == '@' && l.d.AtSign && isIdentStart(l.at(i+1)): + j := l.identEnd(i + 1) + out = append(out, occurrence{ + kind: kindArg, + start: i, + end: j, + name: l.src[i+1 : j], + ident: l.src[i:j], + }) + i = j + + case c == '$' && l.d.DollarNumber && isDigit(l.at(i+1)): + j := i + 1 + for j < len(l.src) && isDigit(l.src[j]) { + j++ + } + n, _ := strconv.Atoi(l.src[i+1 : j]) + out = append(out, occurrence{ + kind: kindNative, + start: i, + end: j, + number: n, + explicit: true, + ident: l.src[i:j], + }) + i = j + + case c == '?' && l.d.Question: + j := i + 1 + for j < len(l.src) && isDigit(l.src[j]) { + j++ + } + occ := occurrence{kind: kindNative, start: i, end: j, ident: l.src[i:j]} + if j > i+1 { + occ.number, _ = strconv.Atoi(l.src[i+1 : j]) + occ.explicit = true + } + out = append(out, occ) + i = j + + default: + i++ + } + } + return out +} + +// sqlcCall parses a "sqlc.()" call that starts at start, where +// afterSqlc is the offset just past the "sqlc" identifier. It reports false +// when the text is not a sqlc call at all, in which case scanning continues +// normally. +func (l *lexer) sqlcCall(start, afterSqlc int) (occurrence, int, bool) { + i := l.skipSpace(afterSqlc) + if l.at(i) != '.' { + return occurrence{}, 0, false + } + i = l.skipSpace(i + 1) + if !isIdentStart(l.at(i)) { + return occurrence{}, 0, false + } + nameEnd := l.identEnd(i) + fn := strings.ToLower(l.src[i:nameEnd]) + + open := l.skipSpace(nameEnd) + if l.at(open) != '(' { + return occurrence{}, 0, false + } + closing := l.matchParen(open) + if closing < 0 { + return occurrence{}, 0, false + } + + occ := occurrence{start: start, end: closing + 1} + switch fn { + case "arg": + occ.kind = kindArg + case "narg": + occ.kind = kindNarg + case "slice": + occ.kind = kindSlice + case "embed": + occ.kind = kindEmbed + default: + occ.err = sqlerr.FunctionNotFound("sqlc." + fn) + occ.err.Location = start + return occ, occ.end, true + } + + args := l.splitArgs(open+1, closing) + if len(args) != 1 { + occ.err = &sqlerr.Error{ + Message: fmt.Sprintf("expected 1 parameter to sqlc.%s; got %d", fn, len(args)), + Location: start, + } + return occ, occ.end, true + } + + name, ident, ok := argument(l.d, args[0]) + if !ok { + occ.err = &sqlerr.Error{ + Message: fmt.Sprintf("expected parameter to sqlc.%s to be string or reference; got %q", fn, strings.TrimSpace(args[0])), + Location: start, + } + return occ, occ.end, true + } + occ.name = name + occ.ident = ident + return occ, occ.end, true +} + +// argument interprets the single argument to a sqlc function. It returns the +// parameter name and the text to reuse when rebuilding a reference, which keeps +// any identifier quoting the user wrote. +func argument(d Dialect, arg string) (name, ident string, ok bool) { + arg = strings.TrimSpace(arg) + if arg == "" { + return "", "", false + } + switch arg[0] { + case '\'': + // A string constant names the parameter but is not a reference, so the + // rebuilt identifier drops the quotes. + s, ok := unquote(arg, '\'') + return s, s, ok + case '"': + s, ok := unquote(arg, '"') + if !ok { + return "", "", false + } + if d.DoubleQuoteString { + return s, s, true + } + return s, arg, true + case '`': + if !d.Backtick { + return "", "", false + } + s, ok := unquote(arg, '`') + if !ok { + return "", "", false + } + return s, arg, true + } + if !isIdentStart(arg[0]) { + return "", "", false + } + for i := 0; i < len(arg); i++ { + if !isIdentPart(arg[i]) && arg[i] != '.' { + return "", "", false + } + } + // A qualified reference flattens to a single name, matching how the AST + // rewrite joined the parts of a ColumnRef. + name = strings.ReplaceAll(arg, ".", "") + if d.FoldIdentifier { + name = strings.ToLower(name) + } + return name, arg, true +} + +func unquote(s string, quote byte) (string, bool) { + if len(s) < 2 || s[0] != quote || s[len(s)-1] != quote { + return "", false + } + inner := s[1 : len(s)-1] + if strings.IndexByte(inner, quote) >= 0 { + doubled := string([]byte{quote, quote}) + if strings.Contains(inner, doubled) { + return strings.ReplaceAll(inner, doubled, string(quote)), true + } + return "", false + } + return inner, true +} diff --git a/internal/sql/preprocess/testdata/clickhouse/arg/input.sql b/internal/sql/preprocess/testdata/clickhouse/arg/input.sql new file mode 100644 index 0000000000..c689beee0c --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/arg/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) AND b = sqlc.narg(y); diff --git a/internal/sql/preprocess/testdata/clickhouse/arg/output.sql b/internal/sql/preprocess/testdata/clickhouse/arg/output.sql new file mode 100644 index 0000000000..0091b6db39 --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/arg/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ? AND b = ?; diff --git a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql new file mode 100644 index 0000000000..3f63649481 --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql new file mode 100644 index 0000000000..e852e1de4a --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?; diff --git a/internal/sql/preprocess/testdata/clickhouse/embed/input.sql b/internal/sql/preprocess/testdata/clickhouse/embed/input.sql new file mode 100644 index 0000000000..bb5188a008 --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/embed/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(users) FROM users; diff --git a/internal/sql/preprocess/testdata/clickhouse/embed/output.sql b/internal/sql/preprocess/testdata/clickhouse/embed/output.sql new file mode 100644 index 0000000000..e9d0968e18 --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/embed/output.sql @@ -0,0 +1 @@ +SELECT users.* FROM users; diff --git a/internal/sql/preprocess/testdata/clickhouse/slice/input.sql b/internal/sql/preprocess/testdata/clickhouse/slice/input.sql new file mode 100644 index 0000000000..f5d89640d7 --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/slice/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/clickhouse/slice/output.sql b/internal/sql/preprocess/testdata/clickhouse/slice/output.sql new file mode 100644 index 0000000000..542215158c --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/slice/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (/*SLICE:ids*/?); diff --git a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql new file mode 100644 index 0000000000..62196060b8 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) AND b = @y; diff --git a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql new file mode 100644 index 0000000000..9dd09b1637 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = @x AND b = @y; diff --git a/internal/sql/preprocess/testdata/googlesql/embed/input.sql b/internal/sql/preprocess/testdata/googlesql/embed/input.sql new file mode 100644 index 0000000000..bb5188a008 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/embed/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(users) FROM users; diff --git a/internal/sql/preprocess/testdata/googlesql/embed/output.sql b/internal/sql/preprocess/testdata/googlesql/embed/output.sql new file mode 100644 index 0000000000..e9d0968e18 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/embed/output.sql @@ -0,0 +1 @@ +SELECT users.* FROM users; diff --git a/internal/sql/preprocess/testdata/googlesql/slice/input.sql b/internal/sql/preprocess/testdata/googlesql/slice/input.sql new file mode 100644 index 0000000000..f5d89640d7 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/slice/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/googlesql/slice/output.sql b/internal/sql/preprocess/testdata/googlesql/slice/output.sql new file mode 100644 index 0000000000..06d0233ae4 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/slice/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (/*SLICE:ids*/@ids); diff --git a/internal/sql/preprocess/testdata/mysql/arg/input.sql b/internal/sql/preprocess/testdata/mysql/arg/input.sql new file mode 100644 index 0000000000..180ea26b3b --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/arg/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) AND b = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/mysql/arg/output.sql b/internal/sql/preprocess/testdata/mysql/arg/output.sql new file mode 100644 index 0000000000..0091b6db39 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/arg/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ? AND b = ?; diff --git a/internal/sql/preprocess/testdata/mysql/embed/input.sql b/internal/sql/preprocess/testdata/mysql/embed/input.sql new file mode 100644 index 0000000000..bb5188a008 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/embed/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(users) FROM users; diff --git a/internal/sql/preprocess/testdata/mysql/embed/output.sql b/internal/sql/preprocess/testdata/mysql/embed/output.sql new file mode 100644 index 0000000000..e9d0968e18 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/embed/output.sql @@ -0,0 +1 @@ +SELECT users.* FROM users; diff --git a/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/input.sql b/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/input.sql new file mode 100644 index 0000000000..9b9c75668e --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ? AND b = sqlc.arg(x) AND c = ?; diff --git a/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/output.sql b/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/output.sql new file mode 100644 index 0000000000..8f52c1ae1b --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ? AND b = ? AND c = ?; diff --git a/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/input.sql b/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/input.sql new file mode 100644 index 0000000000..4da5824466 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/input.sql @@ -0,0 +1,2 @@ +INSERT INTO t (a) VALUES (sqlc.arg(val)) +ON DUPLICATE KEY UPDATE a = sqlc.arg(new_val); diff --git a/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/output.sql b/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/output.sql new file mode 100644 index 0000000000..9681976c94 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/output.sql @@ -0,0 +1,2 @@ +INSERT INTO t (a) VALUES (?) +ON DUPLICATE KEY UPDATE a = ?; diff --git a/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/input.sql b/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/input.sql new file mode 100644 index 0000000000..ae9b08ce68 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/input.sql @@ -0,0 +1 @@ +SELECT 'a\' sqlc.arg(nope)'; diff --git a/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/output.sql b/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/output.sql new file mode 100644 index 0000000000..ae9b08ce68 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/output.sql @@ -0,0 +1 @@ +SELECT 'a\' sqlc.arg(nope)'; diff --git a/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/input.sql b/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/input.sql new file mode 100644 index 0000000000..124b3d3a96 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/input.sql @@ -0,0 +1 @@ +SELECT `sqlc.arg(nope)` FROM t; diff --git a/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/output.sql b/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/output.sql new file mode 100644 index 0000000000..124b3d3a96 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/output.sql @@ -0,0 +1 @@ +SELECT `sqlc.arg(nope)` FROM t; diff --git a/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/input.sql b/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/input.sql new file mode 100644 index 0000000000..3fb25577ba --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/input.sql @@ -0,0 +1 @@ +SELECT "sqlc.arg(nope)" FROM t; diff --git a/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/output.sql b/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/output.sql new file mode 100644 index 0000000000..3fb25577ba --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/output.sql @@ -0,0 +1 @@ +SELECT "sqlc.arg(nope)" FROM t; diff --git a/internal/sql/preprocess/testdata/mysql/skip_hash_comment/input.sql b/internal/sql/preprocess/testdata/mysql/skip_hash_comment/input.sql new file mode 100644 index 0000000000..755efa7a13 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_hash_comment/input.sql @@ -0,0 +1,2 @@ +# sqlc.arg(nope) +SELECT 1; diff --git a/internal/sql/preprocess/testdata/mysql/skip_hash_comment/output.sql b/internal/sql/preprocess/testdata/mysql/skip_hash_comment/output.sql new file mode 100644 index 0000000000..755efa7a13 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_hash_comment/output.sql @@ -0,0 +1,2 @@ +# sqlc.arg(nope) +SELECT 1; diff --git a/internal/sql/preprocess/testdata/mysql/slice/input.sql b/internal/sql/preprocess/testdata/mysql/slice/input.sql new file mode 100644 index 0000000000..f5d89640d7 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/slice/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/mysql/slice/output.sql b/internal/sql/preprocess/testdata/mysql/slice/output.sql new file mode 100644 index 0000000000..542215158c --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/slice/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (/*SLICE:ids*/?); diff --git a/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/input.sql b/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/input.sql new file mode 100644 index 0000000000..ce518fcf22 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/input.sql @@ -0,0 +1 @@ +SELECT @row := @row + 1 FROM users WHERE id = sqlc.arg(id); diff --git a/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/output.sql b/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/output.sql new file mode 100644 index 0000000000..4db8a1b06c --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/output.sql @@ -0,0 +1 @@ +SELECT @row := @row + 1 FROM users WHERE id = ?; diff --git a/internal/sql/preprocess/testdata/postgresql/arg/input.sql b/internal/sql/preprocess/testdata/postgresql/arg/input.sql new file mode 100644 index 0000000000..4f78775148 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/arg/input.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id FROM users WHERE name = sqlc.arg(name); diff --git a/internal/sql/preprocess/testdata/postgresql/arg/output.sql b/internal/sql/preprocess/testdata/postgresql/arg/output.sql new file mode 100644 index 0000000000..e6d2e2ea8a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/arg/output.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id FROM users WHERE name = $1; diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign/input.sql b/internal/sql/preprocess/testdata/postgresql/at_sign/input.sql new file mode 100644 index 0000000000..57c7883786 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE name = @name AND age = @age; diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign/output.sql b/internal/sql/preprocess/testdata/postgresql/at_sign/output.sql new file mode 100644 index 0000000000..90875ab313 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE name = $1 AND age = $2; diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign_cast/input.sql b/internal/sql/preprocess/testdata/postgresql/at_sign_cast/input.sql new file mode 100644 index 0000000000..a167ed5961 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign_cast/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE ok = @flag::bool; diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign_cast/output.sql b/internal/sql/preprocess/testdata/postgresql/at_sign_cast/output.sql new file mode 100644 index 0000000000..28cdd95fca --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign_cast/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE ok = $1::bool; diff --git a/internal/sql/preprocess/testdata/postgresql/comment_inside_call/input.sql b/internal/sql/preprocess/testdata/postgresql/comment_inside_call/input.sql new file mode 100644 index 0000000000..58bb27fc93 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/comment_inside_call/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg/* here */(name); diff --git a/internal/sql/preprocess/testdata/postgresql/comment_inside_call/output.sql b/internal/sql/preprocess/testdata/postgresql/comment_inside_call/output.sql new file mode 100644 index 0000000000..37699f7c74 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/comment_inside_call/output.sql @@ -0,0 +1 @@ +SELECT $1; diff --git a/internal/sql/preprocess/testdata/postgresql/embed/input.sql b/internal/sql/preprocess/testdata/postgresql/embed/input.sql new file mode 100644 index 0000000000..551607433a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(users), sqlc.embed(pets) FROM users JOIN pets ON true; diff --git a/internal/sql/preprocess/testdata/postgresql/embed/output.sql b/internal/sql/preprocess/testdata/postgresql/embed/output.sql new file mode 100644 index 0000000000..59d0cab37b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed/output.sql @@ -0,0 +1 @@ +SELECT users.*, pets.* FROM users JOIN pets ON true; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/input.sql b/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/input.sql new file mode 100644 index 0000000000..86af411c74 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed("Users") FROM "Users"; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/output.sql b/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/output.sql new file mode 100644 index 0000000000..390936296b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/output.sql @@ -0,0 +1 @@ +SELECT "Users".* FROM "Users"; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_with_args/input.sql b/internal/sql/preprocess/testdata/postgresql/embed_with_args/input.sql new file mode 100644 index 0000000000..68b9efb581 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_with_args/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(a), b.name FROM a JOIN b ON b.id = a.b_id WHERE a.id = sqlc.arg(id); diff --git a/internal/sql/preprocess/testdata/postgresql/embed_with_args/output.sql b/internal/sql/preprocess/testdata/postgresql/embed_with_args/output.sql new file mode 100644 index 0000000000..aaa0860aa2 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_with_args/output.sql @@ -0,0 +1 @@ +SELECT a.*, b.name FROM a JOIN b ON b.id = a.b_id WHERE a.id = $1; diff --git a/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/input.sql b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/input.sql new file mode 100644 index 0000000000..f1f94d0d0a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/input.sql @@ -0,0 +1,2 @@ +SELECT sqlc.argh(a); +SELECT sqlc.arg(b); diff --git a/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/output.sql b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/output.sql new file mode 100644 index 0000000000..43f926284c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/output.sql @@ -0,0 +1,2 @@ +SELECT sqlc.argh(a); +SELECT $1; diff --git a/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/stderr.txt new file mode 100644 index 0000000000..c55d98608c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/stderr.txt @@ -0,0 +1 @@ +1:8: function "sqlc.argh" does not exist diff --git a/internal/sql/preprocess/testdata/postgresql/error_nested_call/input.sql b/internal/sql/preprocess/testdata/postgresql/error_nested_call/input.sql new file mode 100644 index 0000000000..e8d7485252 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_nested_call/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg(sqlc.arg(a)); diff --git a/internal/sql/preprocess/testdata/postgresql/error_nested_call/output.sql b/internal/sql/preprocess/testdata/postgresql/error_nested_call/output.sql new file mode 100644 index 0000000000..e8d7485252 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_nested_call/output.sql @@ -0,0 +1 @@ +SELECT sqlc.arg(sqlc.arg(a)); diff --git a/internal/sql/preprocess/testdata/postgresql/error_nested_call/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_nested_call/stderr.txt new file mode 100644 index 0000000000..7faabf74a6 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_nested_call/stderr.txt @@ -0,0 +1 @@ +1:8: expected parameter to sqlc.arg to be string or reference; got "sqlc.arg(a)" diff --git a/internal/sql/preprocess/testdata/postgresql/error_no_arguments/input.sql b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/input.sql new file mode 100644 index 0000000000..540bfb17b1 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg(); diff --git a/internal/sql/preprocess/testdata/postgresql/error_no_arguments/output.sql b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/output.sql new file mode 100644 index 0000000000..540bfb17b1 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/output.sql @@ -0,0 +1 @@ +SELECT sqlc.arg(); diff --git a/internal/sql/preprocess/testdata/postgresql/error_no_arguments/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/stderr.txt new file mode 100644 index 0000000000..46c7fcd096 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/stderr.txt @@ -0,0 +1 @@ +1:8: expected 1 parameter to sqlc.arg; got 0 diff --git a/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/input.sql b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/input.sql new file mode 100644 index 0000000000..3589b18fb6 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg($1); diff --git a/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/output.sql b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/output.sql new file mode 100644 index 0000000000..3589b18fb6 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/output.sql @@ -0,0 +1 @@ +SELECT sqlc.arg($1); diff --git a/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/stderr.txt new file mode 100644 index 0000000000..16d54e925a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/stderr.txt @@ -0,0 +1 @@ +1:8: expected parameter to sqlc.arg to be string or reference; got "$1" diff --git a/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/input.sql b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/input.sql new file mode 100644 index 0000000000..d5e1c628b6 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg('a', 'b'); diff --git a/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/output.sql b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/output.sql new file mode 100644 index 0000000000..d5e1c628b6 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/output.sql @@ -0,0 +1 @@ +SELECT sqlc.arg('a', 'b'); diff --git a/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/stderr.txt new file mode 100644 index 0000000000..92e71bd342 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/stderr.txt @@ -0,0 +1 @@ +1:8: expected 1 parameter to sqlc.arg; got 2 diff --git a/internal/sql/preprocess/testdata/postgresql/error_unknown_function/input.sql b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/input.sql new file mode 100644 index 0000000000..5a344b175a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/input.sql @@ -0,0 +1 @@ +SELECT sqlc.argh(a); diff --git a/internal/sql/preprocess/testdata/postgresql/error_unknown_function/output.sql b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/output.sql new file mode 100644 index 0000000000..5a344b175a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/output.sql @@ -0,0 +1 @@ +SELECT sqlc.argh(a); diff --git a/internal/sql/preprocess/testdata/postgresql/error_unknown_function/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/stderr.txt new file mode 100644 index 0000000000..c55d98608c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/stderr.txt @@ -0,0 +1 @@ +1:8: function "sqlc.argh" does not exist diff --git a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql new file mode 100644 index 0000000000..6da8d33458 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = $2 AND b = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql new file mode 100644 index 0000000000..80bbd33f1b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = $2 AND b = $1; diff --git a/internal/sql/preprocess/testdata/postgresql/extra_whitespace/input.sql b/internal/sql/preprocess/testdata/postgresql/extra_whitespace/input.sql new file mode 100644 index 0000000000..1ddb921525 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/extra_whitespace/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg ( name ), sqlc . arg(other); diff --git a/internal/sql/preprocess/testdata/postgresql/extra_whitespace/output.sql b/internal/sql/preprocess/testdata/postgresql/extra_whitespace/output.sql new file mode 100644 index 0000000000..ca9e5428bf --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/extra_whitespace/output.sql @@ -0,0 +1 @@ +SELECT $1, $2; diff --git a/internal/sql/preprocess/testdata/postgresql/multiline_call/input.sql b/internal/sql/preprocess/testdata/postgresql/multiline_call/input.sql new file mode 100644 index 0000000000..d377f42ba0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/multiline_call/input.sql @@ -0,0 +1,3 @@ +SELECT sqlc.arg( + name +); diff --git a/internal/sql/preprocess/testdata/postgresql/multiline_call/output.sql b/internal/sql/preprocess/testdata/postgresql/multiline_call/output.sql new file mode 100644 index 0000000000..37699f7c74 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/multiline_call/output.sql @@ -0,0 +1 @@ +SELECT $1; diff --git a/internal/sql/preprocess/testdata/postgresql/narg/input.sql b/internal/sql/preprocess/testdata/postgresql/narg/input.sql new file mode 100644 index 0000000000..7e5109ad6e --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/narg/input.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id FROM users WHERE name = sqlc.narg(name); diff --git a/internal/sql/preprocess/testdata/postgresql/narg/output.sql b/internal/sql/preprocess/testdata/postgresql/narg/output.sql new file mode 100644 index 0000000000..e6d2e2ea8a --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/narg/output.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id FROM users WHERE name = $1; diff --git a/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/input.sql b/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/input.sql new file mode 100644 index 0000000000..80e729d3de --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/input.sql @@ -0,0 +1,2 @@ +SELECT sqlc.arg(a); +SELECT sqlc.arg(b); diff --git a/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/output.sql b/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/output.sql new file mode 100644 index 0000000000..aa0274e34d --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/output.sql @@ -0,0 +1,2 @@ +SELECT $1; +SELECT $1; diff --git a/internal/sql/preprocess/testdata/postgresql/quoted_names/input.sql b/internal/sql/preprocess/testdata/postgresql/quoted_names/input.sql new file mode 100644 index 0000000000..155e2f5963 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/quoted_names/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg('a'), sqlc.arg("b"); diff --git a/internal/sql/preprocess/testdata/postgresql/quoted_names/output.sql b/internal/sql/preprocess/testdata/postgresql/quoted_names/output.sql new file mode 100644 index 0000000000..ca9e5428bf --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/quoted_names/output.sql @@ -0,0 +1 @@ +SELECT $1, $2; diff --git a/internal/sql/preprocess/testdata/postgresql/repeated_name/input.sql b/internal/sql/preprocess/testdata/postgresql/repeated_name/input.sql new file mode 100644 index 0000000000..1f6121aa81 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/repeated_name/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) OR b = sqlc.arg(x) OR c = sqlc.arg(y); diff --git a/internal/sql/preprocess/testdata/postgresql/repeated_name/output.sql b/internal/sql/preprocess/testdata/postgresql/repeated_name/output.sql new file mode 100644 index 0000000000..b138d6c0cc --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/repeated_name/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = $1 OR b = $1 OR c = $2; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_block_comment/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_block_comment/input.sql new file mode 100644 index 0000000000..eb0245e5c0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_block_comment/input.sql @@ -0,0 +1,3 @@ +/* sqlc.arg(nope) + @nope */ +SELECT 1; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_block_comment/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_block_comment/output.sql new file mode 100644 index 0000000000..eb0245e5c0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_block_comment/output.sql @@ -0,0 +1,3 @@ +/* sqlc.arg(nope) + @nope */ +SELECT 1; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/input.sql new file mode 100644 index 0000000000..448b6aef67 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/input.sql @@ -0,0 +1 @@ +CREATE FUNCTION f() RETURNS int AS $$ SELECT sqlc.arg(nope); $$ LANGUAGE sql; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/output.sql new file mode 100644 index 0000000000..448b6aef67 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/output.sql @@ -0,0 +1 @@ +CREATE FUNCTION f() RETURNS int AS $$ SELECT sqlc.arg(nope); $$ LANGUAGE sql; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_escape_string/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_escape_string/input.sql new file mode 100644 index 0000000000..800fd62008 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_escape_string/input.sql @@ -0,0 +1 @@ +SELECT E'\' sqlc.arg(nope)'; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_escape_string/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_escape_string/output.sql new file mode 100644 index 0000000000..800fd62008 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_escape_string/output.sql @@ -0,0 +1 @@ +SELECT E'\' sqlc.arg(nope)'; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/input.sql new file mode 100644 index 0000000000..a41da4767c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/input.sql @@ -0,0 +1 @@ +SELECT 1 FROM t WHERE a @> b AND j ? 'a'; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/output.sql new file mode 100644 index 0000000000..a41da4767c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/output.sql @@ -0,0 +1 @@ +SELECT 1 FROM t WHERE a @> b AND j ? 'a'; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_line_comment/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_line_comment/input.sql new file mode 100644 index 0000000000..b718e04c02 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_line_comment/input.sql @@ -0,0 +1,2 @@ +-- name: Get :one -- sqlc.arg(nope) @nope +SELECT 1; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_line_comment/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_line_comment/output.sql new file mode 100644 index 0000000000..b718e04c02 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_line_comment/output.sql @@ -0,0 +1,2 @@ +-- name: Get :one -- sqlc.arg(nope) @nope +SELECT 1; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/input.sql new file mode 100644 index 0000000000..46ac007ae0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/input.sql @@ -0,0 +1 @@ +/* /* sqlc.arg(nope) */ @nope */ SELECT 1; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/output.sql new file mode 100644 index 0000000000..46ac007ae0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/output.sql @@ -0,0 +1 @@ +/* /* sqlc.arg(nope) */ @nope */ SELECT 1; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/input.sql new file mode 100644 index 0000000000..07cf2dc8da --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/input.sql @@ -0,0 +1 @@ +SELECT sqlcx.arg(a), other.embed(b); diff --git a/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/output.sql new file mode 100644 index 0000000000..07cf2dc8da --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/output.sql @@ -0,0 +1 @@ +SELECT sqlcx.arg(a), other.embed(b); diff --git a/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/input.sql new file mode 100644 index 0000000000..3fb25577ba --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/input.sql @@ -0,0 +1 @@ +SELECT "sqlc.arg(nope)" FROM t; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/output.sql new file mode 100644 index 0000000000..3fb25577ba --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/output.sql @@ -0,0 +1 @@ +SELECT "sqlc.arg(nope)" FROM t; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_string_literal/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_string_literal/input.sql new file mode 100644 index 0000000000..4e5ef9f87f --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_string_literal/input.sql @@ -0,0 +1 @@ +SELECT 'sqlc.arg(nope) @nope'; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_string_literal/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_string_literal/output.sql new file mode 100644 index 0000000000..4e5ef9f87f --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_string_literal/output.sql @@ -0,0 +1 @@ +SELECT 'sqlc.arg(nope) @nope'; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/input.sql b/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/input.sql new file mode 100644 index 0000000000..8b4be6fdd8 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/input.sql @@ -0,0 +1 @@ +SELECT $tag$ sqlc.arg(nope) @nope $tag$; diff --git a/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/output.sql b/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/output.sql new file mode 100644 index 0000000000..8b4be6fdd8 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/output.sql @@ -0,0 +1 @@ +SELECT $tag$ sqlc.arg(nope) @nope $tag$; diff --git a/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/input.sql b/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/input.sql new file mode 100644 index 0000000000..0c69e21e7f --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id = ANY(sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/output.sql b/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/output.sql new file mode 100644 index 0000000000..06bb99772d --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id = ANY($1); diff --git a/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/input.sql b/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/input.sql new file mode 100644 index 0000000000..1d254ec1a0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/input.sql @@ -0,0 +1,2 @@ +SELECT ';', sqlc.arg(a); +SELECT sqlc.arg(b); diff --git a/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/output.sql b/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/output.sql new file mode 100644 index 0000000000..f5baebfc00 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/output.sql @@ -0,0 +1,2 @@ +SELECT ';', $1; +SELECT $1; diff --git a/internal/sql/preprocess/testdata/postgresql/uppercase/input.sql b/internal/sql/preprocess/testdata/postgresql/uppercase/input.sql new file mode 100644 index 0000000000..4931f5942b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/uppercase/input.sql @@ -0,0 +1 @@ +SELECT SQLC.ARG(name); diff --git a/internal/sql/preprocess/testdata/postgresql/uppercase/output.sql b/internal/sql/preprocess/testdata/postgresql/uppercase/output.sql new file mode 100644 index 0000000000..37699f7c74 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/uppercase/output.sql @@ -0,0 +1 @@ +SELECT $1; diff --git a/internal/sql/preprocess/testdata/sqlite/arg/input.sql b/internal/sql/preprocess/testdata/sqlite/arg/input.sql new file mode 100644 index 0000000000..238d2d1c85 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/arg/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) AND b = sqlc.arg(y); diff --git a/internal/sql/preprocess/testdata/sqlite/arg/output.sql b/internal/sql/preprocess/testdata/sqlite/arg/output.sql new file mode 100644 index 0000000000..8d719f8cc6 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/arg/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?1 AND b = ?2; diff --git a/internal/sql/preprocess/testdata/sqlite/at_sign/input.sql b/internal/sql/preprocess/testdata/sqlite/at_sign/input.sql new file mode 100644 index 0000000000..00688da206 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/at_sign/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE name = @name; diff --git a/internal/sql/preprocess/testdata/sqlite/at_sign/output.sql b/internal/sql/preprocess/testdata/sqlite/at_sign/output.sql new file mode 100644 index 0000000000..e232a5b5bc --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/at_sign/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE name = ?1; diff --git a/internal/sql/preprocess/testdata/sqlite/embed/input.sql b/internal/sql/preprocess/testdata/sqlite/embed/input.sql new file mode 100644 index 0000000000..2c00b3bee4 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/embed/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(users) FROM users WHERE id = sqlc.arg(id); diff --git a/internal/sql/preprocess/testdata/sqlite/embed/output.sql b/internal/sql/preprocess/testdata/sqlite/embed/output.sql new file mode 100644 index 0000000000..022b9929db --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/embed/output.sql @@ -0,0 +1 @@ +SELECT users.* FROM users WHERE id = ?1; diff --git a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql new file mode 100644 index 0000000000..7b71e48c46 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?2 AND b = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql new file mode 100644 index 0000000000..178138c99d --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?2 AND b = ?1; diff --git a/internal/sql/preprocess/testdata/sqlite/repeated_name/input.sql b/internal/sql/preprocess/testdata/sqlite/repeated_name/input.sql new file mode 100644 index 0000000000..180ea26b3b --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/repeated_name/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg(x) AND b = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/sqlite/repeated_name/output.sql b/internal/sql/preprocess/testdata/sqlite/repeated_name/output.sql new file mode 100644 index 0000000000..143e91e71b --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/repeated_name/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?1 AND b = ?1; diff --git a/internal/sql/preprocess/testdata/sqlite/slice/input.sql b/internal/sql/preprocess/testdata/sqlite/slice/input.sql new file mode 100644 index 0000000000..f5d89640d7 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/slice/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/sqlite/slice/output.sql b/internal/sql/preprocess/testdata/sqlite/slice/output.sql new file mode 100644 index 0000000000..542215158c --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/slice/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE id IN (/*SLICE:ids*/?); diff --git a/internal/sql/rewrite/CLAUDE.md b/internal/sql/rewrite/CLAUDE.md deleted file mode 100644 index 6ea885016e..0000000000 --- a/internal/sql/rewrite/CLAUDE.md +++ /dev/null @@ -1,104 +0,0 @@ -# SQL Rewrite Package - Claude Code Guide - -This package handles AST transformations, primarily for parameter handling. - -## Key Functions - -### NamedParameters -`NamedParameters(engine config.Engine, raw *ast.RawStmt, ...) (*ast.RawStmt, map[int]Parameter, error)` - -Finds and replaces named parameters (`sqlc.arg()`, `@param`) with positional parameters. - -The function: -1. Searches for named parameters using `named.IsParamFunc()` and `named.IsParamSign()` -2. Extracts parameter names and types -3. Replaces them with `ast.ParamRef` nodes -4. Returns a map of parameter positions to their metadata - -### Expand -`Expand(raw *ast.RawStmt, expected int) error` - -Expands `sqlc.slice()` parameters into the correct number of positional parameters. - -## How Parameter Rewriting Works - -### Step 1: Find Named Parameters -```go -refs := astutils.Search(raw.Stmt, func(node ast.Node) bool { - return named.IsParamFunc(node) || named.IsParamSign(node) -}) -``` - -### Step 2: Replace with ParamRef -```go -astutils.Apply(raw.Stmt, func(cr *astutils.Cursor) bool { - if named.IsParamFunc(cr.Node()) { - // Extract name from sqlc.arg(name) - call := cr.Node().(*ast.FuncCall) - name := extractName(call.Args) - - cr.Replace(&ast.ParamRef{ - Number: nextParam(), - Location: call.Location, - }) - } - return true -}, nil) -``` - -## Important: AST Node Requirements - -For parameter rewriting to work correctly, the AST must be walkable. This means: - -1. All node types must have cases in `astutils/walk.go` -2. All node types must have cases in `astutils/rewrite.go` -3. New container types (like `OnDuplicateKeyUpdate`) must be traversed - -### Example: OnDuplicateKeyUpdate - -MySQL's `ON DUPLICATE KEY UPDATE` clause can contain `sqlc.arg()`: -```sql -INSERT INTO t (a) VALUES (sqlc.arg(val)) -ON DUPLICATE KEY UPDATE a = sqlc.arg(new_val) -``` - -For the parameter in `ON DUPLICATE KEY UPDATE` to be found and replaced: - -1. `InsertStmt` in `rewrite.go` must traverse `OnDuplicateKeyUpdate`: -```go -case *ast.InsertStmt: - a.apply(n, "Relation", nil, n.Relation) - a.apply(n, "Cols", nil, n.Cols) - a.apply(n, "SelectStmt", nil, n.SelectStmt) - a.apply(n, "OnConflictClause", nil, n.OnConflictClause) - a.apply(n, "OnDuplicateKeyUpdate", nil, n.OnDuplicateKeyUpdate) // Critical! - a.apply(n, "ReturningList", nil, n.ReturningList) - a.apply(n, "WithClause", nil, n.WithClause) -``` - -2. `OnDuplicateKeyUpdate` must have its own case: -```go -case *ast.OnDuplicateKeyUpdate: - a.apply(n, "List", nil, n.List) -``` - -## Debugging Parameter Issues - -If a `sqlc.arg()` isn't being converted to `?`: - -1. Check that the containing node type has a case in `rewrite.go` -2. Check that the case traverses all child fields -3. Add debug logging to see if the node is being visited: -```go -case *ast.YourType: - fmt.Printf("Visiting YourType with fields: %+v\n", n) - a.apply(n, "ChildField", nil, n.ChildField) -``` - -## Parameter Output Format by Engine - -- PostgreSQL: `$1`, `$2`, `$3`, ... -- MySQL: `?`, `?`, `?`, ... -- SQLite: `?`, `?`, `?`, ... - -The format is determined by the `Dialect.Param()` method in each engine. diff --git a/internal/sql/rewrite/embeds.go b/internal/sql/rewrite/embeds.go deleted file mode 100644 index 596c03be89..0000000000 --- a/internal/sql/rewrite/embeds.go +++ /dev/null @@ -1,91 +0,0 @@ -package rewrite - -import ( - "fmt" - - "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/astutils" -) - -// Embed is an instance of `sqlc.embed(param)` -type Embed struct { - Table *ast.TableName - param string - Node *ast.ColumnRef -} - -// Orig string to replace -func (e Embed) Orig() string { - return fmt.Sprintf("sqlc.embed(%s)", e.param) -} - -// EmbedSet is a set of Embed instances -type EmbedSet []*Embed - -// Find a matching embed by column ref -func (es EmbedSet) Find(node *ast.ColumnRef) (*Embed, bool) { - for _, e := range es { - if e.Node == node { - return e, true - } - } - return nil, false -} - -// Embeds rewrites `sqlc.embed(param)` to a `ast.ColumnRef` of form `param.*`. -// The compiler can make use of the returned `EmbedSet` while expanding the -// `param.*` column refs to produce the correct source edits. -func Embeds(raw *ast.RawStmt) (*ast.RawStmt, EmbedSet) { - var embeds []*Embed - - node := astutils.Apply(raw, func(cr *astutils.Cursor) bool { - node := cr.Node() - - switch { - case isEmbed(node): - fun := node.(*ast.FuncCall) - - if len(fun.Args.Items) == 0 { - return false - } - - param, _ := flatten(fun.Args) - - node := &ast.ColumnRef{ - Fields: &ast.List{ - Items: []ast.Node{ - &ast.String{Str: param}, - &ast.A_Star{}, - }, - }, - } - - embeds = append(embeds, &Embed{ - Table: &ast.TableName{Name: param}, - param: param, - Node: node, - }) - - cr.Replace(node) - return false - default: - return true - } - }, nil) - - return node.(*ast.RawStmt), embeds -} - -func isEmbed(node ast.Node) bool { - call, ok := node.(*ast.FuncCall) - if !ok { - return false - } - - if call.Func == nil { - return false - } - - isValid := call.Func.Schema == "sqlc" && call.Func.Name == "embed" - return isValid -} diff --git a/internal/sql/rewrite/parameters.go b/internal/sql/rewrite/parameters.go deleted file mode 100644 index 77ad5d2573..0000000000 --- a/internal/sql/rewrite/parameters.go +++ /dev/null @@ -1,205 +0,0 @@ -package rewrite - -import ( - "fmt" - "strings" - - "github.com/sqlc-dev/sqlc/internal/config" - "github.com/sqlc-dev/sqlc/internal/source" - "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/astutils" - "github.com/sqlc-dev/sqlc/internal/sql/named" -) - -// Given an AST node, return the string representation of names -func flatten(root ast.Node) (string, bool) { - sw := &stringWalker{} - astutils.Walk(sw, root) - return sw.String, sw.IsConst -} - -type stringWalker struct { - String string - IsConst bool -} - -func (s *stringWalker) Visit(node ast.Node) astutils.Visitor { - if _, ok := node.(*ast.A_Const); ok { - s.IsConst = true - } - if n, ok := node.(*ast.String); ok { - s.String += n.Str - } - return s -} - -func isNamedParamSignCast(node ast.Node) bool { - expr, ok := node.(*ast.A_Expr) - if !ok { - return false - } - _, cast := expr.Rexpr.(*ast.TypeCast) - return astutils.Join(expr.Name, ".") == "@" && cast -} - -// paramFromFuncCall creates a param from sqlc.n?arg() calls return the -// parameter and whether the parameter name was specified a best guess as its -// "source" string representation (used for replacing this function call in the -// original SQL query) -func paramFromFuncCall(call *ast.FuncCall) (named.Param, string) { - paramName, isConst := flatten(call.Args) - - // origName keeps track of how the parameter was specified in the source SQL - origName := paramName - if isConst { - origName = fmt.Sprintf("'%s'", paramName) - } - - var param named.Param - switch call.Func.Name { - case "narg": - param = named.NewUserNullableParam(paramName) - case "slice": - param = named.NewSqlcSlice(paramName) - default: - param = named.NewParam(paramName) - } - - // TODO: This code assumes that sqlc.arg(name) / sqlc.narg(name) is on a single line - // with no extraneous spaces (or any non-significant tokens for that matter) - // except between the function name and argument - funcName := call.Func.Schema + "." + call.Func.Name - spaces := "" - if call.Args != nil && len(call.Args.Items) > 0 { - leftParen := call.Args.Items[0].Pos() - 1 - spaces = strings.Repeat(" ", leftParen-call.Location-len(funcName)) - } - origText := fmt.Sprintf("%s%s(%s)", funcName, spaces, origName) - return param, origText -} - -func NamedParameters(engine config.Engine, raw *ast.RawStmt, numbs map[int]bool, dollar bool) (*ast.RawStmt, *named.ParamSet, []source.Edit) { - foundFunc := astutils.Search(raw, named.IsParamFunc) - foundSign := astutils.Search(raw, named.IsParamSign) - hasNamedParameterSupport := engine != config.EngineMySQL - allParams := named.NewParamSet(numbs, hasNamedParameterSupport) - - if len(foundFunc.Items)+len(foundSign.Items) == 0 { - return raw, allParams, nil - } - - var edits []source.Edit - node := astutils.Apply(raw, func(cr *astutils.Cursor) bool { - node := cr.Node() - switch { - case named.IsParamFunc(node): - fun := node.(*ast.FuncCall) - param, origText := paramFromFuncCall(fun) - argn := allParams.Add(param) - cr.Replace(&ast.ParamRef{ - Number: argn, - Location: fun.Location, - }) - - var replace string - if engine == config.EngineGoogleSQL { - // GoogleSQL supports named parameters natively (and Spanner - // requires them), so keep the "@name" form rather than - // rewriting to a positional placeholder. - if param.IsSqlcSlice() { - replace = fmt.Sprintf(`/*SLICE:%s*/@%s`, param.Name(), param.Name()) - } else { - replace = fmt.Sprintf("@%s", param.Name()) - } - } else if engine == config.EngineMySQL || engine == config.EngineSQLite || !dollar { - if param.IsSqlcSlice() { - // This sequence is also replicated in internal/codegen/golang.Field - // since it's needed during template generation for replacement - replace = fmt.Sprintf(`/*SLICE:%s*/?`, param.Name()) - } else { - if engine == config.EngineSQLite { - replace = fmt.Sprintf("?%d", argn) - } else { - replace = "?" - } - } - } else { - replace = fmt.Sprintf("$%d", argn) - } - - edits = append(edits, source.Edit{ - Location: fun.Location - raw.StmtLocation, - Old: origText, - New: replace, - }) - return false - - case isNamedParamSignCast(node): - expr := node.(*ast.A_Expr) - cast := expr.Rexpr.(*ast.TypeCast) - paramName, _ := flatten(cast.Arg) - param := named.NewParam(paramName) - - argn := allParams.Add(param) - cast.Arg = &ast.ParamRef{ - Number: argn, - Location: expr.Location, - } - cr.Replace(cast) - - // TODO: This code assumes that @foo::bool is on a single line - var replace string - if engine == config.EngineGoogleSQL { - replace = fmt.Sprintf("@%s", paramName) - } else if engine == config.EngineMySQL || !dollar { - replace = "?" - } else if engine == config.EngineSQLite { - replace = fmt.Sprintf("?%d", argn) - } else { - replace = fmt.Sprintf("$%d", argn) - } - - edits = append(edits, source.Edit{ - Location: expr.Location - raw.StmtLocation, - Old: fmt.Sprintf("@%s", paramName), - New: replace, - }) - return false - - case named.IsParamSign(node): - expr := node.(*ast.A_Expr) - paramName, _ := flatten(expr.Rexpr) - param := named.NewParam(paramName) - - argn := allParams.Add(param) - cr.Replace(&ast.ParamRef{ - Number: argn, - Location: expr.Location, - }) - - // TODO: This code assumes that @foo is on a single line - var replace string - if engine == config.EngineGoogleSQL { - replace = fmt.Sprintf("@%s", paramName) - } else if engine == config.EngineMySQL || !dollar { - replace = "?" - } else if engine == config.EngineSQLite { - replace = fmt.Sprintf("?%d", argn) - } else { - replace = fmt.Sprintf("$%d", argn) - } - - edits = append(edits, source.Edit{ - Location: expr.Location - raw.StmtLocation, - Old: fmt.Sprintf("@%s", paramName), - New: replace, - }) - return false - - default: - return true - } - }, nil) - - return node.(*ast.RawStmt), allParams, edits -} diff --git a/internal/sql/validate/cmd.go b/internal/sql/validate/cmd.go index 66e849de6c..e9d9dd03cb 100644 --- a/internal/sql/validate/cmd.go +++ b/internal/sql/validate/cmd.go @@ -7,7 +7,6 @@ import ( "github.com/sqlc-dev/sqlc/internal/metadata" "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/astutils" - "github.com/sqlc-dev/sqlc/internal/sql/named" ) func validateCopyfrom(n ast.Node) error { @@ -39,10 +38,7 @@ func validateCopyfrom(n ast.Node) error { return nil } for _, v := range sublist.Items { - _, ok := v.(*ast.ParamRef) - ok = ok || named.IsParamFunc(v) - ok = ok || named.IsParamSign(v) - if !ok { + if _, ok := v.(*ast.ParamRef); !ok { return errors.New(":copyfrom doesn't support non-parameter values") } } @@ -50,13 +46,11 @@ func validateCopyfrom(n ast.Node) error { } func validateBatch(n ast.Node) error { - funcs := astutils.Search(n, named.IsParamFunc) - params := astutils.Search(n, named.IsParamSign) args := astutils.Search(n, func(n ast.Node) bool { _, ok := n.(*ast.ParamRef) return ok }) - if (len(params.Items) + len(funcs.Items) + len(args.Items)) == 0 { + if len(args.Items) == 0 { return errors.New(":batch* commands require parameters") } return nil diff --git a/internal/sql/validate/func_call.go b/internal/sql/validate/func_call.go index dad621eb12..c60cbaa04b 100644 --- a/internal/sql/validate/func_call.go +++ b/internal/sql/validate/func_call.go @@ -30,10 +30,6 @@ func (v *funcCallVisitor) Visit(node ast.Node) astutils.Visitor { return v } - if fn.Schema == "sqlc" { - return nil - } - fun, err := v.catalog.ResolveFuncCall(call) if fun != nil { return v diff --git a/internal/sql/validate/in.go b/internal/sql/validate/in.go deleted file mode 100644 index 56bcee125d..0000000000 --- a/internal/sql/validate/in.go +++ /dev/null @@ -1,86 +0,0 @@ -package validate - -import ( - "fmt" - - "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/astutils" - "github.com/sqlc-dev/sqlc/internal/sql/catalog" - "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" -) - -type inVisitor struct { - catalog *catalog.Catalog - err error -} - -func (v *inVisitor) Visit(node ast.Node) astutils.Visitor { - if v.err != nil { - return nil - } - - in, ok := node.(*ast.In) - if !ok { - return v - } - - // Validate that sqlc.slice in an IN statement is the only arg, eg: - // id IN (sqlc.slice("ids")) -- GOOD - // id in (0, 1, sqlc.slice("ids")) -- BAD - - if len(in.List) <= 1 { - return v - } - - for _, n := range in.List { - call, ok := n.(*ast.FuncCall) - if !ok { - continue - } - fn := call.Func - if fn == nil { - continue - } - - if fn.Schema == "sqlc" && fn.Name == "slice" { - var inExpr, sliceArg string - - // determine inExpr - switch n := in.Expr.(type) { - case *ast.ColumnRef: - inExpr = n.Name - default: - inExpr = "..." - } - - // determine sliceArg - if len(call.Args.Items) == 1 { - switch n := call.Args.Items[0].(type) { - case *ast.A_Const: - if str, ok := n.Val.(*ast.String); ok { - sliceArg = "\"" + str.Str + "\"" - } else { - sliceArg = "?" - } - case *ast.ColumnRef: - sliceArg = n.Name - default: - // impossible, validate.FuncCall should have caught this - sliceArg = "..." - } - } - v.err = &sqlerr.Error{ - Message: fmt.Sprintf("expected '%s IN' expr to consist only of sqlc.slice(%s); eg ", inExpr, sliceArg), - Location: call.Pos(), - } - } - } - - return v -} - -func In(c *catalog.Catalog, n ast.Node) error { - visitor := inVisitor{catalog: c} - astutils.Walk(&visitor, n) - return visitor.err -} diff --git a/internal/sql/validate/param_ref.go b/internal/sql/validate/param_ref.go deleted file mode 100644 index ab9413f40f..0000000000 --- a/internal/sql/validate/param_ref.go +++ /dev/null @@ -1,48 +0,0 @@ -package validate - -import ( - "errors" - "fmt" - - "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/astutils" - "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" -) - -func ParamRef(n ast.Node) (map[int]bool, bool, error) { - var allrefs []*ast.ParamRef - var dollar bool - var nodollar bool - // Find all parameter references - astutils.Walk(astutils.VisitorFunc(func(node ast.Node) { - switch n := node.(type) { - case *ast.ParamRef: - ref := node.(*ast.ParamRef) - if ref.Dollar { - dollar = true - } else { - nodollar = true - } - allrefs = append(allrefs, n) - } - }), n) - if dollar && nodollar { - return nil, false, errors.New("can not mix $1 format with ? format") - } - - seen := map[int]bool{} - for _, r := range allrefs { - if r.Number > 0 { - seen[r.Number] = true - } - } - for i := 1; i <= len(seen); i += 1 { - if _, ok := seen[i]; !ok { - return seen, !nodollar, &sqlerr.Error{ - Code: "42P18", - Message: fmt.Sprintf("could not determine data type of parameter $%d", i), - } - } - } - return seen, !nodollar, nil -} diff --git a/internal/sql/validate/param_style.go b/internal/sql/validate/param_style.go deleted file mode 100644 index 1182051d20..0000000000 --- a/internal/sql/validate/param_style.go +++ /dev/null @@ -1,68 +0,0 @@ -package validate - -import ( - "fmt" - - "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/astutils" - "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" -) - -type sqlcFuncVisitor struct { - err error -} - -func (v *sqlcFuncVisitor) Visit(node ast.Node) astutils.Visitor { - if v.err != nil { - return nil - } - - call, ok := node.(*ast.FuncCall) - if !ok { - return v - } - fn := call.Func - if fn == nil { - return v - } - - // Custom validation for sqlc.arg, sqlc.narg and sqlc.slice - // TODO: Replace this once type-checking is implemented - if fn.Schema == "sqlc" { - if !(fn.Name == "arg" || fn.Name == "narg" || fn.Name == "slice" || fn.Name == "embed") { - v.err = sqlerr.FunctionNotFound("sqlc." + fn.Name) - return nil - } - - if len(call.Args.Items) != 1 { - v.err = &sqlerr.Error{ - Message: fmt.Sprintf("expected 1 parameter to sqlc.%s; got %d", fn.Name, len(call.Args.Items)), - Location: call.Pos(), - } - return nil - } - - switch n := call.Args.Items[0].(type) { - case *ast.A_Const: - case *ast.ColumnRef: - default: - v.err = &sqlerr.Error{ - Message: fmt.Sprintf("expected parameter to sqlc.%s to be string or reference; got %T", fn.Name, n), - Location: call.Pos(), - } - return nil - } - - // If we have sqlc.arg or sqlc.narg, there is no need to resolve the function call. - // It won't resolve anyway, sinc it is not a real function. - return nil - } - - return nil -} - -func SqlcFunctions(n ast.Node) error { - visitor := sqlcFuncVisitor{} - astutils.Walk(&visitor, n) - return visitor.err -} diff --git a/internal/sql/validate/slice.go b/internal/sql/validate/slice.go new file mode 100644 index 0000000000..cbd45ed9fe --- /dev/null +++ b/internal/sql/validate/slice.go @@ -0,0 +1,70 @@ +package validate + +import ( + "fmt" + + "github.com/sqlc-dev/sqlc/internal/sql/ast" + "github.com/sqlc-dev/sqlc/internal/sql/astutils" + "github.com/sqlc-dev/sqlc/internal/sql/sqlerr" +) + +type sliceVisitor struct { + // slices maps the location of a placeholder in the query text to the name + // of the sqlc.slice() parameter it was rewritten from. + slices map[int]string + err error +} + +func (v *sliceVisitor) Visit(node ast.Node) astutils.Visitor { + if v.err != nil { + return nil + } + + in, ok := node.(*ast.In) + if !ok { + return v + } + + // A slice expands to a comma-separated list at query time, so it has to be + // the only element of the IN expression: + // id IN (sqlc.slice("ids")) -- GOOD + // id in (0, 1, sqlc.slice("ids")) -- BAD + if len(in.List) <= 1 { + return v + } + + for _, n := range in.List { + ref, ok := n.(*ast.ParamRef) + if !ok { + continue + } + name, ok := v.slices[ref.Location] + if !ok { + continue + } + + inExpr := "..." + if col, ok := in.Expr.(*ast.ColumnRef); ok { + inExpr = col.Name + } + v.err = &sqlerr.Error{ + Message: fmt.Sprintf("expected '%s IN' expr to consist only of sqlc.slice(%s); eg ", inExpr, name), + Location: ref.Location, + } + return nil + } + + return v +} + +// Slice reports whether every sqlc.slice() parameter is the only element of the +// IN expression that contains it. The slices map is keyed by the location the +// preprocessor recorded for each rewritten slice placeholder. +func Slice(n ast.Node, slices map[int]string) error { + if len(slices) == 0 { + return nil + } + visitor := sliceVisitor{slices: slices} + astutils.Walk(&visitor, n) + return visitor.err +} From 7fcad42c72f1690e1cd06c813d42e3c816399d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:50:51 +0000 Subject: [PATCH 2/4] preprocess: add a side-table golden to each testdata case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewritten SQL only shows half of what the preprocessor produces. The other half — parameter names and nullability, embed and slice spans, placeholder numbering and the offset map back to the original text — was covered by a handful of hand-written Go tests that each poked at one case. Record it as a golden instead. Every testdata directory now holds a side_table.json alongside its output.sql, rendered by reading the result back through the same API the compiler uses, so all 59 cases exercise the whole side table rather than the six that had a bespoke test. The hand-written tests are gone. Two supporting changes: - Result.Statements() exposes the statements in source order, so the side table can be walked without looking each one up by offset - the whitespace trailing the final semicolon is no longer reported as a statement; it is still written to the output, it is just not something a query can be found in Renaming the errors helper to cover ParamErr as well as Err turned up two cases whose input also tripped the numbering-gap check. Their inputs are now valid so each case tests one thing, and the gap and mixed-style errors get cases of their own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015BSM9xGoosq58r1Mz6B7YP --- internal/sql/preprocess/CLAUDE.md | 25 +- internal/sql/preprocess/preprocess.go | 17 +- internal/sql/preprocess/preprocess_test.go | 291 ++++++++---------- .../testdata/clickhouse/arg/side_table.json | 24 ++ .../side_table.json | 16 + .../testdata/clickhouse/embed/side_table.json | 15 + .../testdata/clickhouse/slice/side_table.json | 17 + .../side_table.json | 23 ++ .../testdata/googlesql/embed/side_table.json | 15 + .../testdata/googlesql/slice/side_table.json | 17 + .../testdata/mysql/arg/side_table.json | 23 ++ .../testdata/mysql/embed/side_table.json | 15 + .../side_table.json | 28 ++ .../on_duplicate_key_update/side_table.json | 23 ++ .../skip_backslash_escape/side_table.json | 7 + .../skip_backtick_identifier/side_table.json | 7 + .../skip_double_quoted_string/side_table.json | 7 + .../mysql/skip_hash_comment/side_table.json | 7 + .../testdata/mysql/slice/side_table.json | 17 + .../side_table.json | 16 + .../testdata/postgresql/arg/side_table.json | 16 + .../postgresql/at_sign/side_table.json | 23 ++ .../postgresql/at_sign_cast/side_table.json | 16 + .../comment_inside_call/side_table.json | 16 + .../testdata/postgresql/embed/side_table.json | 21 ++ .../postgresql/embed_and_user_star/input.sql | 1 + .../postgresql/embed_and_user_star/output.sql | 1 + .../embed_and_user_star/side_table.json | 15 + .../embed_quoted_identifier/side_table.json | 15 + .../embed_with_args/side_table.json | 24 ++ .../error_is_per_statement/side_table.json | 23 ++ .../error_nested_call/side_table.json | 9 + .../error_no_arguments/side_table.json | 9 + .../postgresql/error_numbering_gap/input.sql | 1 + .../postgresql/error_numbering_gap/output.sql | 1 + .../error_numbering_gap/side_table.json | 22 ++ .../postgresql/error_numbering_gap/stderr.txt | 1 + .../side_table.json | 9 + .../error_too_many_arguments/side_table.json | 9 + .../error_unknown_function/side_table.json | 9 + .../postgresql/existing_placeholder/input.sql | 2 +- .../existing_placeholder/output.sql | 2 +- .../existing_placeholder/side_table.json | 22 ++ .../extra_whitespace/side_table.json | 23 ++ .../postgresql/multiline_call/side_table.json | 16 + .../testdata/postgresql/narg/side_table.json | 17 + .../side_table.json | 30 ++ .../postgresql/quoted_names/side_table.json | 23 ++ .../postgresql/repeated_name/side_table.json | 30 ++ .../skip_block_comment/side_table.json | 7 + .../skip_dollar_quoted/side_table.json | 7 + .../skip_escape_string/side_table.json | 7 + .../skip_jsonb_operators/side_table.json | 7 + .../skip_line_comment/side_table.json | 7 + .../skip_nested_block_comment/side_table.json | 7 + .../skip_other_schemas/side_table.json | 7 + .../skip_quoted_identifier/side_table.json | 7 + .../skip_string_literal/side_table.json | 7 + .../skip_tagged_dollar_quoted/side_table.json | 7 + .../slice_is_an_array/side_table.json | 17 + .../side_table.json | 30 ++ .../postgresql/uppercase/side_table.json | 16 + .../testdata/sqlite/arg/side_table.json | 23 ++ .../testdata/sqlite/at_sign/side_table.json | 16 + .../testdata/sqlite/embed/side_table.json | 24 ++ .../error_mixed_placeholder_styles/input.sql | 1 + .../error_mixed_placeholder_styles/output.sql | 1 + .../side_table.json | 22 ++ .../error_mixed_placeholder_styles/stderr.txt | 1 + .../existing_numbered_placeholder/input.sql | 2 +- .../existing_numbered_placeholder/output.sql | 2 +- .../side_table.json | 22 ++ .../sqlite/repeated_name/side_table.json | 23 ++ .../testdata/sqlite/slice/side_table.json | 17 + 74 files changed, 1133 insertions(+), 170 deletions(-) create mode 100644 internal/sql/preprocess/testdata/clickhouse/arg/side_table.json create mode 100644 internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json create mode 100644 internal/sql/preprocess/testdata/clickhouse/embed/side_table.json create mode 100644 internal/sql/preprocess/testdata/clickhouse/slice/side_table.json create mode 100644 internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json create mode 100644 internal/sql/preprocess/testdata/googlesql/embed/side_table.json create mode 100644 internal/sql/preprocess/testdata/googlesql/slice/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/arg/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/embed/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/skip_backslash_escape/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/skip_hash_comment/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/slice/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/arg/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign_cast/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/comment_inside_call/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/embed/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_and_user_star/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_and_user_star/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_and_user_star/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_with_args/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_is_per_statement/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_nested_call/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_no_arguments/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_numbering_gap/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_numbering_gap/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/error_numbering_gap/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_numbering_gap/stderr.txt create mode 100644 internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/error_unknown_function/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/existing_placeholder/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/extra_whitespace/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/multiline_call/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/narg/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/quoted_names/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/repeated_name/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_block_comment/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_escape_string/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_line_comment/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_other_schemas/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_string_literal/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/slice_is_an_array/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/uppercase/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/arg/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/at_sign/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/embed/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/stderr.txt create mode 100644 internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/repeated_name/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/slice/side_table.json diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md index 5f0a8ee31c..225ab20003 100644 --- a/internal/sql/preprocess/CLAUDE.md +++ b/internal/sql/preprocess/CLAUDE.md @@ -63,8 +63,10 @@ original source, so errors point at what the user wrote. ## Invariants -- **Line structure is preserved.** A rewrite never adds or removes a newline, so - line numbers survive even before `Origin` is applied. +- **A rewrite never adds a line.** Replacements are single-line, so a line + number taken from the rewritten text is never past the end of the original. It + can *lose* lines, when the sqlc call itself spanned several — map offsets + through `Origin` rather than trusting line numbers. - **An invalid statement is copied through untouched.** The engine still parses what the user wrote and the error is reported per statement, not per file. - **Nothing inside a comment or literal is rewritten.** Query annotations like @@ -72,9 +74,22 @@ original source, so errors point at what the user wrote. ## Tests -`testdata///` holds an `input.sql`, the expected `output.sql` and, -for invalid input, a `stderr.txt`. `TestRewrite` runs one subtest per directory. -Regenerate the goldens with: +`testdata///` holds the input and the expected results: + +| file | contents | +| --- | --- | +| `input.sql` | the query file | +| `output.sql` | the rewritten SQL | +| `side_table.json` | everything the preprocessor recorded | +| `stderr.txt` | reported errors, only when the input is invalid | + +`TestRewrite` runs one subtest per directory. `side_table.json` is rendered by +reading the result back through the same API the compiler uses, so it covers +parameter names and nullability, embed and slice spans, placeholder numbering +and the offset map — a parameter's `location` is its offset in the rewritten +text and its `origin` is where it came from. + +Regenerate every golden with: ```bash go test ./internal/sql/preprocess -update diff --git a/internal/sql/preprocess/preprocess.go b/internal/sql/preprocess/preprocess.go index d002a37115..c2db464715 100644 --- a/internal/sql/preprocess/preprocess.go +++ b/internal/sql/preprocess/preprocess.go @@ -106,6 +106,11 @@ type edit struct { newStart, newEnd int } +// Statements returns every statement in the rewritten text, in source order. +func (r *Result) Statements() []*Statement { + return r.stmts +} + // Statement returns the preprocessing results for the statement covering the // given offset in the rewritten text. It never returns nil. func (r *Result) Statement(location int) *Statement { @@ -176,8 +181,18 @@ func Dialected(d Dialect, src string) (*Result, error) { var b strings.Builder b.Grow(len(src)) - for _, span := range l.statements() { + spans := l.statements() + for i, span := range spans { start, end := span[0], span[1] + + // Whatever trails the last semicolon is only whitespace in a + // well-formed file. It still belongs in the output, but it is not a + // statement. + if i == len(spans)-1 && strings.TrimSpace(src[start:end]) == "" { + b.WriteString(src[start:end]) + continue + } + occs := l.scan(start, end) stmt := &Statement{Start: b.Len()} diff --git a/internal/sql/preprocess/preprocess_test.go b/internal/sql/preprocess/preprocess_test.go index 46f49e967a..93fd2bd9ed 100644 --- a/internal/sql/preprocess/preprocess_test.go +++ b/internal/sql/preprocess/preprocess_test.go @@ -1,6 +1,7 @@ package preprocess_test import ( + "encoding/json" "flag" "fmt" "os" @@ -21,12 +22,14 @@ import ( var update = flag.Bool("update", false, "update the testdata golden files") // TestRewrite runs every case under testdata. Each case is a directory holding -// an input.sql, the expected output.sql and, when the input is invalid, a -// stderr.txt with the reported error. Cases are grouped by engine: +// the input and the expected results, grouped by engine: // -// testdata///input.sql -// testdata///output.sql -// testdata///stderr.txt (optional) +// testdata///input.sql the query file +// testdata///output.sql the rewritten SQL +// testdata///side_table.json what the preprocessor recorded +// testdata///stderr.txt reported errors (only when invalid) +// +// Regenerate the golden files with `go test ./internal/sql/preprocess -update`. func TestRewrite(t *testing.T) { for _, dir := range cases(t) { t.Run(filepath.ToSlash(dir), func(t *testing.T) { @@ -40,11 +43,134 @@ func TestRewrite(t *testing.T) { } compare(t, filepath.Join(path, "output.sql"), res.Text) + compare(t, filepath.Join(path, "side_table.json"), sideTable(res)) compare(t, filepath.Join(path, "stderr.txt"), errors(input, res)) + + // A rewrite only ever replaces text with something shorter or + // equal in lines, so a line number taken from the rewritten text is + // never past the end of the original. + if want, got := strings.Count(input, "\n"), strings.Count(res.Text, "\n"); got > want { + t.Errorf("rewrite added lines: got %d, want at most %d", got, want) + } }) } } +// The JSON shape of the side table the preprocessor records alongside the +// rewritten SQL. Locations are offsets into the rewritten text; origins are the +// offsets they map back to in the original source. +type statement struct { + Start int `json:"start"` + End int `json:"end"` + Dollar bool `json:"dollar"` + Params []parameter `json:"params,omitempty"` + Embeds []embed `json:"embeds,omitempty"` + Error string `json:"error,omitempty"` + ErrorAt int `json:"error_at,omitempty"` + ParamError string `json:"param_error,omitempty"` +} + +type parameter struct { + Number int `json:"number"` + Location int `json:"location"` + Origin int `json:"origin"` + Name string `json:"name,omitempty"` + // Named is false for a placeholder the user wrote themselves, which the + // preprocessor numbers but does not name. + Named bool `json:"named"` + // Nullable is true when sqlc.narg() forced the parameter nullable, even + // though the schema says it is NOT NULL. + Nullable bool `json:"nullable,omitempty"` + Slice bool `json:"slice,omitempty"` +} + +type embed struct { + Table string `json:"table"` + Location int `json:"location"` + Origin int `json:"origin"` + Orig string `json:"orig"` +} + +// sideTable renders everything the preprocessor recorded, reading it back +// through the same API the compiler uses. +func sideTable(res *preprocess.Result) string { + out := make([]statement, 0, len(res.Statements())) + for _, stmt := range res.Statements() { + s := statement{Start: stmt.Start, End: stmt.End, Dollar: stmt.Dollar} + if stmt.Err != nil { + s.Error = stmt.Err.Error() + if e, ok := stmt.Err.(*sqlerr.Error); ok { + s.ErrorAt = e.Location + } + } + if stmt.ParamErr != nil { + s.ParamError = stmt.ParamErr.Error() + } + + locations := make([]int, 0, len(stmt.Numbers)) + for loc := range stmt.Numbers { + locations = append(locations, loc) + } + sort.Ints(locations) + for _, loc := range locations { + number := stmt.Numbers[loc] + name, _ := stmt.Params.NameFor(number) + // Merging against a NOT NULL parameter is how the compiler asks + // whether the user overrode nullability with sqlc.narg(). + p, isNamed := stmt.Params.FetchMerge(number, named.NewInferredParam(name, true)) + _, isSlice := stmt.Slices[loc] + s.Params = append(s.Params, parameter{ + Number: number, + Location: loc, + Origin: res.Origin(loc), + Name: name, + Named: isNamed, + Nullable: isNamed && !p.NotNull(), + Slice: isSlice, + }) + } + + for _, e := range stmt.Embeds { + s.Embeds = append(s.Embeds, embed{ + Table: e.Table.Name, + Location: e.Location, + Origin: res.Origin(e.Location), + Orig: e.Orig(), + }) + } + out = append(out, s) + } + + blob, err := json.MarshalIndent(out, "", " ") + if err != nil { + panic(err) + } + return string(blob) + "\n" +} + +// errors renders every validation error the preprocessor recorded, one per +// line, as "line:column: message" against the original source. +func errors(input string, res *preprocess.Result) string { + var out []string + for _, stmt := range res.Statements() { + for _, err := range []error{stmt.Err, stmt.ParamErr} { + if err == nil { + continue + } + loc := stmt.Start + if e, ok := err.(*sqlerr.Error); ok && e.Location != 0 { + loc = e.Location + } + line, column := source.LineNumber(input, res.Origin(loc)) + out = append(out, fmt.Sprintf("%d:%d: %s", line, column, err)) + } + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + // cases returns every testdata directory that holds an input.sql, relative to // testdata and in a stable order. func cases(t *testing.T) []string { @@ -74,32 +200,6 @@ func cases(t *testing.T) []string { return dirs } -// errors renders every validation error the preprocessor recorded, one per -// line, as "line:column: message" against the original source. -func errors(input string, res *preprocess.Result) string { - var out []string - for offset := 0; offset < len(res.Text); { - stmt := res.Statement(offset) - if stmt.End <= offset { - break - } - offset = stmt.End - if stmt.Err == nil { - continue - } - loc := 0 - if e, ok := stmt.Err.(*sqlerr.Error); ok { - loc = res.Origin(e.Location) - } - line, column := source.LineNumber(input, loc) - out = append(out, fmt.Sprintf("%d:%d: %s", line, column, stmt.Err)) - } - if len(out) == 0 { - return "" - } - return strings.Join(out, "\n") + "\n" -} - func readFile(t *testing.T, path string) string { t.Helper() blob, err := os.ReadFile(path) @@ -133,132 +233,3 @@ func compare(t *testing.T, path, actual string) { t.Errorf("%s differed (-want +got):\n%s", filepath.Base(path), diff) } } - -func mustRewrite(t *testing.T, engine config.Engine, src string) *preprocess.Result { - t.Helper() - res, err := preprocess.File(engine, src) - if err != nil { - t.Fatalf("preprocess.File(%s): %s", engine, err) - } - return res -} - -func TestParamSet(t *testing.T) { - res := mustRewrite(t, config.EnginePostgreSQL, - "SELECT * FROM users WHERE a = sqlc.arg(alpha) AND b = sqlc.narg(beta) AND c = ANY(sqlc.slice(gamma));") - stmt := res.Statement(0) - - for num, want := range map[int]string{1: "alpha", 2: "beta", 3: "gamma"} { - got, ok := stmt.Params.NameFor(num) - if !ok { - t.Fatalf("no name for parameter %d", num) - } - if got != want { - t.Errorf("parameter %d: got %q, want %q", num, got, want) - } - } - - // sqlc.narg() marks the parameter nullable even when inference says - // otherwise. - beta, _ := stmt.Params.FetchMerge(2, named.NewInferredParam("beta", true)) - if beta.NotNull() { - t.Error("sqlc.narg parameter should be nullable") - } - gamma, _ := stmt.Params.FetchMerge(3, named.NewParam("gamma")) - if !gamma.IsSqlcSlice() { - t.Error("sqlc.slice parameter should be marked as a slice") - } -} - -func TestEmbedSpans(t *testing.T) { - src := "SELECT sqlc.embed(a), b.* FROM a, b;" - res := mustRewrite(t, config.EnginePostgreSQL, src) - stmt := res.Statement(0) - if len(stmt.Embeds) != 1 { - t.Fatalf("expected 1 embed, got %d", len(stmt.Embeds)) - } - e := stmt.Embeds[0] - if e.Table.Name != "a" { - t.Errorf("embed table: got %q, want %q", e.Table.Name, "a") - } - if want := strings.Index(res.Text, "a.*"); e.Location != want { - t.Errorf("embed location: got %d, want %d", e.Location, want) - } - // A star reference the user wrote must not be mistaken for an embed. - if _, ok := stmt.Embeds.Find(strings.Index(res.Text, "b.*")); ok { - t.Error("user-written b.* was reported as an embed") - } - if got, want := e.Orig(), "sqlc.embed(a)"; got != want { - t.Errorf("Orig: got %q, want %q", got, want) - } -} - -func TestSliceSpans(t *testing.T) { - src := "SELECT * FROM t WHERE a IN (sqlc.slice(ids)) AND b = sqlc.arg(x);" - res := mustRewrite(t, config.EngineMySQL, src) - stmt := res.Statement(0) - if len(stmt.Slices) != 1 { - t.Fatalf("expected 1 slice, got %d", len(stmt.Slices)) - } - // The recorded offset points at the placeholder itself, past the marker, - // which is where the engine reports the parameter node. - want := strings.Index(res.Text, "*/?") + len("*/") - if got, ok := stmt.Slices[want]; !ok { - t.Errorf("slice offsets %v do not contain %d", stmt.Slices, want) - } else if got != "ids" { - t.Errorf("slice name: got %q, want %q", got, "ids") - } -} - -func TestOriginMapping(t *testing.T) { - src := "SELECT * FROM t WHERE a = sqlc.arg(alpha) AND b = sqlc.arg(beta);" - res := mustRewrite(t, config.EnginePostgreSQL, src) - - // Text before the first rewrite maps to itself. - if got := res.Origin(3); got != 3 { - t.Errorf("Origin(3): got %d, want 3", got) - } - // Text after a rewrite maps back across the length change. - newB, oldB := strings.Index(res.Text, "b ="), strings.Index(src, "b =") - if got := res.Origin(newB); got != oldB { - t.Errorf("Origin(%d): got %d, want %d", newB, got, oldB) - } - // An offset inside a placeholder maps to the start of what it replaced. - newParam := strings.Index(res.Text, "$2") - oldParam := strings.Index(src, "sqlc.arg(beta)") - if got := res.Origin(newParam); got != oldParam { - t.Errorf("Origin(%d): got %d, want %d", newParam, got, oldParam) - } -} - -func TestLinesArePreserved(t *testing.T) { - // Downstream error reporting counts lines in the rewritten text, so a - // rewrite must never add or remove one. - src := "-- name: Get :one\nSELECT *\nFROM users\nWHERE id = sqlc.arg(id)\n AND name = @name;\n" - res := mustRewrite(t, config.EnginePostgreSQL, src) - if want, got := strings.Count(src, "\n"), strings.Count(res.Text, "\n"); want != got { - t.Errorf("line count changed: got %d, want %d", got, want) - } -} - -func TestStatementLookup(t *testing.T) { - src := "SELECT sqlc.arg(a);\nSELECT sqlc.arg(b), sqlc.arg(c);\n" - res := mustRewrite(t, config.EnginePostgreSQL, src) - first := res.Statement(0) - second := res.Statement(strings.Index(res.Text, "$1, $2")) - if first == second { - t.Fatal("expected two distinct statements") - } - if name, _ := first.Params.NameFor(1); name != "a" { - t.Errorf("first statement parameter 1: got %q, want %q", name, "a") - } - if name, _ := second.Params.NameFor(2); name != "c" { - t.Errorf("second statement parameter 2: got %q, want %q", name, "c") - } -} - -func TestUnknownEngine(t *testing.T) { - if _, err := preprocess.File(config.Engine("nope"), "SELECT 1;"); err == nil { - t.Fatal("expected an error for an unknown engine") - } -} diff --git a/internal/sql/preprocess/testdata/clickhouse/arg/side_table.json b/internal/sql/preprocess/testdata/clickhouse/arg/side_table.json new file mode 100644 index 0000000000..2eea42e53c --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/arg/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 43, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 41, + "origin": 51, + "name": "y", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json new file mode 100644 index 0000000000..e773758f6d --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 33, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/clickhouse/embed/side_table.json b/internal/sql/preprocess/testdata/clickhouse/embed/side_table.json new file mode 100644 index 0000000000..840d08c39d --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/embed/side_table.json @@ -0,0 +1,15 @@ +[ + { + "start": 0, + "end": 26, + "dollar": true, + "embeds": [ + { + "table": "users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(users)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/clickhouse/slice/side_table.json b/internal/sql/preprocess/testdata/clickhouse/slice/side_table.json new file mode 100644 index 0000000000..4b16794ab9 --- /dev/null +++ b/internal/sql/preprocess/testdata/clickhouse/slice/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 50, + "dollar": true, + "params": [ + { + "number": 1, + "location": 47, + "origin": 34, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json new file mode 100644 index 0000000000..1e687873c6 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 42, + "origin": 51, + "name": "y", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/googlesql/embed/side_table.json b/internal/sql/preprocess/testdata/googlesql/embed/side_table.json new file mode 100644 index 0000000000..840d08c39d --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/embed/side_table.json @@ -0,0 +1,15 @@ +[ + { + "start": 0, + "end": 26, + "dollar": true, + "embeds": [ + { + "table": "users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(users)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/googlesql/slice/side_table.json b/internal/sql/preprocess/testdata/googlesql/slice/side_table.json new file mode 100644 index 0000000000..bd079a7709 --- /dev/null +++ b/internal/sql/preprocess/testdata/googlesql/slice/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 53, + "dollar": true, + "params": [ + { + "number": 1, + "location": 47, + "origin": 34, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/arg/side_table.json b/internal/sql/preprocess/testdata/mysql/arg/side_table.json new file mode 100644 index 0000000000..78b83b34a6 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/arg/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 43, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 41, + "origin": 51, + "name": "x", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/embed/side_table.json b/internal/sql/preprocess/testdata/mysql/embed/side_table.json new file mode 100644 index 0000000000..840d08c39d --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/embed/side_table.json @@ -0,0 +1,15 @@ +[ + { + "start": 0, + "end": 26, + "dollar": true, + "embeds": [ + { + "table": "users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(users)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/side_table.json b/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/side_table.json new file mode 100644 index 0000000000..2f8a512eef --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/existing_placeholders_are_numbered_together/side_table.json @@ -0,0 +1,28 @@ +[ + { + "start": 0, + "end": 53, + "dollar": false, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "named": false + }, + { + "number": 2, + "location": 41, + "origin": 41, + "name": "x", + "named": true + }, + { + "number": 3, + "location": 51, + "origin": 61, + "named": false + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/side_table.json b/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/side_table.json new file mode 100644 index 0000000000..6b4e506b56 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/on_duplicate_key_update/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 59, + "dollar": true, + "params": [ + { + "number": 1, + "location": 26, + "origin": 26, + "name": "val", + "named": true + }, + { + "number": 2, + "location": 57, + "origin": 69, + "name": "new_val", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/side_table.json b/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/side_table.json new file mode 100644 index 0000000000..7ab9eaedd2 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_backslash_escape/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 28, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/side_table.json b/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/side_table.json new file mode 100644 index 0000000000..cdde9a6d54 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_backtick_identifier/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 31, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/side_table.json b/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/side_table.json new file mode 100644 index 0000000000..cdde9a6d54 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_double_quoted_string/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 31, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/mysql/skip_hash_comment/side_table.json b/internal/sql/preprocess/testdata/mysql/skip_hash_comment/side_table.json new file mode 100644 index 0000000000..667e960a18 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/skip_hash_comment/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 26, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/mysql/slice/side_table.json b/internal/sql/preprocess/testdata/mysql/slice/side_table.json new file mode 100644 index 0000000000..4b16794ab9 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/slice/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 50, + "dollar": true, + "params": [ + { + "number": 1, + "location": 47, + "origin": 34, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/side_table.json b/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/side_table.json new file mode 100644 index 0000000000..ccf1ada0f7 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/user_variables_are_not_parameters/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 48, + "dollar": true, + "params": [ + { + "number": 1, + "location": 46, + "origin": 46, + "name": "id", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/arg/side_table.json b/internal/sql/preprocess/testdata/postgresql/arg/side_table.json new file mode 100644 index 0000000000..d01162f091 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/arg/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 59, + "dollar": true, + "params": [ + { + "number": 1, + "location": 56, + "origin": 56, + "name": "name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign/side_table.json b/internal/sql/preprocess/testdata/postgresql/at_sign/side_table.json new file mode 100644 index 0000000000..bebc9a14d7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 50, + "dollar": true, + "params": [ + { + "number": 1, + "location": 34, + "origin": 34, + "name": "name", + "named": true + }, + { + "number": 2, + "location": 47, + "origin": 50, + "name": "age", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign_cast/side_table.json b/internal/sql/preprocess/testdata/postgresql/at_sign_cast/side_table.json new file mode 100644 index 0000000000..02ec768518 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign_cast/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 41, + "dollar": true, + "params": [ + { + "number": 1, + "location": 32, + "origin": 32, + "name": "flag", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/comment_inside_call/side_table.json b/internal/sql/preprocess/testdata/postgresql/comment_inside_call/side_table.json new file mode 100644 index 0000000000..80979ab73b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/comment_inside_call/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 10, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/embed/side_table.json b/internal/sql/preprocess/testdata/postgresql/embed/side_table.json new file mode 100644 index 0000000000..ed71a0d974 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed/side_table.json @@ -0,0 +1,21 @@ +[ + { + "start": 0, + "end": 52, + "dollar": true, + "embeds": [ + { + "table": "users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(users)" + }, + { + "table": "pets", + "location": 16, + "origin": 26, + "orig": "sqlc.embed(pets)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/input.sql b/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/input.sql new file mode 100644 index 0000000000..bec18076c7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed(a), b.* FROM a, b; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/output.sql b/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/output.sql new file mode 100644 index 0000000000..e3ea7edc61 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/output.sql @@ -0,0 +1 @@ +SELECT a.*, b.* FROM a, b; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/side_table.json b/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/side_table.json new file mode 100644 index 0000000000..d30b005ee8 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_and_user_star/side_table.json @@ -0,0 +1,15 @@ +[ + { + "start": 0, + "end": 26, + "dollar": true, + "embeds": [ + { + "table": "a", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(a)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/side_table.json b/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/side_table.json new file mode 100644 index 0000000000..02f9e3ddb4 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_quoted_identifier/side_table.json @@ -0,0 +1,15 @@ +[ + { + "start": 0, + "end": 30, + "dollar": true, + "embeds": [ + { + "table": "Users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(\"Users\")" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/embed_with_args/side_table.json b/internal/sql/preprocess/testdata/postgresql/embed_with_args/side_table.json new file mode 100644 index 0000000000..93a6a0f157 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_with_args/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 66, + "dollar": true, + "params": [ + { + "number": 1, + "location": 63, + "origin": 73, + "name": "id", + "named": true + } + ], + "embeds": [ + { + "table": "a", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(a)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/side_table.json new file mode 100644 index 0000000000..e4c5d10b30 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_is_per_statement/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 20, + "dollar": true, + "error": "function \"sqlc.argh\" does not exist", + "error_at": 7 + }, + { + "start": 20, + "end": 31, + "dollar": true, + "params": [ + { + "number": 1, + "location": 28, + "origin": 28, + "name": "b", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_nested_call/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_nested_call/side_table.json new file mode 100644 index 0000000000..988c57ab6e --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_nested_call/side_table.json @@ -0,0 +1,9 @@ +[ + { + "start": 0, + "end": 29, + "dollar": true, + "error": "expected parameter to sqlc.arg to be string or reference; got \"sqlc.arg(a)\"", + "error_at": 7 + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_no_arguments/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/side_table.json new file mode 100644 index 0000000000..3f37493ae4 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_no_arguments/side_table.json @@ -0,0 +1,9 @@ +[ + { + "start": 0, + "end": 18, + "dollar": true, + "error": "expected 1 parameter to sqlc.arg; got 0", + "error_at": 7 + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/input.sql b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/input.sql new file mode 100644 index 0000000000..5a0ead05c7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = $2 AND b = $3; diff --git a/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/output.sql b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/output.sql new file mode 100644 index 0000000000..5a0ead05c7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = $2 AND b = $3; diff --git a/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/side_table.json new file mode 100644 index 0000000000..18933caa30 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/side_table.json @@ -0,0 +1,22 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 2, + "location": 31, + "origin": 31, + "named": false + }, + { + "number": 3, + "location": 42, + "origin": 42, + "named": false + } + ], + "param_error": "could not determine data type of parameter $1" + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/stderr.txt b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/stderr.txt new file mode 100644 index 0000000000..a9cbefb9c2 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_numbering_gap/stderr.txt @@ -0,0 +1 @@ +1:1: could not determine data type of parameter $1 diff --git a/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/side_table.json new file mode 100644 index 0000000000..918be18b6d --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_placeholder_argument/side_table.json @@ -0,0 +1,9 @@ +[ + { + "start": 0, + "end": 20, + "dollar": true, + "error": "expected parameter to sqlc.arg to be string or reference; got \"$1\"", + "error_at": 7 + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/side_table.json new file mode 100644 index 0000000000..501e713b55 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_too_many_arguments/side_table.json @@ -0,0 +1,9 @@ +[ + { + "start": 0, + "end": 26, + "dollar": true, + "error": "expected 1 parameter to sqlc.arg; got 2", + "error_at": 7 + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/error_unknown_function/side_table.json b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/side_table.json new file mode 100644 index 0000000000..99e103eaa7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/error_unknown_function/side_table.json @@ -0,0 +1,9 @@ +[ + { + "start": 0, + "end": 20, + "dollar": true, + "error": "function \"sqlc.argh\" does not exist", + "error_at": 7 + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql index 6da8d33458..1d7497ac8b 100644 --- a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql +++ b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/input.sql @@ -1 +1 @@ -SELECT id FROM users WHERE a = $2 AND b = sqlc.arg(x); +SELECT id FROM users WHERE a = $1 AND b = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql index 80bbd33f1b..1742cc3d85 100644 --- a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql +++ b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/output.sql @@ -1 +1 @@ -SELECT id FROM users WHERE a = $2 AND b = $1; +SELECT id FROM users WHERE a = $1 AND b = $2; diff --git a/internal/sql/preprocess/testdata/postgresql/existing_placeholder/side_table.json b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/side_table.json new file mode 100644 index 0000000000..8f0974672b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/existing_placeholder/side_table.json @@ -0,0 +1,22 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "named": false + }, + { + "number": 2, + "location": 42, + "origin": 42, + "name": "x", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/extra_whitespace/side_table.json b/internal/sql/preprocess/testdata/postgresql/extra_whitespace/side_table.json new file mode 100644 index 0000000000..58dc58d402 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/extra_whitespace/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 14, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "name", + "named": true + }, + { + "number": 2, + "location": 11, + "origin": 28, + "name": "other", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/multiline_call/side_table.json b/internal/sql/preprocess/testdata/postgresql/multiline_call/side_table.json new file mode 100644 index 0000000000..80979ab73b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/multiline_call/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 10, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/narg/side_table.json b/internal/sql/preprocess/testdata/postgresql/narg/side_table.json new file mode 100644 index 0000000000..7d3175896b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/narg/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 59, + "dollar": true, + "params": [ + { + "number": 1, + "location": 56, + "origin": 56, + "name": "name", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/side_table.json b/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/side_table.json new file mode 100644 index 0000000000..2921bcb179 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/numbering_restarts_per_statement/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 10, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "a", + "named": true + } + ] + }, + { + "start": 10, + "end": 21, + "dollar": true, + "params": [ + { + "number": 1, + "location": 18, + "origin": 27, + "name": "b", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/quoted_names/side_table.json b/internal/sql/preprocess/testdata/postgresql/quoted_names/side_table.json new file mode 100644 index 0000000000..c6fedbd2fa --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/quoted_names/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 14, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "a", + "named": true + }, + { + "number": 2, + "location": 11, + "origin": 22, + "name": "b", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/repeated_name/side_table.json b/internal/sql/preprocess/testdata/postgresql/repeated_name/side_table.json new file mode 100644 index 0000000000..4203a72bca --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/repeated_name/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 54, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 1, + "location": 41, + "origin": 50, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 51, + "origin": 69, + "name": "y", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_block_comment/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_block_comment/side_table.json new file mode 100644 index 0000000000..33fdd7c2f2 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_block_comment/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 39, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/side_table.json new file mode 100644 index 0000000000..d67323c3c8 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_dollar_quoted/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 77, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_escape_string/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_escape_string/side_table.json new file mode 100644 index 0000000000..7ab9eaedd2 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_escape_string/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 28, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/side_table.json new file mode 100644 index 0000000000..85b41b892c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_jsonb_operators/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 41, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_line_comment/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_line_comment/side_table.json new file mode 100644 index 0000000000..93bd6b47a1 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_line_comment/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 51, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/side_table.json new file mode 100644 index 0000000000..05701e1815 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_nested_block_comment/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 42, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/side_table.json new file mode 100644 index 0000000000..c279f2b3d7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_other_schemas/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 36, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/side_table.json new file mode 100644 index 0000000000..cdde9a6d54 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_quoted_identifier/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 31, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_string_literal/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_string_literal/side_table.json new file mode 100644 index 0000000000..06317d80bf --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_string_literal/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 30, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/side_table.json b/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/side_table.json new file mode 100644 index 0000000000..3bd689e974 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/skip_tagged_dollar_quoted/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 40, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/side_table.json b/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/side_table.json new file mode 100644 index 0000000000..f9350ea654 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/slice_is_an_array/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 40, + "dollar": true, + "params": [ + { + "number": 1, + "location": 36, + "origin": 36, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/side_table.json b/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/side_table.json new file mode 100644 index 0000000000..a3e7e0545b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/statement_terminator_in_string/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 15, + "dollar": true, + "params": [ + { + "number": 1, + "location": 12, + "origin": 12, + "name": "a", + "named": true + } + ] + }, + { + "start": 15, + "end": 26, + "dollar": true, + "params": [ + { + "number": 1, + "location": 23, + "origin": 32, + "name": "b", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/uppercase/side_table.json b/internal/sql/preprocess/testdata/postgresql/uppercase/side_table.json new file mode 100644 index 0000000000..80979ab73b --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/uppercase/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 10, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/arg/side_table.json b/internal/sql/preprocess/testdata/sqlite/arg/side_table.json new file mode 100644 index 0000000000..1e687873c6 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/arg/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 42, + "origin": 51, + "name": "y", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/at_sign/side_table.json b/internal/sql/preprocess/testdata/sqlite/at_sign/side_table.json new file mode 100644 index 0000000000..5b95487501 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/at_sign/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 37, + "dollar": true, + "params": [ + { + "number": 1, + "location": 34, + "origin": 34, + "name": "name", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/embed/side_table.json b/internal/sql/preprocess/testdata/sqlite/embed/side_table.json new file mode 100644 index 0000000000..2a2c4fc4ad --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/embed/side_table.json @@ -0,0 +1,24 @@ +[ + { + "start": 0, + "end": 40, + "dollar": true, + "params": [ + { + "number": 1, + "location": 37, + "origin": 47, + "name": "id", + "named": true + } + ], + "embeds": [ + { + "table": "users", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(users)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/input.sql b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/input.sql new file mode 100644 index 0000000000..0c98f3e515 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?1 AND b = ?; diff --git a/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/output.sql b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/output.sql new file mode 100644 index 0000000000..0c98f3e515 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ?1 AND b = ?; diff --git a/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/side_table.json b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/side_table.json new file mode 100644 index 0000000000..ae8f878474 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/side_table.json @@ -0,0 +1,22 @@ +[ + { + "start": 0, + "end": 44, + "dollar": false, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "named": false + }, + { + "number": 2, + "location": 42, + "origin": 42, + "named": false + } + ], + "param_error": "can not mix $1 format with ? format" + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/stderr.txt b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/stderr.txt new file mode 100644 index 0000000000..9d9039890f --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_mixed_placeholder_styles/stderr.txt @@ -0,0 +1 @@ +1:1: can not mix $1 format with ? format diff --git a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql index 7b71e48c46..838caa52c8 100644 --- a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql +++ b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/input.sql @@ -1 +1 @@ -SELECT id FROM users WHERE a = ?2 AND b = sqlc.arg(x); +SELECT id FROM users WHERE a = ?1 AND b = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql index 178138c99d..8d719f8cc6 100644 --- a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql +++ b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/output.sql @@ -1 +1 @@ -SELECT id FROM users WHERE a = ?2 AND b = ?1; +SELECT id FROM users WHERE a = ?1 AND b = ?2; diff --git a/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/side_table.json b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/side_table.json new file mode 100644 index 0000000000..8f0974672b --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/existing_numbered_placeholder/side_table.json @@ -0,0 +1,22 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "named": false + }, + { + "number": 2, + "location": 42, + "origin": 42, + "name": "x", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/repeated_name/side_table.json b/internal/sql/preprocess/testdata/sqlite/repeated_name/side_table.json new file mode 100644 index 0000000000..490774d5fd --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/repeated_name/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 45, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 1, + "location": 42, + "origin": 51, + "name": "x", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/slice/side_table.json b/internal/sql/preprocess/testdata/sqlite/slice/side_table.json new file mode 100644 index 0000000000..4b16794ab9 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/slice/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 50, + "dollar": true, + "params": [ + { + "number": 1, + "location": 47, + "origin": 34, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] From dc51324156c59a99164b8c6f34ea4736da13880c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 16:23:41 +0000 Subject: [PATCH 3/4] preprocess: cover every sqlc syntax shape used in internal/endtoend Audited every .sql file in internal/endtoend (and examples/), grouping each sqlc.* call by engine, function and argument shape, then diffed that inventory against the preprocessor's testdata. Seventeen shapes the corpus relies on had no golden. They all behaved correctly; they just were not pinned. The ones that exercise a distinct code path: - MySQL reads "x" as a string, so a double-quoted argument names the parameter and keeps its case, unlike a bare reference which is folded (coalesce_params/mysql, overrides_go_types/mysql) - $1 is not a placeholder in MySQL and is left alone (create_view/mysql) - "-- @sqlc-vet-disable" is a vet directive in a PostgreSQL query file, where @name *is* sqlc syntax; it survives only because the lexer never looks inside a comment (vet_disable) - PostgreSQL's @> operator next to a string holding a $-path, with a cast on the argument (table_function/postgresql) The rest fill in per-engine shapes: string-constant arguments for narg and slice on PostgreSQL and SQLite, narg against a bare reference on MySQL and SQLite, and the four invalid-call errors on MySQL and SQLite, where the reported text is dialect-specific ('got "?"' rather than 'got "$1"'). Also adds cases for the string-constant forms that had only one golden between them: case preservation against a folded bare reference, an escaped quote inside the name, and sqlc.embed('t'). 76 cases in total. Every sqlc syntax shape in the corpus now has a preprocessor test for its engine. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015BSM9xGoosq58r1Mz6B7YP --- internal/sql/preprocess/CLAUDE.md | 7 ++++ .../input.sql | 1 + .../output.sql | 1 + .../side_table.json | 7 ++++ .../mysql/double_quoted_argument/input.sql | 4 +++ .../mysql/double_quoted_argument/output.sql | 4 +++ .../double_quoted_argument/side_table.json | 31 ++++++++++++++++++ .../mysql/error_invalid_calls/input.sql | 11 +++++++ .../mysql/error_invalid_calls/output.sql | 11 +++++++ .../mysql/error_invalid_calls/side_table.json | 30 +++++++++++++++++ .../mysql/error_invalid_calls/stderr.txt | 4 +++ .../testdata/mysql/narg_identifier/input.sql | 1 + .../testdata/mysql/narg_identifier/output.sql | 1 + .../mysql/narg_identifier/side_table.json | 17 ++++++++++ .../mysql/slice_folds_identifier/input.sql | 1 + .../mysql/slice_folds_identifier/output.sql | 1 + .../slice_folds_identifier/side_table.json | 17 ++++++++++ .../testdata/mysql/string_constant/input.sql | 1 + .../testdata/mysql/string_constant/output.sql | 1 + .../mysql/string_constant/side_table.json | 32 +++++++++++++++++++ .../string_constant_keeps_case/input.sql | 1 + .../string_constant_keeps_case/output.sql | 1 + .../side_table.json | 23 +++++++++++++ .../at_sign_in_vet_directive/input.sql | 3 ++ .../at_sign_in_vet_directive/output.sql | 3 ++ .../at_sign_in_vet_directive/side_table.json | 16 ++++++++++ .../embed_string_constant/input.sql | 1 + .../embed_string_constant/output.sql | 1 + .../embed_string_constant/side_table.json | 15 +++++++++ .../jsonb_operator_and_path_string/input.sql | 2 ++ .../jsonb_operator_and_path_string/output.sql | 2 ++ .../side_table.json | 16 ++++++++++ .../postgresql/narg_string_constant/input.sql | 1 + .../narg_string_constant/output.sql | 1 + .../narg_string_constant/side_table.json | 17 ++++++++++ .../slice_string_constant/input.sql | 1 + .../slice_string_constant/output.sql | 1 + .../slice_string_constant/side_table.json | 17 ++++++++++ .../string_constant_escaped_quote/input.sql | 1 + .../string_constant_escaped_quote/output.sql | 1 + .../side_table.json | 16 ++++++++++ .../string_constant_keeps_case/input.sql | 1 + .../string_constant_keeps_case/output.sql | 1 + .../side_table.json | 23 +++++++++++++ .../sqlite/error_invalid_calls/input.sql | 11 +++++++ .../sqlite/error_invalid_calls/output.sql | 11 +++++++ .../error_invalid_calls/side_table.json | 30 +++++++++++++++++ .../sqlite/error_invalid_calls/stderr.txt | 4 +++ .../testdata/sqlite/narg_identifier/input.sql | 1 + .../sqlite/narg_identifier/output.sql | 1 + .../sqlite/narg_identifier/side_table.json | 17 ++++++++++ .../testdata/sqlite/string_constant/input.sql | 1 + .../sqlite/string_constant/output.sql | 1 + .../sqlite/string_constant/side_table.json | 32 +++++++++++++++++++ 54 files changed, 457 insertions(+) create mode 100644 internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/double_quoted_argument/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/double_quoted_argument/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/double_quoted_argument/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/error_invalid_calls/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/error_invalid_calls/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/error_invalid_calls/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/error_invalid_calls/stderr.txt create mode 100644 internal/sql/preprocess/testdata/mysql/narg_identifier/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/narg_identifier/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/narg_identifier/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/slice_folds_identifier/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/slice_folds_identifier/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/slice_folds_identifier/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/string_constant/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/string_constant/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/string_constant/side_table.json create mode 100644 internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/input.sql create mode 100644 internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/output.sql create mode 100644 internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_string_constant/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_string_constant/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/embed_string_constant/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/narg_string_constant/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/narg_string_constant/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/narg_string_constant/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/slice_string_constant/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/slice_string_constant/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/slice_string_constant/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/side_table.json create mode 100644 internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/input.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/output.sql create mode 100644 internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/error_invalid_calls/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/error_invalid_calls/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/error_invalid_calls/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/error_invalid_calls/stderr.txt create mode 100644 internal/sql/preprocess/testdata/sqlite/narg_identifier/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/narg_identifier/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/narg_identifier/side_table.json create mode 100644 internal/sql/preprocess/testdata/sqlite/string_constant/input.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/string_constant/output.sql create mode 100644 internal/sql/preprocess/testdata/sqlite/string_constant/side_table.json diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md index 225ab20003..890cb5efc9 100644 --- a/internal/sql/preprocess/CLAUDE.md +++ b/internal/sql/preprocess/CLAUDE.md @@ -95,6 +95,13 @@ Regenerate every golden with: go test ./internal/sql/preprocess -update ``` +The cases cover every shape of sqlc syntax that appears in +`internal/endtoend`, per engine — each function against a bare reference, a +string constant and a quoted identifier, the placeholder styles, and the +constructs that must be left alone (comments, literals, MySQL user variables +and `$1`, PostgreSQL's `@>` and `?` operators). When you add a shape to the +corpus, add it here too. + ## Adding a dialect Add an entry to `dialects` in dialect.go. Nothing else in the codebase needs to diff --git a/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/input.sql b/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/input.sql new file mode 100644 index 0000000000..d933db8127 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/input.sql @@ -0,0 +1 @@ +SELECT * FROM second_view WHERE val2 = $1; diff --git a/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/output.sql b/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/output.sql new file mode 100644 index 0000000000..d933db8127 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/output.sql @@ -0,0 +1 @@ +SELECT * FROM second_view WHERE val2 = $1; diff --git a/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/side_table.json b/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/side_table.json new file mode 100644 index 0000000000..05701e1815 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/dollar_number_is_not_a_placeholder/side_table.json @@ -0,0 +1,7 @@ +[ + { + "start": 0, + "end": 42, + "dollar": true + } +] diff --git a/internal/sql/preprocess/testdata/mysql/double_quoted_argument/input.sql b/internal/sql/preprocess/testdata/mysql/double_quoted_argument/input.sql new file mode 100644 index 0000000000..37f43141bf --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/double_quoted_argument/input.sql @@ -0,0 +1,4 @@ +SELECT + (CASE WHEN sqlc.arg("Timezone") = "calendar" THEN cal.Timezone ELSE sqlc.arg("Timezone") END), + COALESCE(sqlc.narg("calName"), "") +FROM Calendar cal; diff --git a/internal/sql/preprocess/testdata/mysql/double_quoted_argument/output.sql b/internal/sql/preprocess/testdata/mysql/double_quoted_argument/output.sql new file mode 100644 index 0000000000..201532c117 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/double_quoted_argument/output.sql @@ -0,0 +1,4 @@ +SELECT + (CASE WHEN ? = "calendar" THEN cal.Timezone ELSE ? END), + COALESCE(?, "") +FROM Calendar cal; diff --git a/internal/sql/preprocess/testdata/mysql/double_quoted_argument/side_table.json b/internal/sql/preprocess/testdata/mysql/double_quoted_argument/side_table.json new file mode 100644 index 0000000000..711a8833c2 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/double_quoted_argument/side_table.json @@ -0,0 +1,31 @@ +[ + { + "start": 0, + "end": 106, + "dollar": true, + "params": [ + { + "number": 1, + "location": 22, + "origin": 22, + "name": "Timezone", + "named": true + }, + { + "number": 2, + "location": 60, + "origin": 79, + "name": "Timezone", + "named": true + }, + { + "number": 3, + "location": 81, + "origin": 119, + "name": "calName", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/error_invalid_calls/input.sql b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/input.sql new file mode 100644 index 0000000000..51f7ddb8ee --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/input.sql @@ -0,0 +1,11 @@ +-- name: WrongFunc :one +SELECT id FROM users WHERE id = sqlc.argh(target_id); + +-- name: TooManyArgs :one +SELECT id FROM users WHERE id = sqlc.arg('foo', 'bar'); + +-- name: TooFewArgs :one +SELECT id FROM users WHERE id = sqlc.arg(); + +-- name: InvalidArgPlaceholder :one +SELECT id FROM users WHERE id = sqlc.arg(?); diff --git a/internal/sql/preprocess/testdata/mysql/error_invalid_calls/output.sql b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/output.sql new file mode 100644 index 0000000000..51f7ddb8ee --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/output.sql @@ -0,0 +1,11 @@ +-- name: WrongFunc :one +SELECT id FROM users WHERE id = sqlc.argh(target_id); + +-- name: TooManyArgs :one +SELECT id FROM users WHERE id = sqlc.arg('foo', 'bar'); + +-- name: TooFewArgs :one +SELECT id FROM users WHERE id = sqlc.arg(); + +-- name: InvalidArgPlaceholder :one +SELECT id FROM users WHERE id = sqlc.arg(?); diff --git a/internal/sql/preprocess/testdata/mysql/error_invalid_calls/side_table.json b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/side_table.json new file mode 100644 index 0000000000..a36ba15e41 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 77, + "dollar": true, + "error": "function \"sqlc.argh\" does not exist", + "error_at": 56 + }, + { + "start": 77, + "end": 160, + "dollar": true, + "error": "expected 1 parameter to sqlc.arg; got 2", + "error_at": 137 + }, + { + "start": 160, + "end": 230, + "dollar": true, + "error": "expected 1 parameter to sqlc.arg; got 0", + "error_at": 219 + }, + { + "start": 230, + "end": 312, + "dollar": true, + "error": "expected parameter to sqlc.arg to be string or reference; got \"?\"", + "error_at": 300 + } +] diff --git a/internal/sql/preprocess/testdata/mysql/error_invalid_calls/stderr.txt b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/stderr.txt new file mode 100644 index 0000000000..aae154ee46 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/error_invalid_calls/stderr.txt @@ -0,0 +1,4 @@ +2:33: function "sqlc.argh" does not exist +5:33: expected 1 parameter to sqlc.arg; got 2 +8:33: expected 1 parameter to sqlc.arg; got 0 +11:33: expected parameter to sqlc.arg to be string or reference; got "?" diff --git a/internal/sql/preprocess/testdata/mysql/narg_identifier/input.sql b/internal/sql/preprocess/testdata/mysql/narg_identifier/input.sql new file mode 100644 index 0000000000..c36f3d6aaa --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/narg_identifier/input.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE name = sqlc.narg(name); diff --git a/internal/sql/preprocess/testdata/mysql/narg_identifier/output.sql b/internal/sql/preprocess/testdata/mysql/narg_identifier/output.sql new file mode 100644 index 0000000000..e48f33043d --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/narg_identifier/output.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE name = ?; diff --git a/internal/sql/preprocess/testdata/mysql/narg_identifier/side_table.json b/internal/sql/preprocess/testdata/mysql/narg_identifier/side_table.json new file mode 100644 index 0000000000..c3b25fbcda --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/narg_identifier/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 35, + "dollar": true, + "params": [ + { + "number": 1, + "location": 33, + "origin": 33, + "name": "name", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/input.sql b/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/input.sql new file mode 100644 index 0000000000..3fdbab315d --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/input.sql @@ -0,0 +1 @@ +SELECT * FROM foo WHERE retyped IN (sqlc.slice(paramName)); diff --git a/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/output.sql b/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/output.sql new file mode 100644 index 0000000000..451d50ce5b --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/output.sql @@ -0,0 +1 @@ +SELECT * FROM foo WHERE retyped IN (/*SLICE:paramname*/?); diff --git a/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/side_table.json b/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/side_table.json new file mode 100644 index 0000000000..43154fe710 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/slice_folds_identifier/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 58, + "dollar": true, + "params": [ + { + "number": 1, + "location": 55, + "origin": 36, + "name": "paramname", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/string_constant/input.sql b/internal/sql/preprocess/testdata/mysql/string_constant/input.sql new file mode 100644 index 0000000000..21f5701a00 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/string_constant/input.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = sqlc.arg('x') AND b = sqlc.narg('y') AND c IN (sqlc.slice('ids')); diff --git a/internal/sql/preprocess/testdata/mysql/string_constant/output.sql b/internal/sql/preprocess/testdata/mysql/string_constant/output.sql new file mode 100644 index 0000000000..7dd7adcca6 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/string_constant/output.sql @@ -0,0 +1 @@ +SELECT id FROM users WHERE a = ? AND b = ? AND c IN (/*SLICE:ids*/?); diff --git a/internal/sql/preprocess/testdata/mysql/string_constant/side_table.json b/internal/sql/preprocess/testdata/mysql/string_constant/side_table.json new file mode 100644 index 0000000000..45ffcd24b8 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/string_constant/side_table.json @@ -0,0 +1,32 @@ +[ + { + "start": 0, + "end": 69, + "dollar": true, + "params": [ + { + "number": 1, + "location": 31, + "origin": 31, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 41, + "origin": 53, + "name": "y", + "named": true, + "nullable": true + }, + { + "number": 3, + "location": 66, + "origin": 78, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/input.sql b/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/input.sql new file mode 100644 index 0000000000..beec84e61c --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg('MixedCase'), sqlc.arg(MixedCase); diff --git a/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/output.sql b/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/output.sql new file mode 100644 index 0000000000..99dcdde756 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/output.sql @@ -0,0 +1 @@ +SELECT ?, ?; diff --git a/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/side_table.json b/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/side_table.json new file mode 100644 index 0000000000..16a138bfc4 --- /dev/null +++ b/internal/sql/preprocess/testdata/mysql/string_constant_keeps_case/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 12, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "MixedCase", + "named": true + }, + { + "number": 2, + "location": 10, + "origin": 30, + "name": "mixedcase", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/input.sql b/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/input.sql new file mode 100644 index 0000000000..d06af4e9eb --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/input.sql @@ -0,0 +1,3 @@ +-- name: SkipVetAll :exec +-- @sqlc-vet-disable always-fail no-exec +SELECT true FROM users WHERE id = sqlc.arg(id); diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/output.sql b/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/output.sql new file mode 100644 index 0000000000..5bbca3dd62 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/output.sql @@ -0,0 +1,3 @@ +-- name: SkipVetAll :exec +-- @sqlc-vet-disable always-fail no-exec +SELECT true FROM users WHERE id = $1; diff --git a/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/side_table.json b/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/side_table.json new file mode 100644 index 0000000000..3430150bb0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/at_sign_in_vet_directive/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 104, + "dollar": true, + "params": [ + { + "number": 1, + "location": 101, + "origin": 101, + "name": "id", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/embed_string_constant/input.sql b/internal/sql/preprocess/testdata/postgresql/embed_string_constant/input.sql new file mode 100644 index 0000000000..77203a9818 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_string_constant/input.sql @@ -0,0 +1 @@ +SELECT sqlc.embed('t') FROM t; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_string_constant/output.sql b/internal/sql/preprocess/testdata/postgresql/embed_string_constant/output.sql new file mode 100644 index 0000000000..269c8bb8c7 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_string_constant/output.sql @@ -0,0 +1 @@ +SELECT t.* FROM t; diff --git a/internal/sql/preprocess/testdata/postgresql/embed_string_constant/side_table.json b/internal/sql/preprocess/testdata/postgresql/embed_string_constant/side_table.json new file mode 100644 index 0000000000..b7b97cfaf3 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/embed_string_constant/side_table.json @@ -0,0 +1,15 @@ +[ + { + "start": 0, + "end": 18, + "dollar": true, + "embeds": [ + { + "table": "t", + "location": 7, + "origin": 7, + "orig": "sqlc.embed(t)" + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/input.sql b/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/input.sql new file mode 100644 index 0000000000..523e2d7ef0 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/input.sql @@ -0,0 +1,2 @@ +SELECT * FROM transactions +WHERE jsonb_extract_path(transactions.data, '$.transaction.signatures[0]') @> to_jsonb(sqlc.arg('data')::text); diff --git a/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/output.sql b/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/output.sql new file mode 100644 index 0000000000..acc0a99dc5 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/output.sql @@ -0,0 +1,2 @@ +SELECT * FROM transactions +WHERE jsonb_extract_path(transactions.data, '$.transaction.signatures[0]') @> to_jsonb($1::text); diff --git a/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/side_table.json b/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/side_table.json new file mode 100644 index 0000000000..36f37fe0bb --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/jsonb_operator_and_path_string/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 124, + "dollar": true, + "params": [ + { + "number": 1, + "location": 114, + "origin": 114, + "name": "data", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/narg_string_constant/input.sql b/internal/sql/preprocess/testdata/postgresql/narg_string_constant/input.sql new file mode 100644 index 0000000000..1563e71033 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/narg_string_constant/input.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE name = COALESCE(sqlc.narg('name'), name); diff --git a/internal/sql/preprocess/testdata/postgresql/narg_string_constant/output.sql b/internal/sql/preprocess/testdata/postgresql/narg_string_constant/output.sql new file mode 100644 index 0000000000..50612d0580 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/narg_string_constant/output.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE name = COALESCE($1, name); diff --git a/internal/sql/preprocess/testdata/postgresql/narg_string_constant/side_table.json b/internal/sql/preprocess/testdata/postgresql/narg_string_constant/side_table.json new file mode 100644 index 0000000000..a076148449 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/narg_string_constant/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 52, + "dollar": true, + "params": [ + { + "number": 1, + "location": 42, + "origin": 42, + "name": "name", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/slice_string_constant/input.sql b/internal/sql/preprocess/testdata/postgresql/slice_string_constant/input.sql new file mode 100644 index 0000000000..3132a9fa60 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/slice_string_constant/input.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE id = ANY(sqlc.slice('ids')); diff --git a/internal/sql/preprocess/testdata/postgresql/slice_string_constant/output.sql b/internal/sql/preprocess/testdata/postgresql/slice_string_constant/output.sql new file mode 100644 index 0000000000..bd7ba1f310 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/slice_string_constant/output.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE id = ANY($1); diff --git a/internal/sql/preprocess/testdata/postgresql/slice_string_constant/side_table.json b/internal/sql/preprocess/testdata/postgresql/slice_string_constant/side_table.json new file mode 100644 index 0000000000..d7f67eea34 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/slice_string_constant/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 39, + "dollar": true, + "params": [ + { + "number": 1, + "location": 35, + "origin": 35, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/input.sql b/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/input.sql new file mode 100644 index 0000000000..1643d3792c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg('it''s'); diff --git a/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/output.sql b/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/output.sql new file mode 100644 index 0000000000..37699f7c74 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/output.sql @@ -0,0 +1 @@ +SELECT $1; diff --git a/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/side_table.json b/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/side_table.json new file mode 100644 index 0000000000..5c7870733f --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/string_constant_escaped_quote/side_table.json @@ -0,0 +1,16 @@ +[ + { + "start": 0, + "end": 10, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "it's", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/input.sql b/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/input.sql new file mode 100644 index 0000000000..beec84e61c --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/input.sql @@ -0,0 +1 @@ +SELECT sqlc.arg('MixedCase'), sqlc.arg(MixedCase); diff --git a/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/output.sql b/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/output.sql new file mode 100644 index 0000000000..ca9e5428bf --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/output.sql @@ -0,0 +1 @@ +SELECT $1, $2; diff --git a/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/side_table.json b/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/side_table.json new file mode 100644 index 0000000000..48ccbea4b5 --- /dev/null +++ b/internal/sql/preprocess/testdata/postgresql/string_constant_keeps_case/side_table.json @@ -0,0 +1,23 @@ +[ + { + "start": 0, + "end": 14, + "dollar": true, + "params": [ + { + "number": 1, + "location": 7, + "origin": 7, + "name": "MixedCase", + "named": true + }, + { + "number": 2, + "location": 11, + "origin": 30, + "name": "mixedcase", + "named": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/input.sql b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/input.sql new file mode 100644 index 0000000000..51f7ddb8ee --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/input.sql @@ -0,0 +1,11 @@ +-- name: WrongFunc :one +SELECT id FROM users WHERE id = sqlc.argh(target_id); + +-- name: TooManyArgs :one +SELECT id FROM users WHERE id = sqlc.arg('foo', 'bar'); + +-- name: TooFewArgs :one +SELECT id FROM users WHERE id = sqlc.arg(); + +-- name: InvalidArgPlaceholder :one +SELECT id FROM users WHERE id = sqlc.arg(?); diff --git a/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/output.sql b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/output.sql new file mode 100644 index 0000000000..51f7ddb8ee --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/output.sql @@ -0,0 +1,11 @@ +-- name: WrongFunc :one +SELECT id FROM users WHERE id = sqlc.argh(target_id); + +-- name: TooManyArgs :one +SELECT id FROM users WHERE id = sqlc.arg('foo', 'bar'); + +-- name: TooFewArgs :one +SELECT id FROM users WHERE id = sqlc.arg(); + +-- name: InvalidArgPlaceholder :one +SELECT id FROM users WHERE id = sqlc.arg(?); diff --git a/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/side_table.json b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/side_table.json new file mode 100644 index 0000000000..a36ba15e41 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/side_table.json @@ -0,0 +1,30 @@ +[ + { + "start": 0, + "end": 77, + "dollar": true, + "error": "function \"sqlc.argh\" does not exist", + "error_at": 56 + }, + { + "start": 77, + "end": 160, + "dollar": true, + "error": "expected 1 parameter to sqlc.arg; got 2", + "error_at": 137 + }, + { + "start": 160, + "end": 230, + "dollar": true, + "error": "expected 1 parameter to sqlc.arg; got 0", + "error_at": 219 + }, + { + "start": 230, + "end": 312, + "dollar": true, + "error": "expected parameter to sqlc.arg to be string or reference; got \"?\"", + "error_at": 300 + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/stderr.txt b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/stderr.txt new file mode 100644 index 0000000000..aae154ee46 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/error_invalid_calls/stderr.txt @@ -0,0 +1,4 @@ +2:33: function "sqlc.argh" does not exist +5:33: expected 1 parameter to sqlc.arg; got 2 +8:33: expected 1 parameter to sqlc.arg; got 0 +11:33: expected parameter to sqlc.arg to be string or reference; got "?" diff --git a/internal/sql/preprocess/testdata/sqlite/narg_identifier/input.sql b/internal/sql/preprocess/testdata/sqlite/narg_identifier/input.sql new file mode 100644 index 0000000000..c36f3d6aaa --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/narg_identifier/input.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE name = sqlc.narg(name); diff --git a/internal/sql/preprocess/testdata/sqlite/narg_identifier/output.sql b/internal/sql/preprocess/testdata/sqlite/narg_identifier/output.sql new file mode 100644 index 0000000000..58a07d1185 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/narg_identifier/output.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE name = ?1; diff --git a/internal/sql/preprocess/testdata/sqlite/narg_identifier/side_table.json b/internal/sql/preprocess/testdata/sqlite/narg_identifier/side_table.json new file mode 100644 index 0000000000..dfa9deb94a --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/narg_identifier/side_table.json @@ -0,0 +1,17 @@ +[ + { + "start": 0, + "end": 36, + "dollar": true, + "params": [ + { + "number": 1, + "location": 33, + "origin": 33, + "name": "name", + "named": true, + "nullable": true + } + ] + } +] diff --git a/internal/sql/preprocess/testdata/sqlite/string_constant/input.sql b/internal/sql/preprocess/testdata/sqlite/string_constant/input.sql new file mode 100644 index 0000000000..3efd6a9c11 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/string_constant/input.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE a = sqlc.arg('x') AND b = sqlc.narg('y') AND c IN (sqlc.slice('ids')); diff --git a/internal/sql/preprocess/testdata/sqlite/string_constant/output.sql b/internal/sql/preprocess/testdata/sqlite/string_constant/output.sql new file mode 100644 index 0000000000..ea33ad30e7 --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/string_constant/output.sql @@ -0,0 +1 @@ +SELECT * FROM users WHERE a = ?1 AND b = ?2 AND c IN (/*SLICE:ids*/?); diff --git a/internal/sql/preprocess/testdata/sqlite/string_constant/side_table.json b/internal/sql/preprocess/testdata/sqlite/string_constant/side_table.json new file mode 100644 index 0000000000..f48044c3bc --- /dev/null +++ b/internal/sql/preprocess/testdata/sqlite/string_constant/side_table.json @@ -0,0 +1,32 @@ +[ + { + "start": 0, + "end": 70, + "dollar": true, + "params": [ + { + "number": 1, + "location": 30, + "origin": 30, + "name": "x", + "named": true + }, + { + "number": 2, + "location": 41, + "origin": 52, + "name": "y", + "named": true, + "nullable": true + }, + { + "number": 3, + "location": 67, + "origin": 77, + "name": "ids", + "named": true, + "slice": true + } + ] + } +] From a624f1543a8647532d9aa1c33fb0dbd835541a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 16:31:13 +0000 Subject: [PATCH 4/4] preprocess: do not rewrite sqlc syntax for GoogleSQL or ClickHouse Both engines are getting their own parameter system, so they should not pick up sqlc.arg()/narg()/slice()/embed() by default just because the preprocessor happens to be dialect-driven. Drop their entries from the dialect table. An engine with no entry is now passed through untouched: File returns the source unchanged with an empty side table, so sqlc.arg() reaches the parser as the function call it looks like and fails there. Leaving an engine out of the table is the switch, and dialect.go says so. GoogleSQL still converts "@name" to a ParamRef. That is native GoogleSQL syntax rather than sqlc syntax, and the AST rewriter that used to turn it into one is gone, so the converter has to. Parameter names now come from the column the parameter is compared against, which is what every GoogleSQL golden already expected. The ClickHouse operator work stays, retargeted off sqlc syntax: A_Expr_Kind_OP on binary and unary operators plus the seeded comparison and arithmetic operators are what let the analysis core type a WHERE clause at all, and analyze_params/clickhouse now covers them with native "?" placeholders. Preprocessor testdata drops to 69 cases across the three engines that are actually preprocessed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015BSM9xGoosq58r1Mz6B7YP --- internal/compiler/compile.go | 6 +---- internal/core/analyzer/bench_test.go | 13 +++------- .../clickhouse/exec.json | 0 .../analyze_params/clickhouse/query.sql | 5 ++++ .../clickhouse/schema.sql | 0 .../clickhouse/stdout.txt | 6 ++--- .../analyze_sqlc_args/clickhouse/query.sql | 5 ---- internal/engine/googlesql/convert.go | 10 +++---- internal/sql/preprocess/CLAUDE.md | 26 ++++++++++++------- internal/sql/preprocess/dialect.go | 26 +++++-------------- internal/sql/preprocess/preprocess.go | 20 +++++++------- internal/sql/preprocess/preprocess_test.go | 5 +--- .../testdata/clickhouse/arg/input.sql | 1 - .../testdata/clickhouse/arg/output.sql | 1 - .../testdata/clickhouse/arg/side_table.json | 24 ----------------- .../at_sign_is_not_a_parameter/input.sql | 1 - .../at_sign_is_not_a_parameter/output.sql | 1 - .../side_table.json | 16 ------------ .../testdata/clickhouse/embed/input.sql | 1 - .../testdata/clickhouse/embed/output.sql | 1 - .../testdata/clickhouse/embed/side_table.json | 15 ----------- .../testdata/clickhouse/slice/input.sql | 1 - .../testdata/clickhouse/slice/output.sql | 1 - .../testdata/clickhouse/slice/side_table.json | 17 ------------ .../arg_keeps_named_parameters/input.sql | 1 - .../arg_keeps_named_parameters/output.sql | 1 - .../side_table.json | 23 ---------------- .../testdata/googlesql/embed/input.sql | 1 - .../testdata/googlesql/embed/output.sql | 1 - .../testdata/googlesql/embed/side_table.json | 15 ----------- .../testdata/googlesql/slice/input.sql | 1 - .../testdata/googlesql/slice/output.sql | 1 - .../testdata/googlesql/slice/side_table.json | 17 ------------ 33 files changed, 50 insertions(+), 213 deletions(-) rename internal/endtoend/testdata/{analyze_sqlc_args => analyze_params}/clickhouse/exec.json (100%) create mode 100644 internal/endtoend/testdata/analyze_params/clickhouse/query.sql rename internal/endtoend/testdata/{analyze_sqlc_args => analyze_params}/clickhouse/schema.sql (100%) rename internal/endtoend/testdata/{analyze_sqlc_args => analyze_params}/clickhouse/stdout.txt (93%) delete mode 100644 internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/arg/input.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/arg/output.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/arg/side_table.json delete mode 100644 internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json delete mode 100644 internal/sql/preprocess/testdata/clickhouse/embed/input.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/embed/output.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/embed/side_table.json delete mode 100644 internal/sql/preprocess/testdata/clickhouse/slice/input.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/slice/output.sql delete mode 100644 internal/sql/preprocess/testdata/clickhouse/slice/side_table.json delete mode 100644 internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql delete mode 100644 internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql delete mode 100644 internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json delete mode 100644 internal/sql/preprocess/testdata/googlesql/embed/input.sql delete mode 100644 internal/sql/preprocess/testdata/googlesql/embed/output.sql delete mode 100644 internal/sql/preprocess/testdata/googlesql/embed/side_table.json delete mode 100644 internal/sql/preprocess/testdata/googlesql/slice/input.sql delete mode 100644 internal/sql/preprocess/testdata/googlesql/slice/output.sql delete mode 100644 internal/sql/preprocess/testdata/googlesql/slice/side_table.json diff --git a/internal/compiler/compile.go b/internal/compiler/compile.go index 3db4b05d3a..11e67f91fb 100644 --- a/internal/compiler/compile.go +++ b/internal/compiler/compile.go @@ -107,11 +107,7 @@ func (c *Compiler) parseQueries(o opts.Parser) (*Result, error) { // sqlc syntax is not SQL. Rewrite it to native placeholders before the // engine parser ever sees the query, so parsers only handle SQL. - pp, err := preprocess.File(c.conf.Engine, src) - if err != nil { - merr.Add(filename, src, 0, err) - continue - } + pp := preprocess.File(c.conf.Engine, src) stmts, err := c.parser.Parse(strings.NewReader(pp.Text)) if err != nil { diff --git a/internal/core/analyzer/bench_test.go b/internal/core/analyzer/bench_test.go index d2846a0419..8d6bc6a478 100644 --- a/internal/core/analyzer/bench_test.go +++ b/internal/core/analyzer/bench_test.go @@ -4,13 +4,11 @@ import ( "strings" "testing" - "github.com/sqlc-dev/sqlc/internal/config" "github.com/sqlc-dev/sqlc/internal/core" "github.com/sqlc-dev/sqlc/internal/core/analyzer" coreschema "github.com/sqlc-dev/sqlc/internal/core/schema" "github.com/sqlc-dev/sqlc/internal/engine/googlesql" "github.com/sqlc-dev/sqlc/internal/sql/ast" - "github.com/sqlc-dev/sqlc/internal/sql/preprocess" ) const benchSchema = ` @@ -49,14 +47,11 @@ func parseAll(t testing.TB, src string) []ast.Node { return out } -// parseQueries mirrors the compiler pipeline: rewrite sqlc syntax to native SQL, -// then parse. +// parseQueries mirrors the compiler pipeline. GoogleSQL is not preprocessed — +// "@name" is native syntax the converter turns into a ParamRef — so parsing is +// all it takes. func parseQueries(t testing.TB, src string) []ast.Node { - res, err := preprocess.File(config.EngineGoogleSQL, src) - if err != nil { - t.Fatal(err) - } - return parseAll(t, res.Text) + return parseAll(t, src) } func newBenchCatalog(t testing.TB) (*core.Catalog, []ast.Node) { diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/exec.json b/internal/endtoend/testdata/analyze_params/clickhouse/exec.json similarity index 100% rename from internal/endtoend/testdata/analyze_sqlc_args/clickhouse/exec.json rename to internal/endtoend/testdata/analyze_params/clickhouse/exec.json diff --git a/internal/endtoend/testdata/analyze_params/clickhouse/query.sql b/internal/endtoend/testdata/analyze_params/clickhouse/query.sql new file mode 100644 index 0000000000..5405a5baa8 --- /dev/null +++ b/internal/endtoend/testdata/analyze_params/clickhouse/query.sql @@ -0,0 +1,5 @@ +-- name: GetEvent :one +SELECT id, name FROM events WHERE id = ?; + +-- name: FilterEvents :many +SELECT id, name FROM events WHERE name = ? AND amount > ?; diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/schema.sql b/internal/endtoend/testdata/analyze_params/clickhouse/schema.sql similarity index 100% rename from internal/endtoend/testdata/analyze_sqlc_args/clickhouse/schema.sql rename to internal/endtoend/testdata/analyze_params/clickhouse/schema.sql diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt b/internal/endtoend/testdata/analyze_params/clickhouse/stdout.txt similarity index 93% rename from internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt rename to internal/endtoend/testdata/analyze_params/clickhouse/stdout.txt index d01537f993..6fb0cc654b 100644 --- a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/stdout.txt +++ b/internal/endtoend/testdata/analyze_params/clickhouse/stdout.txt @@ -22,7 +22,7 @@ { "number": 1, "column": { - "name": "event_id", + "name": "id", "data_type": "uint64", "not_null": true, "is_array": false, @@ -64,9 +64,9 @@ { "number": 2, "column": { - "name": "min_amount", + "name": "amount", "data_type": "float64", - "not_null": false, + "not_null": true, "is_array": false, "table": "events" } diff --git a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql b/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql deleted file mode 100644 index 19ea7c7c43..0000000000 --- a/internal/endtoend/testdata/analyze_sqlc_args/clickhouse/query.sql +++ /dev/null @@ -1,5 +0,0 @@ --- name: GetEvent :one -SELECT id, name FROM events WHERE id = sqlc.arg(event_id); - --- name: FilterEvents :many -SELECT id, name FROM events WHERE name = sqlc.arg(name) AND amount > sqlc.narg(min_amount); diff --git a/internal/engine/googlesql/convert.go b/internal/engine/googlesql/convert.go index b9d75062ea..e94304096f 100644 --- a/internal/engine/googlesql/convert.go +++ b/internal/engine/googlesql/convert.go @@ -514,11 +514,11 @@ func (c *cc) convertIntLiteral(n *zjast.IntLiteral) ast.Node { // convertParameterExpr converts "@name" and "?" query parameters. // -// Both become ParamRef nodes. GoogleSQL names its parameters, so repeated uses -// of a name share a number; positional parameters are numbered by their 1-based -// position in the statement. The compiler replaces these numbers with the ones -// the preprocessor assigned in source order, so they only matter when a -// statement is converted on its own. +// Both become ParamRef nodes. "@name" is native GoogleSQL syntax rather than +// sqlc syntax, so nothing rewrites it before the parser runs and the numbering +// here is what the rest of the pipeline sees: repeated uses of a name share a +// number, and positional parameters are numbered by their 1-based position in +// the statement. func (c *cc) convertParameterExpr(n *zjast.ParameterExpr) ast.Node { if n.Name != nil { number, ok := c.namedParams[n.Name.Name] diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md index 890cb5efc9..a7f4d9aafe 100644 --- a/internal/sql/preprocess/CLAUDE.md +++ b/internal/sql/preprocess/CLAUDE.md @@ -20,8 +20,11 @@ Native placeholders by dialect: | postgresql | `$1` | sqlc syntax | | mysql | `?` | user variable, left alone | | sqlite | `?1` | sqlc syntax | -| googlesql | `@name` | native, left in place | -| clickhouse | `?` | not a parameter | + +GoogleSQL and ClickHouse are deliberately absent. They handle their own +parameter syntax, so `File` returns their source unchanged and sqlc syntax is +not available to them — `sqlc.arg()` reaches the parser as the function call it +looks like. ## How it works @@ -31,8 +34,9 @@ comments (`--`, `/* */`, `#`), string literals, quoted identifiers, backticks and PostgreSQL dollar-quoted strings. `Dialect` (dialect.go) is the single place those lexical rules live. -`File(engine, src)` walks the whole file, splits it at top-level semicolons and, -for each statement: +`File(engine, src)` looks up the dialect; an engine with no entry gets its +source back untouched. Otherwise it walks the whole file, splits it at +top-level semicolons and, for each statement: 1. `scan` collects every sqlc construct **and** every native placeholder, in source order. @@ -96,13 +100,15 @@ go test ./internal/sql/preprocess -update ``` The cases cover every shape of sqlc syntax that appears in -`internal/endtoend`, per engine — each function against a bare reference, a -string constant and a quoted identifier, the placeholder styles, and the -constructs that must be left alone (comments, literals, MySQL user variables -and `$1`, PostgreSQL's `@>` and `?` operators). When you add a shape to the -corpus, add it here too. +`internal/endtoend`, per preprocessed engine — each function against a bare +reference, a string constant and a quoted identifier, the placeholder styles, +and the constructs that must be left alone (comments, literals, MySQL user +variables and `$1`, PostgreSQL's `@>` and `?` operators). When you add a shape +to the corpus, add it here too. ## Adding a dialect Add an entry to `dialects` in dialect.go. Nothing else in the codebase needs to -change for `sqlc.arg`/`narg`/`slice`/`embed` to work for a new engine. +change for `sqlc.arg`/`narg`/`slice`/`embed` to work for a new engine — but only +add one if the engine should support sqlc syntax at all. Leaving an engine out +is a deliberate choice, not an oversight. diff --git a/internal/sql/preprocess/dialect.go b/internal/sql/preprocess/dialect.go index 25f54206c8..29340bb1f4 100644 --- a/internal/sql/preprocess/dialect.go +++ b/internal/sql/preprocess/dialect.go @@ -10,12 +10,10 @@ type Style int const ( // StyleDollar numbers parameters as $1, $2, ... (PostgreSQL) StyleDollar Style = iota - // StyleQuestion uses an unnumbered ? for every parameter (MySQL, ClickHouse) + // StyleQuestion uses an unnumbered ? for every parameter (MySQL) StyleQuestion // StyleOrdinal numbers parameters as ?1, ?2, ... (SQLite) StyleOrdinal - // StyleAtName keeps parameters named as @name (GoogleSQL) - StyleAtName ) // Dialect describes the lexical rules the preprocessor needs to walk a query @@ -90,31 +88,19 @@ var dialects = map[config.Engine]Dialect{ Backtick: true, Backslash: true, }, - config.EngineGoogleSQL: { - Style: StyleAtName, - AtSign: true, - Question: true, - Backtick: true, - Backslash: true, - }, - config.EngineClickHouse: { - Style: StyleQuestion, - Question: true, - Backtick: true, - HashComment: true, - Backslash: true, - }, } -// DialectFor returns the lexical rules for an engine. +// DialectFor returns the lexical rules for an engine, and whether the engine is +// preprocessed at all. GoogleSQL and ClickHouse are not: they handle their own +// parameter syntax, so their queries reach the parser unchanged. func DialectFor(engine config.Engine) (Dialect, bool) { d, ok := dialects[engine] return d, ok } // hasNamedSupport reports whether repeated uses of the same parameter name can -// share a single placeholder. MySQL and ClickHouse send an argument per "?", so -// every occurrence needs its own number. +// share a single placeholder. MySQL sends an argument per "?", so every +// occurrence needs its own number. func (d Dialect) hasNamedSupport() bool { return d.Style != StyleQuestion } diff --git a/internal/sql/preprocess/preprocess.go b/internal/sql/preprocess/preprocess.go index c2db464715..76501cb60a 100644 --- a/internal/sql/preprocess/preprocess.go +++ b/internal/sql/preprocess/preprocess.go @@ -165,16 +165,21 @@ type occurrence struct { } // File rewrites every sqlc construct in src to native SQL for the given engine. -func File(engine config.Engine, src string) (*Result, error) { +// +// Engines that are not preprocessed — GoogleSQL and ClickHouse, which handle +// their own parameter syntax — get the source back unchanged, with an empty +// side table. sqlc.arg() and friends are not rewritten for them, so they reach +// the parser as the function calls they look like. +func File(engine config.Engine, src string) *Result { d, ok := DialectFor(engine) if !ok { - return nil, fmt.Errorf("preprocess: unknown engine: %s", engine) + return &Result{Text: src} } return Dialected(d, src) } // Dialected rewrites src using an explicit dialect. -func Dialected(d Dialect, src string) (*Result, error) { +func Dialected(d Dialect, src string) *Result { l := &lexer{d: d, src: src} res := &Result{} @@ -257,7 +262,7 @@ func Dialected(d Dialect, src string) (*Result, error) { } res.Text = b.String() - return res, nil + return res } func firstError(occs []occurrence) *sqlerr.Error { @@ -372,13 +377,6 @@ func (d Dialect) replacement(occ *occurrence) string { return occ.ident } switch d.Style { - case StyleAtName: - // GoogleSQL names its parameters, so "@name" is already native and a - // user-written one is rewritten to itself. - if occ.kind == kindSlice { - return fmt.Sprintf("/*SLICE:%s*/@%s", occ.name, occ.name) - } - return "@" + occ.name case StyleDollar: // PostgreSQL passes a slice as a single array parameter, so it needs no // expansion marker. diff --git a/internal/sql/preprocess/preprocess_test.go b/internal/sql/preprocess/preprocess_test.go index 93fd2bd9ed..5b0eb3e0aa 100644 --- a/internal/sql/preprocess/preprocess_test.go +++ b/internal/sql/preprocess/preprocess_test.go @@ -37,10 +37,7 @@ func TestRewrite(t *testing.T) { engine := config.Engine(filepath.Dir(dir)) input := readFile(t, filepath.Join(path, "input.sql")) - res, err := preprocess.File(engine, input) - if err != nil { - t.Fatalf("preprocess.File: %s", err) - } + res := preprocess.File(engine, input) compare(t, filepath.Join(path, "output.sql"), res.Text) compare(t, filepath.Join(path, "side_table.json"), sideTable(res)) diff --git a/internal/sql/preprocess/testdata/clickhouse/arg/input.sql b/internal/sql/preprocess/testdata/clickhouse/arg/input.sql deleted file mode 100644 index c689beee0c..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/arg/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE a = sqlc.arg(x) AND b = sqlc.narg(y); diff --git a/internal/sql/preprocess/testdata/clickhouse/arg/output.sql b/internal/sql/preprocess/testdata/clickhouse/arg/output.sql deleted file mode 100644 index 0091b6db39..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/arg/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE a = ? AND b = ?; diff --git a/internal/sql/preprocess/testdata/clickhouse/arg/side_table.json b/internal/sql/preprocess/testdata/clickhouse/arg/side_table.json deleted file mode 100644 index 2eea42e53c..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/arg/side_table.json +++ /dev/null @@ -1,24 +0,0 @@ -[ - { - "start": 0, - "end": 43, - "dollar": true, - "params": [ - { - "number": 1, - "location": 31, - "origin": 31, - "name": "x", - "named": true - }, - { - "number": 2, - "location": 41, - "origin": 51, - "name": "y", - "named": true, - "nullable": true - } - ] - } -] diff --git a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql deleted file mode 100644 index 3f63649481..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE a = sqlc.arg(x); diff --git a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql deleted file mode 100644 index e852e1de4a..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE a = ?; diff --git a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json b/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json deleted file mode 100644 index e773758f6d..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/at_sign_is_not_a_parameter/side_table.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "start": 0, - "end": 33, - "dollar": true, - "params": [ - { - "number": 1, - "location": 31, - "origin": 31, - "name": "x", - "named": true - } - ] - } -] diff --git a/internal/sql/preprocess/testdata/clickhouse/embed/input.sql b/internal/sql/preprocess/testdata/clickhouse/embed/input.sql deleted file mode 100644 index bb5188a008..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/embed/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT sqlc.embed(users) FROM users; diff --git a/internal/sql/preprocess/testdata/clickhouse/embed/output.sql b/internal/sql/preprocess/testdata/clickhouse/embed/output.sql deleted file mode 100644 index e9d0968e18..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/embed/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT users.* FROM users; diff --git a/internal/sql/preprocess/testdata/clickhouse/embed/side_table.json b/internal/sql/preprocess/testdata/clickhouse/embed/side_table.json deleted file mode 100644 index 840d08c39d..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/embed/side_table.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "start": 0, - "end": 26, - "dollar": true, - "embeds": [ - { - "table": "users", - "location": 7, - "origin": 7, - "orig": "sqlc.embed(users)" - } - ] - } -] diff --git a/internal/sql/preprocess/testdata/clickhouse/slice/input.sql b/internal/sql/preprocess/testdata/clickhouse/slice/input.sql deleted file mode 100644 index f5d89640d7..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/slice/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE id IN (sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/clickhouse/slice/output.sql b/internal/sql/preprocess/testdata/clickhouse/slice/output.sql deleted file mode 100644 index 542215158c..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/slice/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE id IN (/*SLICE:ids*/?); diff --git a/internal/sql/preprocess/testdata/clickhouse/slice/side_table.json b/internal/sql/preprocess/testdata/clickhouse/slice/side_table.json deleted file mode 100644 index 4b16794ab9..0000000000 --- a/internal/sql/preprocess/testdata/clickhouse/slice/side_table.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "start": 0, - "end": 50, - "dollar": true, - "params": [ - { - "number": 1, - "location": 47, - "origin": 34, - "name": "ids", - "named": true, - "slice": true - } - ] - } -] diff --git a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql deleted file mode 100644 index 62196060b8..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE a = sqlc.arg(x) AND b = @y; diff --git a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql deleted file mode 100644 index 9dd09b1637..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE a = @x AND b = @y; diff --git a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json b/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json deleted file mode 100644 index 1e687873c6..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/arg_keeps_named_parameters/side_table.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "start": 0, - "end": 45, - "dollar": true, - "params": [ - { - "number": 1, - "location": 31, - "origin": 31, - "name": "x", - "named": true - }, - { - "number": 2, - "location": 42, - "origin": 51, - "name": "y", - "named": true - } - ] - } -] diff --git a/internal/sql/preprocess/testdata/googlesql/embed/input.sql b/internal/sql/preprocess/testdata/googlesql/embed/input.sql deleted file mode 100644 index bb5188a008..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/embed/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT sqlc.embed(users) FROM users; diff --git a/internal/sql/preprocess/testdata/googlesql/embed/output.sql b/internal/sql/preprocess/testdata/googlesql/embed/output.sql deleted file mode 100644 index e9d0968e18..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/embed/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT users.* FROM users; diff --git a/internal/sql/preprocess/testdata/googlesql/embed/side_table.json b/internal/sql/preprocess/testdata/googlesql/embed/side_table.json deleted file mode 100644 index 840d08c39d..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/embed/side_table.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "start": 0, - "end": 26, - "dollar": true, - "embeds": [ - { - "table": "users", - "location": 7, - "origin": 7, - "orig": "sqlc.embed(users)" - } - ] - } -] diff --git a/internal/sql/preprocess/testdata/googlesql/slice/input.sql b/internal/sql/preprocess/testdata/googlesql/slice/input.sql deleted file mode 100644 index f5d89640d7..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/slice/input.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE id IN (sqlc.slice(ids)); diff --git a/internal/sql/preprocess/testdata/googlesql/slice/output.sql b/internal/sql/preprocess/testdata/googlesql/slice/output.sql deleted file mode 100644 index 06d0233ae4..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/slice/output.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT id FROM users WHERE id IN (/*SLICE:ids*/@ids); diff --git a/internal/sql/preprocess/testdata/googlesql/slice/side_table.json b/internal/sql/preprocess/testdata/googlesql/slice/side_table.json deleted file mode 100644 index bd079a7709..0000000000 --- a/internal/sql/preprocess/testdata/googlesql/slice/side_table.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "start": 0, - "end": 53, - "dollar": true, - "params": [ - { - "number": 1, - "location": 47, - "origin": 34, - "name": "ids", - "named": true, - "slice": true - } - ] - } -]