From a92e2ec583ea6c23201310444db3d1298efb887c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 19:04:09 +0000 Subject: [PATCH] googlesql: type inference via the new analysis core (analyze only) Redo GoogleSQL analyze support on top of the core catalog and analyzer landed in #4521, rather than the legacy compiler catalog the original attempt used. - A googlesql dialect seed registers the built-in GoogleSQL types, the comparison/arithmetic operators over them, and COUNT as an int64 aggregate, so the core catalog can resolve schema types and predicates. - The compiler builds a core.Catalog for the googlesql engine; schema DDL is applied through internal/core/schema and queries are resolved by internal/core/analyzer, never entering the legacy analyze path. - `sqlc analyze --dialect googlesql` accepts the new engine. Core analyzer: - Support INSERT, UPDATE, and DELETE in addition to SELECT: bind the target relation, infer parameter types from the columns they are assigned to or compared against, and project RETURNING lists. - Resolve COUNT(*) through a catalog-registered aggregate when the dialect provides one, falling back to int8 as before. - The core compile path now rewrites named parameters before analysis, so "@name" resolves to a numbered parameter carrying its name. GoogleSQL engine: - 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`. - Tag operator A_Expr nodes with A_Expr_Kind_OP, matching the other engines, so the analyzer recognizes them as operator expressions. - Keep named parameters in their native "@name" form when rewriting, since GoogleSQL (and Spanner in particular) requires them. End-to-end coverage lands as analyze_basic, analyze_select (star expansion, joins, aggregates), and analyze_dml cases; the parse_basic/googlesql golden picks up the corrected statement spans and the now-detected query name and command. --- docs/howto/analyze.md | 2 +- docs/howto/parse.md | 2 +- internal/cmd/analyze.go | 11 +- internal/compiler/engine.go | 9 + internal/compiler/parse_core.go | 30 ++- internal/config/config.go | 1 + internal/core/analyzer/analyzer.go | 15 ++ internal/core/analyzer/dml.go | 185 +++++++++++++++++ internal/core/analyzer/expr.go | 3 + .../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 | 17 ++ .../testdata/analyze_dml/googlesql/schema.sql | 5 + .../testdata/analyze_dml/googlesql/stdout.txt | 192 ++++++++++++++++++ .../analyze_select/googlesql/exec.json | 5 + .../analyze_select/googlesql/query.sql | 8 + .../analyze_select/googlesql/schema.sql | 12 ++ .../analyze_select/googlesql/stdout.txt | 96 +++++++++ .../testdata/parse_basic/googlesql/stdout.txt | 6 +- internal/engine/googlesql/convert.go | 3 + internal/engine/googlesql/parse.go | 15 +- internal/engine/googlesql/seed.go | 108 ++++++++++ internal/sql/rewrite/parameters.go | 19 +- 26 files changed, 777 insertions(+), 18 deletions(-) create mode 100644 internal/core/analyzer/dml.go 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 create mode 100644 internal/endtoend/testdata/analyze_select/googlesql/exec.json create mode 100644 internal/endtoend/testdata/analyze_select/googlesql/query.sql create mode 100644 internal/endtoend/testdata/analyze_select/googlesql/schema.sql create mode 100644 internal/endtoend/testdata/analyze_select/googlesql/stdout.txt create mode 100644 internal/engine/googlesql/seed.go diff --git a/docs/howto/analyze.md b/docs/howto/analyze.md index 7da2518770..f14a0e18f8 100644 --- a/docs/howto/analyze.md +++ b/docs/howto/analyze.md @@ -19,7 +19,7 @@ provided. The schema is always read from the `--schema` file. ## Flags - `--dialect`, `-d` - The SQL dialect to use. One of `postgresql`, `mysql`, - `sqlite`, or `clickhouse`. Required. + `sqlite`, `clickhouse`, 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 e8ef0f2243..11740db8bb 100644 --- a/internal/cmd/analyze.go +++ b/internal/cmd/analyze.go @@ -40,6 +40,9 @@ Examples: # Analyze a ClickHouse query sqlc analyze --dialect clickhouse --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 @@ -53,7 +56,7 @@ Examples: return err } if dialect == "" { - return fmt.Errorf("--dialect flag is required (postgresql, mysql, sqlite, or clickhouse)") + return fmt.Errorf("--dialect flag is required (postgresql, mysql, sqlite, clickhouse, or googlesql)") } schemaPath, err := cmd.Flags().GetString("schema") @@ -112,8 +115,10 @@ Examples: engine = config.EngineSQLite case "clickhouse": engine = config.EngineClickHouse + case "googlesql": + engine = config.EngineGoogleSQL default: - return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, or clickhouse)", dialect) + return fmt.Errorf("unsupported dialect: %s (use postgresql, mysql, sqlite, clickhouse, or googlesql)", dialect) } sql := config.SQL{ @@ -155,7 +160,7 @@ Examples: return nil }, } - cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, sqlite, or clickhouse)") + cmd.Flags().StringP("dialect", "d", "", "SQL dialect to use (postgresql, mysql, sqlite, clickhouse, 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 8b50d30426..071f3458e0 100644 --- a/internal/compiler/engine.go +++ b/internal/compiler/engine.go @@ -10,6 +10,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/dbmanager" "github.com/sqlc-dev/sqlc/internal/engine/clickhouse" "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" @@ -123,6 +124,14 @@ func NewCompiler(conf config.SQL, combo config.CombinedSettings, parserOpts opts return nil, fmt.Errorf("clickhouse: init catalog: %w", err) } c.coreCatalog = cat + case config.EngineGoogleSQL: + c.parser = googlesql.NewParser() + c.selector = newDefaultSelector() + cat, err := core.New(googlesql.Dialect()) + if err != nil { + return nil, fmt.Errorf("googlesql: init catalog: %w", err) + } + c.coreCatalog = cat default: return nil, fmt.Errorf("unknown engine: %s", conf.Engine) } diff --git a/internal/compiler/parse_core.go b/internal/compiler/parse_core.go index 9d79b21709..5cede4356b 100644 --- a/internal/compiler/parse_core.go +++ b/internal/compiler/parse_core.go @@ -9,6 +9,8 @@ import ( "github.com/sqlc-dev/sqlc/internal/metadata" "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/validate" ) @@ -46,10 +48,21 @@ 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 + } + var cols []*Column var params []Parameter - if _, ok := raw.Stmt.(*ast.SelectStmt); ok { - res, err := coreanalyzer.Prepare(c.coreCatalog, raw) + switch rewritten.Stmt.(type) { + case *ast.SelectStmt, *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt: + res, err := coreanalyzer.Prepare(c.coreCatalog, rewritten) if err != nil { return nil, err } @@ -57,11 +70,11 @@ func (c *Compiler) parseQueryCore(stmt ast.Node, src string) (*Query, error) { cols = append(cols, coreColumn(col)) } for _, p := range res.Parameters { - params = append(params, Parameter{Number: p.Number, Column: coreParamColumn(p)}) + params = append(params, Parameter{Number: p.Number, Column: coreParamColumn(p, namedParams)}) } } - trimmed, comments, err := source.StripComments(rawSQL) + trimmed, comments, err := source.StripComments(expanded) if err != nil { return nil, err } @@ -100,7 +113,7 @@ func coreColumn(c core.Column) *Column { return col } -func coreParamColumn(p core.Parameter) *Column { +func coreParamColumn(p core.Parameter, params *named.ParamSet) *Column { col := &Column{ Name: p.Name, DataType: p.DataType, @@ -110,5 +123,12 @@ func coreParamColumn(p core.Parameter) *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 + } + } return col } diff --git a/internal/config/config.go b/internal/config/config.go index 6f0a9bf0eb..b488c5b953 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,7 @@ const ( EnginePostgreSQL Engine = "postgresql" EngineSQLite Engine = "sqlite" EngineClickHouse Engine = "clickhouse" + EngineGoogleSQL Engine = "googlesql" ) type Config struct { diff --git a/internal/core/analyzer/analyzer.go b/internal/core/analyzer/analyzer.go index d4fcc57bbc..533557e417 100644 --- a/internal/core/analyzer/analyzer.go +++ b/internal/core/analyzer/analyzer.go @@ -21,6 +21,21 @@ func Prepare(cat *core.Catalog, stmt ast.Node) (core.PrepareResult, error) { return core.PrepareResult{}, err } a.command = core.CommandSelect + case *ast.InsertStmt: + if err := a.analyzeInsert(s); err != nil { + return core.PrepareResult{}, err + } + a.command = core.CommandInsert + case *ast.UpdateStmt: + if err := a.analyzeUpdate(s); err != nil { + return core.PrepareResult{}, err + } + a.command = core.CommandUpdate + case *ast.DeleteStmt: + if err := a.analyzeDelete(s); err != nil { + return core.PrepareResult{}, err + } + a.command = core.CommandDelete default: return core.PrepareResult{}, fmt.Errorf("analyzer: unsupported statement %T", stmt) } diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go new file mode 100644 index 0000000000..7ea4285e7e --- /dev/null +++ b/internal/core/analyzer/dml.go @@ -0,0 +1,185 @@ +package analyzer + +import ( + "fmt" + + "github.com/sqlc-dev/sqlc/internal/sql/ast" +) + +func (a *analyzer) analyzeInsert(s *ast.InsertStmt) error { + if s.Relation == nil { + return fmt.Errorf("insert: missing relation") + } + rel, err := a.bindRangeVar(s.Relation) + if err != nil { + return err + } + a.scope = &scope{rels: []scopeRel{rel}} + + targets, err := insertTargets(rel, s.Cols) + if err != nil { + return err + } + if err := a.bindInsertValues(s.SelectStmt, rel, targets); err != nil { + return err + } + return a.projectReturning(s.ReturningList) +} + +func (a *analyzer) analyzeUpdate(s *ast.UpdateStmt) error { + sc, err := a.relationScope(s.Relations, s.FromClause) + if err != nil { + return err + } + a.scope = sc + target := sc.rels[0] + + for _, item := range listItems(s.TargetList) { + rt, ok := item.(*ast.ResTarget) + if !ok || rt.Name == nil { + continue + } + col, ok := findColumn(target, *rt.Name) + if !ok { + return fmt.Errorf("unknown column %q", *rt.Name) + } + if err := a.bindValue(target, &col, rt.Val); err != nil { + return fmt.Errorf("set %s: %w", *rt.Name, err) + } + } + + if s.WhereClause != nil { + if _, err := a.typeExpr(s.WhereClause); err != nil { + return fmt.Errorf("where: %w", err) + } + } + return a.projectReturning(s.ReturningList) +} + +func (a *analyzer) analyzeDelete(s *ast.DeleteStmt) error { + sc, err := a.relationScope(s.Relations, s.UsingClause) + if err != nil { + return err + } + a.scope = sc + + if s.WhereClause != nil { + if _, err := a.typeExpr(s.WhereClause); err != nil { + return fmt.Errorf("where: %w", err) + } + } + return a.projectReturning(s.ReturningList) +} + +func (a *analyzer) relationScope(relations, extra *ast.List) (*scope, error) { + sc := &scope{} + for _, item := range listItems(relations) { + if err := a.appendFromItem(sc, item); err != nil { + return nil, err + } + } + for _, item := range listItems(extra) { + if err := a.appendFromItem(sc, item); err != nil { + return nil, err + } + } + if len(sc.rels) == 0 { + return nil, fmt.Errorf("missing target relation") + } + return sc, nil +} + +func insertTargets(rel scopeRel, cols *ast.List) ([]scopeCol, error) { + items := listItems(cols) + if len(items) == 0 { + return rel.cols, nil + } + out := make([]scopeCol, 0, len(items)) + for _, item := range items { + rt, ok := item.(*ast.ResTarget) + if !ok || rt.Name == nil { + return nil, fmt.Errorf("insert: unsupported column target %T", item) + } + col, ok := findColumn(rel, *rt.Name) + if !ok { + return nil, fmt.Errorf("unknown column %q", *rt.Name) + } + out = append(out, col) + } + return out, nil +} + +func (a *analyzer) bindInsertValues(n ast.Node, rel scopeRel, targets []scopeCol) error { + if n == nil { + return nil + } + sel, ok := n.(*ast.SelectStmt) + if !ok { + return fmt.Errorf("insert: unsupported source %T", n) + } + if sel.ValuesLists == nil { + return fmt.Errorf("insert: INSERT ... SELECT is not supported") + } + for _, row := range listItems(sel.ValuesLists) { + values, ok := row.(*ast.List) + if !ok { + continue + } + for i, v := range values.Items { + var target *scopeCol + if i < len(targets) { + target = &targets[i] + } + if err := a.bindValue(rel, target, v); err != nil { + return err + } + } + } + return nil +} + +func (a *analyzer) bindValue(rel scopeRel, target *scopeCol, v ast.Node) error { + if target != nil { + switch value := v.(type) { + case *ast.ParamRef: + a.inferParam(value.Number, columnType(rel, *target)) + return nil + case *ast.A_Const: + return nil + } + } + _, err := a.typeExpr(v) + return err +} + +func (a *analyzer) projectReturning(l *ast.List) error { + for _, item := range listItems(l) { + rt, ok := item.(*ast.ResTarget) + if !ok { + continue + } + if err := a.projectTarget(rt); err != nil { + return err + } + } + return nil +} + +func findColumn(rel scopeRel, name string) (scopeCol, bool) { + for _, col := range rel.cols { + if col.name == name { + return col, true + } + } + return scopeCol{}, false +} + +func columnType(rel scopeRel, col scopeCol) exprType { + return exprType{ + typeOID: col.typeOID, + nullable: !col.notNull, + sourceClassOID: rel.classOID, + sourceAttributeOID: col.attOID, + sourceTableAlias: rel.alias, + } +} diff --git a/internal/core/analyzer/expr.go b/internal/core/analyzer/expr.go index ff9ecd7e9d..037e2f34ad 100644 --- a/internal/core/analyzer/expr.go +++ b/internal/core/analyzer/expr.go @@ -276,6 +276,9 @@ func (a *analyzer) typeFuncCall(f *ast.FuncCall) (exprType, error) { } if f.AggStar && (name == "count" || name == "count.*") { + if overloads, err := a.cat.FindProcs("count", nil); err == nil && len(overloads) > 0 { + return exprType{typeOID: overloads[0].ReturnTypeOID, nullable: overloads[0].ReturnNullable}, nil + } oid, err := a.cat.TypeOID("int8") if err != nil { return exprType{}, err 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..e35c5f5d02 --- /dev/null +++ b/internal/endtoend/testdata/analyze_dml/googlesql/query.sql @@ -0,0 +1,17 @@ +-- 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: UpdateNameReturning :one +UPDATE users SET name = @name WHERE id = @id THEN RETURN id, name; + +-- name: DeleteUser :exec +DELETE FROM users WHERE id = @id; + +-- name: DeleteUserReturning :one +DELETE FROM users WHERE id = @id THEN RETURN 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..5148457d6d --- /dev/null +++ b/internal/endtoend/testdata/analyze_dml/googlesql/stdout.txt @@ -0,0 +1,192 @@ +[ + { + "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": "UpdateNameReturning", + "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": "name", + "data_type": "string", + "not_null": true, + "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" + } + } + ] + }, + { + "name": "DeleteUserReturning", + "cmd": ":one", + "columns": [ + { + "name": "id", + "data_type": "int64", + "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_select/googlesql/exec.json b/internal/endtoend/testdata/analyze_select/googlesql/exec.json new file mode 100644 index 0000000000..a53ddddc6d --- /dev/null +++ b/internal/endtoend/testdata/analyze_select/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_select/googlesql/query.sql b/internal/endtoend/testdata/analyze_select/googlesql/query.sql new file mode 100644 index 0000000000..b4e7756130 --- /dev/null +++ b/internal/endtoend/testdata/analyze_select/googlesql/query.sql @@ -0,0 +1,8 @@ +-- name: ListUsers :many +SELECT * FROM users; + +-- name: CountUsers :one +SELECT COUNT(*) AS total FROM users; + +-- name: ListUserPosts :many +SELECT u.name, p.* FROM users u JOIN posts p ON p.user_id = u.id WHERE u.name = @name; diff --git a/internal/endtoend/testdata/analyze_select/googlesql/schema.sql b/internal/endtoend/testdata/analyze_select/googlesql/schema.sql new file mode 100644 index 0000000000..18ef00e682 --- /dev/null +++ b/internal/endtoend/testdata/analyze_select/googlesql/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id INT64 NOT NULL, + name STRING NOT NULL, + bio STRING, +) PRIMARY KEY (id); + +CREATE TABLE posts ( + id INT64 NOT NULL, + user_id INT64 NOT NULL, + title STRING(255), + created TIMESTAMP NOT NULL, +) PRIMARY KEY (id); diff --git a/internal/endtoend/testdata/analyze_select/googlesql/stdout.txt b/internal/endtoend/testdata/analyze_select/googlesql/stdout.txt new file mode 100644 index 0000000000..b2b19ce73f --- /dev/null +++ b/internal/endtoend/testdata/analyze_select/googlesql/stdout.txt @@ -0,0 +1,96 @@ +[ + { + "name": "ListUsers", + "cmd": ":many", + "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" + }, + { + "name": "bio", + "data_type": "string", + "not_null": false, + "is_array": false, + "table": "users" + } + ], + "params": [] + }, + { + "name": "CountUsers", + "cmd": ":one", + "columns": [ + { + "name": "total", + "data_type": "int64", + "not_null": true, + "is_array": false + } + ], + "params": [] + }, + { + "name": "ListUserPosts", + "cmd": ":many", + "columns": [ + { + "name": "name", + "data_type": "string", + "not_null": true, + "is_array": false, + "table": "users" + }, + { + "name": "id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "posts" + }, + { + "name": "user_id", + "data_type": "int64", + "not_null": true, + "is_array": false, + "table": "posts" + }, + { + "name": "title", + "data_type": "string", + "not_null": false, + "is_array": false, + "table": "posts" + }, + { + "name": "created", + "data_type": "timestamp", + "not_null": true, + "is_array": false, + "table": "posts" + } + ], + "params": [ + { + "number": 1, + "column": { + "name": "name", + "data_type": "string", + "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..acf0af4657 100644 --- a/internal/engine/googlesql/convert.go +++ b/internal/engine/googlesql/convert.go @@ -419,6 +419,7 @@ func (c *cc) convertExpr(node zjast.Node) ast.Node { op = "<<" } return &ast.A_Expr{ + Kind: ast.A_Expr_Kind_OP, Name: &ast.List{Items: []ast.Node{&ast.String{Str: op}}}, Lexpr: c.convertExpr(n.Lhs), Rexpr: c.convertExpr(n.Rhs), @@ -546,6 +547,7 @@ func (c *cc) convertUnaryExpression(n *zjast.UnaryExpression) ast.Node { } } return &ast.A_Expr{ + Kind: ast.A_Expr_Kind_OP, Name: &ast.List{Items: []ast.Node{&ast.String{Str: n.Op}}}, Rexpr: c.convertExpr(n.Operand), Location: n.Pos(), @@ -570,6 +572,7 @@ func (c *cc) convertBinaryExpression(n *zjast.BinaryExpression) ast.Node { } var expr ast.Node = &ast.A_Expr{ + Kind: ast.A_Expr_Kind_OP, Name: &ast.List{Items: []ast.Node{&ast.String{Str: n.Op}}}, Lexpr: c.convertExpr(n.Left), Rexpr: c.convertExpr(n.Right), 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/engine/googlesql/seed.go b/internal/engine/googlesql/seed.go new file mode 100644 index 0000000000..ae78bef1e2 --- /dev/null +++ b/internal/engine/googlesql/seed.go @@ -0,0 +1,108 @@ +package googlesql + +import ( + "fmt" + + "github.com/sqlc-dev/sqlc/internal/core" +) + +func Dialect() core.Option { + return core.WithSeed(Seed) +} + +type gsqlType struct { + name string + category string +} + +var googlesqlTypes = []gsqlType{ + {"bool", "B"}, + {"int64", "N"}, {"float32", "N"}, {"float64", "N"}, + {"numeric", "N"}, {"bignumeric", "N"}, + {"string", "S"}, {"bytes", "S"}, {"uuid", "S"}, + {"date", "D"}, {"datetime", "D"}, {"time", "D"}, {"timestamp", "D"}, + {"interval", "T"}, + {"json", "U"}, {"geography", "U"}, {"struct", "U"}, {"enum", "U"}, + {"tokenlist", "U"}, + {"array", "A"}, +} + +var comparisonOperators = []string{"=", "<>", "!=", "<", "<=", ">", ">="} + +var arithmeticOperators = []string{"+", "-", "*", "/"} + +var arithmeticTypes = []string{"int64", "float32", "float64", "numeric", "bignumeric"} + +func Seed(cat *core.Catalog) error { + dialectOID, err := cat.CreateDialect("googlesql") + if err != nil { + return err + } + + oids := make(map[string]int64, len(googlesqlTypes)) + for _, t := range googlesqlTypes { + oid, err := cat.CreateTypeSpec(core.TypeSpec{ + Name: t.name, + Typtype: "b", + Category: t.category, + DialectOID: dialectOID, + }) + if err != nil { + return fmt.Errorf("seed googlesql type %q: %w", t.name, err) + } + oids[t.name] = oid + } + + boolOID := oids["bool"] + for _, t := range googlesqlTypes { + if t.name == "array" || t.name == "struct" { + 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 googlesql operator %q(%s): %w", op, t.name, err) + } + } + } + + for _, name := range arithmeticTypes { + for _, op := range arithmeticOperators { + if _, err := cat.CreateOperator(core.OperatorSpec{ + Name: op, + DialectOID: dialectOID, + LeftTypeOID: oids[name], + RightTypeOID: oids[name], + ResultTypeOID: oids[name], + }); err != nil { + return fmt.Errorf("seed googlesql operator %q(%s): %w", op, name, err) + } + } + } + + if _, err := cat.CreateProc(core.ProcSpec{ + Name: "count", + DialectOID: dialectOID, + Kind: "a", + ReturnTypeOID: oids["int64"], + }); err != nil { + return fmt.Errorf("seed googlesql function %q: %w", "count", err) + } + + if _, err := cat.CreateOperator(core.OperatorSpec{ + Name: "||", + DialectOID: dialectOID, + LeftTypeOID: oids["string"], + RightTypeOID: oids["string"], + ResultTypeOID: oids["string"], + }); err != nil { + return fmt.Errorf("seed googlesql operator %q: %w", "||", err) + } + + return 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)