From 794f498cfda6234f299437239fd24a046846d967 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 16:14:50 +0000 Subject: [PATCH] Add analyze support for GoogleSQL via zetajones Wire the GoogleSQL (zetajones) engine into sqlc's native static analysis so `sqlc analyze --dialect googlesql` infers result columns and parameters from a schema, mirroring the existing PostgreSQL, MySQL, and SQLite support. Changes: - Add the `googlesql` engine constant and register its parser, catalog, and selector in the compiler. - Accept `googlesql` as an `analyze` dialect and document it (and the previously-undocumented `parse` dialect). - Keep named parameters in their native `@name` form when rewriting, since GoogleSQL supports named parameters (Spanner requires them), and quote identifiers with backticks during star expansion. - Include a statement's leading comment in its reported location so the `-- name:` annotation is captured; this also fixes name extraction for `sqlc parse --dialect googlesql`. - Initialize the list fields the compiler walks unconditionally (ReturningList, INSERT ... VALUES TargetList, UPDATE FromClause), fixing nil-pointer panics when analyzing INSERT/UPDATE/DELETE statements. Add end-to-end coverage for basic SELECT analysis and for INSERT/UPDATE/DELETE (including THEN RETURN). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CmGTScco7g1CNc5LjPSJVd --- docs/howto/analyze.md | 4 +- docs/howto/parse.md | 2 +- internal/cmd/analyze.go | 11 +- internal/compiler/engine.go | 5 + internal/compiler/expand.go | 2 +- internal/config/config.go | 1 + .../analyze_basic/googlesql/exec.json | 5 + .../analyze_basic/googlesql/query.sql | 2 + .../analyze_basic/googlesql/schema.sql | 5 + .../analyze_basic/googlesql/stdout.txt | 34 +++++ .../testdata/analyze_dml/googlesql/exec.json | 5 + .../testdata/analyze_dml/googlesql/query.sql | 11 ++ .../testdata/analyze_dml/googlesql/schema.sql | 5 + .../testdata/analyze_dml/googlesql/stdout.txt | 125 ++++++++++++++++++ .../testdata/parse_basic/googlesql/stdout.txt | 6 +- internal/engine/googlesql/convert.go | 16 ++- internal/engine/googlesql/parse.go | 15 ++- internal/sql/rewrite/parameters.go | 19 ++- 18 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 internal/endtoend/testdata/analyze_basic/googlesql/exec.json create mode 100644 internal/endtoend/testdata/analyze_basic/googlesql/query.sql create mode 100644 internal/endtoend/testdata/analyze_basic/googlesql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_basic/googlesql/stdout.txt create mode 100644 internal/endtoend/testdata/analyze_dml/googlesql/exec.json create mode 100644 internal/endtoend/testdata/analyze_dml/googlesql/query.sql create mode 100644 internal/endtoend/testdata/analyze_dml/googlesql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_dml/googlesql/stdout.txt diff --git a/docs/howto/analyze.md b/docs/howto/analyze.md index d3e0a60771..7bfb9f8c05 100644 --- a/docs/howto/analyze.md +++ b/docs/howto/analyze.md @@ -18,8 +18,8 @@ provided. The schema is always read from the `--schema` file. ## Flags -- `--dialect`, `-d` - The SQL dialect to use. One of `postgresql`, `mysql`, or - `sqlite`. Required. +- `--dialect`, `-d` - The SQL dialect to use. One of `postgresql`, `mysql`, + `sqlite`, or `googlesql`. Required. - `--schema`, `-s` - Path to the schema (DDL) file. Required. - `--ast` - Include each statement's AST in the output. Defaults to `false`. diff --git a/docs/howto/parse.md b/docs/howto/parse.md index 406cc12673..28ba9a3d9a 100644 --- a/docs/howto/parse.md +++ b/docs/howto/parse.md @@ -20,7 +20,7 @@ provided. ## Flags - `--dialect`, `-d` - The SQL dialect to use. One of `postgresql`, `mysql`, - `sqlite`, or `clickhouse`. Required. + `sqlite`, `clickhouse`, or `googlesql`. Required. ## Examples diff --git a/internal/cmd/analyze.go b/internal/cmd/analyze.go index 51de2605b6..688fa53685 100644 --- a/internal/cmd/analyze.go +++ b/internal/cmd/analyze.go @@ -37,6 +37,9 @@ Examples: # Analyze a SQLite query sqlc analyze --dialect sqlite --schema schema.sql query.sql + # Analyze a GoogleSQL (BigQuery, Spanner) query + sqlc analyze --dialect googlesql --schema schema.sql query.sql + # Analyze a query piped via stdin echo "-- name: GetAuthor :one SELECT * FROM authors WHERE id = $1;" | sqlc analyze --dialect postgresql --schema schema.sql @@ -50,7 +53,7 @@ Examples: return err } if dialect == "" { - return fmt.Errorf("--dialect flag is required (postgresql, mysql, or sqlite)") + return fmt.Errorf("--dialect flag is required (postgresql, mysql, sqlite, or googlesql)") } schemaPath, err := cmd.Flags().GetString("schema") @@ -107,8 +110,10 @@ Examples: engine = config.EngineMySQL case "sqlite": engine = config.EngineSQLite + case "googlesql": + engine = config.EngineGoogleSQL default: - return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, or sqlite)", dialect) + return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, or googlesql)", dialect) } sql := config.SQL{ @@ -150,7 +155,7 @@ Examples: return nil }, } - cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, or sqlite)") + cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, sqlite, or googlesql)") cmd.Flags().StringP("schema", "s", "", "path to the schema file") cmd.Flags().BoolP("ast", "", false, "include the statement AST in the output") return cmd diff --git a/internal/compiler/engine.go b/internal/compiler/engine.go index 64fdf3d5c7..75a8fab7f8 100644 --- a/internal/compiler/engine.go +++ b/internal/compiler/engine.go @@ -8,6 +8,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/config" "github.com/sqlc-dev/sqlc/internal/dbmanager" "github.com/sqlc-dev/sqlc/internal/engine/dolphin" + "github.com/sqlc-dev/sqlc/internal/engine/googlesql" "github.com/sqlc-dev/sqlc/internal/engine/postgresql" pganalyze "github.com/sqlc-dev/sqlc/internal/engine/postgresql/analyzer" "github.com/sqlc-dev/sqlc/internal/engine/sqlite" @@ -82,6 +83,10 @@ func NewCompiler(conf config.SQL, combo config.CombinedSettings, parserOpts opts c.parser = dolphin.NewParser() c.catalog = dolphin.NewCatalog() c.selector = newDefaultSelector() + case config.EngineGoogleSQL: + c.parser = googlesql.NewParser() + c.catalog = googlesql.NewCatalog() + c.selector = newDefaultSelector() case config.EnginePostgreSQL: parser := postgresql.NewParser() c.parser = parser diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index c60b7618b2..067eb2db63 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -71,7 +71,7 @@ func (c *Compiler) quoteIdent(ident string) string { func (c *Compiler) quote(x string) string { switch c.conf.Engine { - case config.EngineMySQL: + case config.EngineMySQL, config.EngineGoogleSQL: return "`" + x + "`" default: return "\"" + x + "\"" diff --git a/internal/config/config.go b/internal/config/config.go index ff7faedcaa..ea4c710124 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ const ( EngineMySQL Engine = "mysql" EnginePostgreSQL Engine = "postgresql" EngineSQLite Engine = "sqlite" + EngineGoogleSQL Engine = "googlesql" ) type Config struct { diff --git a/internal/endtoend/testdata/analyze_basic/googlesql/exec.json b/internal/endtoend/testdata/analyze_basic/googlesql/exec.json new file mode 100644 index 0000000000..a53ddddc6d --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/googlesql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "googlesql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_basic/googlesql/query.sql b/internal/endtoend/testdata/analyze_basic/googlesql/query.sql new file mode 100644 index 0000000000..e3aba1cde2 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/googlesql/query.sql @@ -0,0 +1,2 @@ +-- name: GetUser :one +SELECT id, name FROM users WHERE id = @id; diff --git a/internal/endtoend/testdata/analyze_basic/googlesql/schema.sql b/internal/endtoend/testdata/analyze_basic/googlesql/schema.sql new file mode 100644 index 0000000000..1f45153b37 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/googlesql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id INT64 NOT NULL, + name STRING NOT NULL, + bio STRING, +) PRIMARY KEY (id); diff --git a/internal/endtoend/testdata/analyze_basic/googlesql/stdout.txt b/internal/endtoend/testdata/analyze_basic/googlesql/stdout.txt new file mode 100644 index 0000000000..4075f80992 --- /dev/null +++ b/internal/endtoend/testdata/analyze_basic/googlesql/stdout.txt @@ -0,0 +1,34 @@ +[ + { + "name": "GetUser", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + }, + { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "users" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + } + } + ] + } +] diff --git a/internal/endtoend/testdata/analyze_dml/googlesql/exec.json b/internal/endtoend/testdata/analyze_dml/googlesql/exec.json new file mode 100644 index 0000000000..a53ddddc6d --- /dev/null +++ b/internal/endtoend/testdata/analyze_dml/googlesql/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "googlesql", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_dml/googlesql/query.sql b/internal/endtoend/testdata/analyze_dml/googlesql/query.sql new file mode 100644 index 0000000000..129a73be1c --- /dev/null +++ b/internal/endtoend/testdata/analyze_dml/googlesql/query.sql @@ -0,0 +1,11 @@ +-- name: CreateUser :exec +INSERT INTO users (id, name, bio) VALUES (@id, @name, @bio); + +-- name: CreateUserReturning :one +INSERT INTO users (id, name) VALUES (@id, @name) THEN RETURN id, name; + +-- name: UpdateBio :exec +UPDATE users SET bio = @bio WHERE id = @id; + +-- name: DeleteUser :exec +DELETE FROM users WHERE id = @id; diff --git a/internal/endtoend/testdata/analyze_dml/googlesql/schema.sql b/internal/endtoend/testdata/analyze_dml/googlesql/schema.sql new file mode 100644 index 0000000000..1f45153b37 --- /dev/null +++ b/internal/endtoend/testdata/analyze_dml/googlesql/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id INT64 NOT NULL, + name STRING NOT NULL, + bio STRING, +) PRIMARY KEY (id); diff --git a/internal/endtoend/testdata/analyze_dml/googlesql/stdout.txt b/internal/endtoend/testdata/analyze_dml/googlesql/stdout.txt new file mode 100644 index 0000000000..30a234ac4b --- /dev/null +++ b/internal/endtoend/testdata/analyze_dml/googlesql/stdout.txt @@ -0,0 +1,125 @@ +[ + { + "name": "CreateUser", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + } + }, + { + "number": 2, + "column": { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "users" + } + }, + { + "number": 3, + "column": { + "name": "bio", + "data_type": "string", + "not_null": false, + "is_array": false, + "table": "users" + } + } + ] + }, + { + "name": "CreateUserReturning", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + }, + { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "users" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + } + }, + { + "number": 2, + "column": { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "users" + } + } + ] + }, + { + "name": "UpdateBio", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "bio", + "data_type": "string", + "not_null": false, + "is_array": false, + "table": "users" + } + }, + { + "number": 2, + "column": { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + } + } + ] + }, + { + "name": "DeleteUser", + "cmd": ":exec", + "columns": [], + "params": [ + { + "number": 1, + "column": { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "users" + } + } + ] + } +] diff --git a/internal/endtoend/testdata/parse_basic/googlesql/stdout.txt b/internal/endtoend/testdata/parse_basic/googlesql/stdout.txt index db7128d126..086f885402 100644 --- a/internal/endtoend/testdata/parse_basic/googlesql/stdout.txt +++ b/internal/endtoend/testdata/parse_basic/googlesql/stdout.txt @@ -1,5 +1,7 @@ [ { + "name": "GetValue", + "cmd": ":one", "ast": { "Stmt": { "DistinctClause": null, @@ -35,8 +37,8 @@ "Larg": null, "Rarg": null }, - "StmtLocation": 23, - "StmtLen": 8 + "StmtLocation": 0, + "StmtLen": 31 } } ] diff --git a/internal/engine/googlesql/convert.go b/internal/engine/googlesql/convert.go index ae84839a89..4c8795a58e 100644 --- a/internal/engine/googlesql/convert.go +++ b/internal/engine/googlesql/convert.go @@ -750,7 +750,8 @@ func (c *cc) convertInsertStatement(n *zjast.InsertStatement) ast.Node { return todo(n) } stmt := &ast.InsertStmt{ - Relation: parseRangeVar(path), + Relation: parseRangeVar(path), + ReturningList: &ast.List{}, } if n.Columns != nil { @@ -770,6 +771,10 @@ func (c *cc) convertInsertStatement(n *zjast.InsertStatement) ast.Node { switch { case n.Rows != nil: selectStmt := &ast.SelectStmt{ + // A non-nil TargetList matches the shape the other engines produce + // for INSERT ... VALUES; the compiler's parameter search iterates it + // unconditionally. + TargetList: &ast.List{}, ValuesLists: &ast.List{}, } for _, row := range n.Rows.Rows { @@ -816,8 +821,10 @@ func (c *cc) convertUpdateStatement(n *zjast.UpdateStatement) ast.Node { rv := parseRangeVar(path) rv.Alias = convertAlias(n.Alias) stmt := &ast.UpdateStmt{ - Relations: &ast.List{Items: []ast.Node{rv}}, - TargetList: &ast.List{}, + Relations: &ast.List{Items: []ast.Node{rv}}, + TargetList: &ast.List{}, + FromClause: &ast.List{}, + ReturningList: &ast.List{}, } if n.UpdateItemList != nil { @@ -869,7 +876,8 @@ func (c *cc) convertDeleteStatement(n *zjast.DeleteStatement) ast.Node { rv := parseRangeVar(path) rv.Alias = convertAlias(n.Alias) stmt := &ast.DeleteStmt{ - Relations: &ast.List{Items: []ast.Node{rv}}, + Relations: &ast.List{Items: []ast.Node{rv}}, + ReturningList: &ast.List{}, } if n.Where != nil { diff --git a/internal/engine/googlesql/parse.go b/internal/engine/googlesql/parse.go index ea77df7a03..87967c00ce 100644 --- a/internal/engine/googlesql/parse.go +++ b/internal/engine/googlesql/parse.go @@ -29,23 +29,32 @@ func (p *Parser) Parse(r io.Reader) ([]ast.Statement, error) { return nil, err } + // zetajones node locations are byte offsets into the input. Following the + // convention used by the other engines, a statement's location starts at the + // end of the previous statement (or the start of the input) so that any + // leading comment — in particular the "-- name:" annotation sqlc relies on — + // is captured as part of the statement. var stmts []ast.Statement + loc := 0 for _, stmt := range stmtNodes { converter := &cc{} out := converter.convert(stmt) if _, ok := out.(*ast.TODO); ok { + // Skip over the unsupported statement (and its trailing semicolon) + // so the next statement's leading comment is measured from here. + loc = stmt.End() + 1 continue } - // zetajones node locations are byte offsets into the input. - loc := stmt.Pos() + end := stmt.End() stmts = append(stmts, ast.Statement{ Raw: &ast.RawStmt{ Stmt: out, StmtLocation: loc, - StmtLen: stmt.End() - loc, + StmtLen: end - loc, }, }) + loc = end + 1 } return stmts, nil diff --git a/internal/sql/rewrite/parameters.go b/internal/sql/rewrite/parameters.go index d1ea1a22cc..77ad5d2573 100644 --- a/internal/sql/rewrite/parameters.go +++ b/internal/sql/rewrite/parameters.go @@ -102,7 +102,16 @@ func NamedParameters(engine config.Engine, raw *ast.RawStmt, numbs map[int]bool, }) var replace string - if engine == config.EngineMySQL || engine == config.EngineSQLite || !dollar { + 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 @@ -140,7 +149,9 @@ func NamedParameters(engine config.Engine, raw *ast.RawStmt, numbs map[int]bool, // TODO: This code assumes that @foo::bool is on a single line var replace string - if engine == config.EngineMySQL || !dollar { + 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) @@ -168,7 +179,9 @@ func NamedParameters(engine config.Engine, raw *ast.RawStmt, numbs map[int]bool, // TODO: This code assumes that @foo is on a single line var replace string - if engine == config.EngineMySQL || !dollar { + 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)