From 4739a8ac5993c2db2434362a9ab685e8c8fc64ac Mon Sep 17 00:00:00 2001 From: Yumin Xia Date: Wed, 9 Sep 2026 00:15:13 +0000 Subject: [PATCH 1/2] feat(core): apply wicked compiler and protocol changes to upstream Add primary-schema facts and scoped schema conventions, retain parameter context inference and fix repeated-relation ambiguity, and expose the minimal backend protocol. Include core tests, release version plumbing, secure toolchain/dependencies, and core CI/test tooling. No wicked generation implementation or dispatch is included in this commit. --- .github/workflows/build.yml | 2 +- .github/workflows/ci-kotlin.yml | 3 +- .github/workflows/ci-python.yml | 2 +- .github/workflows/ci-typescript.yml | 2 +- .github/workflows/ci.yml | 6 +- .gitignore | 3 +- Makefile | 11 +- go.mod | 18 +- go.sum | 21 + internal/cmd/shim.go | 18 +- internal/compiler/compile.go | 45 ++ internal/compiler/engine.go | 1 + internal/compiler/parameter_context_test.go | 144 +++++++ internal/compiler/parse.go | 132 +++++- internal/compiler/resolve.go | 15 + internal/compiler/result.go | 1 + internal/compiler/wicked.go | 47 ++ internal/compiler/wicked_test.go | 83 ++++ internal/config/config.go | 9 +- internal/config/validate.go | 19 + internal/config/wicked_test.go | 30 ++ internal/endtoend/ddl_test.go | 4 + .../postgresql/pgx/exec.json | 2 +- .../postgresql/pgx/go/query.sql.go | 6 +- internal/info/facts.go | 2 +- internal/plugin/codegen.pb.go | 408 ++++++++++++------ protos/plugin/codegen.proto | 20 + scripts/release.go | 7 +- scripts/test-local/main.go | 158 +++++++ 29 files changed, 1045 insertions(+), 174 deletions(-) create mode 100644 internal/compiler/parameter_context_test.go create mode 100644 internal/compiler/wicked.go create mode 100644 internal/compiler/wicked_test.go create mode 100644 internal/config/wicked_test.go create mode 100644 scripts/test-local/main.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa868ee9fd..b9888a1df0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26.2' + go-version: '1.26.8' - name: install ./... run: go build ./... env: diff --git a/.github/workflows/ci-kotlin.yml b/.github/workflows/ci-kotlin.yml index d791c7e727..69ce56451c 100644 --- a/.github/workflows/ci-kotlin.yml +++ b/.github/workflows/ci-kotlin.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26.2' + go-version: '1.26.8' - name: install ./... run: go install ./... - uses: actions/checkout@v6 @@ -30,4 +30,3 @@ jobs: working-directory: kotlin - run: sqlc diff working-directory: kotlin/examples - diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 9338a2304e..be87a104be 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26.2' + go-version: '1.26.8' - name: install ./... run: go install ./... - uses: actions/checkout@v6 diff --git a/.github/workflows/ci-typescript.yml b/.github/workflows/ci-typescript.yml index 5ce9b475e9..dd8e7b345c 100644 --- a/.github/workflows/ci-typescript.yml +++ b/.github/workflows/ci-typescript.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26.2' + go-version: '1.26.8' - name: install ./... run: go install ./... - uses: actions/checkout@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cd48289a5..09d962b506 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26.2' + go-version: '1.26.8' - run: go build ./... env: CGO_ENABLED: "0" @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26.2' + go-version: '1.26.8' - name: install gotestsum run: go install gotest.tools/gotestsum@latest @@ -72,3 +72,5 @@ jobs: steps: - uses: golang/govulncheck-action@v1 + with: + go-version-input: '1.26.8' diff --git a/.gitignore b/.gitignore index 39961ebb02..6cf96c03a3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ __pycache__ .direnv .devenv* devenv.local.nix - +# Local command builds. +/bin/ diff --git a/Makefile b/Makefile index b8745e57dc..f0e7db4ba3 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,15 @@ +BUF ?= buf +CGO_ENABLED ?= 1 +COMMIT_HASH := $(shell git describe --tags --always --dirty) +LDFLAGS := -X github.com/sqlc-dev/sqlc/internal/info.Version=$(COMMIT_HASH)-wicked-fork + .PHONY: build build-endtoend test test-ci test-examples test-endtoend start psql mysqlsh proto build: - go build ./... + CGO_ENABLED=$(CGO_ENABLED) go build -ldflags="$(LDFLAGS)" -o bin/ ./cmd/... install: - go install ./... + CGO_ENABLED=$(CGO_ENABLED) go install -ldflags="$(LDFLAGS)" ./cmd/... test: go test ./... @@ -48,7 +53,7 @@ mysqlsh: mysqlsh --sql --user root --password mysecretpassword --database dinotest 127.0.0.1:3306 proto: - buf generate + $(BUF) generate remote-proto: protoc \ diff --git a/go.mod b/go.mod index f40b6330ca..1e82d61e91 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/sqlc-dev/sqlc -go 1.26.0 - -toolchain go1.26.2 +go 1.26.8 require ( github.com/antlr4-go/antlr/v4 v4.13.1 @@ -25,8 +23,8 @@ require ( github.com/tetratelabs/wazero v1.11.0 github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/sync v0.20.0 - google.golang.org/grpc v1.80.0 + golang.org/x/sync v0.21.0 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -51,10 +49,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.39.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) diff --git a/go.sum b/go.sum index f48d68eccb..78abad1b8c 100644 --- a/go.sum +++ b/go.sum @@ -105,14 +105,19 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -138,15 +143,25 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -155,10 +170,16 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/shim.go b/internal/cmd/shim.go index 654500429a..446d6d5f01 100644 --- a/internal/cmd/shim.go +++ b/internal/cmd/shim.go @@ -6,6 +6,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/config/convert" "github.com/sqlc-dev/sqlc/internal/info" "github.com/sqlc-dev/sqlc/internal/plugin" + "github.com/sqlc-dev/sqlc/internal/sql/ast" "github.com/sqlc-dev/sqlc/internal/sql/catalog" ) @@ -224,10 +225,25 @@ func pluginQueryParam(p compiler.Parameter) *plugin.Parameter { } func codeGenRequest(r *compiler.Result, settings config.CombinedSettings) *plugin.GenerateRequest { - return &plugin.GenerateRequest{ + req := &plugin.GenerateRequest{ Settings: pluginSettings(r, settings), Catalog: pluginCatalog(r.Catalog), Queries: pluginQueries(r), SqlcVersion: info.Version, } + if r.Wicked != nil { + rel := r.Wicked.PrimaryRelation + facts := &plugin.WickedMetadata{ + PrimarySchemaPath: r.Wicked.PrimarySchemaPath, + PrimarySchemaSql: r.Wicked.PrimarySchemaSQL, + PrimaryRelation: &plugin.Identifier{Catalog: rel.Catalog, Schema: rel.Schema, Name: rel.Name}, + QueryIsSelect: make(map[string]bool, len(r.Queries)), + } + for _, q := range r.Queries { + _, isSelect := q.RawStmt.Stmt.(*ast.SelectStmt) + facts.QueryIsSelect[q.Metadata.Name] = isSelect + } + req.BackendMetadata = &plugin.GenerateRequest_Wicked{Wicked: facts} + } + return req } diff --git a/internal/compiler/compile.go b/internal/compiler/compile.go index b6bba42e16..d750560066 100644 --- a/internal/compiler/compile.go +++ b/internal/compiler/compile.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "github.com/sqlc-dev/sqlc/internal/migrations" @@ -31,6 +32,16 @@ func (c *Compiler) parseCatalog(schemas []string) error { if err != nil { return err } + if c.conf.IsWicked() { + if c.databaseOnlyMode { + return fmt.Errorf("wicked requires schema-based analysis to identify the primary model") + } + if len(files) == 0 { + return fmt.Errorf("wicked requires a primary schema file") + } + c.wicked = &WickedSchema{PrimarySchemaPath: files[0]} + slices.Reverse(files) + } merr := multierr.New() for _, filename := range files { blob, err := os.ReadFile(filename) @@ -39,6 +50,9 @@ func (c *Compiler) parseCatalog(schemas []string) error { continue } contents := migrations.RemoveRollbackStatements(string(blob)) + if c.wicked != nil && filename == c.wicked.PrimarySchemaPath { + c.wicked.PrimarySchemaSQL = contents + } contents = migrations.RemovePsqlMetaCommands(contents) c.schema = append(c.schema, contents) @@ -55,16 +69,46 @@ func (c *Compiler) parseCatalog(schemas []string) error { continue } + layouts := 0 for i := range stmts { if err := c.catalog.Update(stmts[i], c); err != nil { merr.Add(filename, contents, stmts[i].Pos(), err) continue } + if c.wicked != nil { + if wickedCreatesLayout(stmts[i]) { + layouts++ + if layouts > 1 { + merr.Add(filename, contents, stmts[i].Pos(), fmt.Errorf("only one table creation is allowed per schema.sql file")) + } + } + if filename == c.wicked.PrimarySchemaPath && c.wicked.PrimaryRelation == nil { + if rel := wickedRelation(stmts[i]); rel != nil { + table, err := c.catalog.GetTable(rel) + if err != nil { + merr.Add(filename, contents, stmts[i].Pos(), err) + continue + } + // Track catalog identity through subsequent ALTER/RENAME. + c.wicked.PrimaryRelation = table.Rel + } + } + } } } if len(merr.Errs()) > 0 { return merr } + if c.wicked != nil && c.wicked.PrimaryRelation == nil { + return fmt.Errorf("wicked: primary schema %q has no supported table layout", c.wicked.PrimarySchemaPath) + } + if c.wicked != nil { + identity := *c.wicked.PrimaryRelation + if identity.Schema == "" { + identity.Schema = c.catalog.DefaultSchema + } + c.wicked.PrimaryRelation = &identity + } return nil } @@ -137,5 +181,6 @@ func (c *Compiler) parseQueries(o opts.Parser) (*Result, error) { return &Result{ Catalog: c.catalog, Queries: q, + Wicked: c.wicked, }, nil } diff --git a/internal/compiler/engine.go b/internal/compiler/engine.go index 64fdf3d5c7..9d8ff17db5 100644 --- a/internal/compiler/engine.go +++ b/internal/compiler/engine.go @@ -28,6 +28,7 @@ type Compiler struct { selector selector schema []string + wicked *WickedSchema // databaseOnlyMode indicates that the compiler should use database-only analysis // and skip building the internal catalog from schema files (analyzer.database: only) diff --git a/internal/compiler/parameter_context_test.go b/internal/compiler/parameter_context_test.go new file mode 100644 index 0000000000..964498fe64 --- /dev/null +++ b/internal/compiler/parameter_context_test.go @@ -0,0 +1,144 @@ +package compiler + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/multierr" + "github.com/sqlc-dev/sqlc/internal/opts" +) + +func TestParameterContextInference(t *testing.T) { + for _, tc := range []struct { + name, condition string + }{ + {"null_first", "sqlc.narg('id') IS NULL OR id = sqlc.narg('id')"}, + {"cast_after_null", "sqlc.narg('id') IS NULL OR id = sqlc.narg('id')::bigint"}, + {"comparison_first", "id = sqlc.narg('id') OR sqlc.narg('id') IS NULL"}, + {"cast_first", "sqlc.narg('id')::bigint IS NULL OR id = sqlc.narg('id')"}, + } { + t.Run(tc.name, func(t *testing.T) { + q := compileParameterQuery(t, "SELECT * FROM things WHERE ("+tc.condition+") AND name = sqlc.arg('name');") + if len(q.Params) != 2 { + t.Fatalf("parameters = %d, want 2", len(q.Params)) + } + id, name := q.Params[0], q.Params[1] + if id.Number != 1 || id.Column.Name != "id" || strings.TrimPrefix(id.Column.DataType, "pg_catalog.") != "int8" || id.Column.NotNull { + t.Errorf("id parameter: number=%d column=%+v; want nullable bigint parameter 1", id.Number, id.Column) + } + if name.Number != 2 || name.Column.Name != "name" || name.Column.DataType != "text" || !name.Column.NotNull { + t.Errorf("name parameter: number=%d column=%+v; want non-null text parameter 2", name.Number, name.Column) + } + }) + } +} + +func TestParameterContextPreservesInsertBinding(t *testing.T) { + q := compileParameterQuery(t, `WITH previous AS ( + SELECT name FROM things WHERE id = $1 +) +INSERT INTO things (id, name) +SELECT $1, @name::text +WHERE NOT EXISTS (SELECT * FROM previous WHERE previous.name = @name::text) +RETURNING *;`) + if len(q.Params) != 2 { + t.Fatalf("parameters = %d, want 2", len(q.Params)) + } + id := q.Params[0] + if id.Number != 1 || id.Column.Name != "id" || !id.Column.NotNull { + t.Fatalf("lost INSERT target binding: %+v", id.Column) + } +} + +func TestParameterContextNullability(t *testing.T) { + for _, tc := range []struct { + name, query, dataType string + notNull bool + }{ + {"id_comparison", "UPDATE things SET optional_id = @id WHERE id <> @id RETURNING *;", "int8", true}, + {"time_comparison", "UPDATE things SET ended_at = @now WHERE expires_at <= @now RETURNING *;", "timestamptz", true}, + {"nullable_assignment", "UPDATE things SET optional_id = @id RETURNING *;", "int8", false}, + {"explicit_nullable", "UPDATE things SET optional_id = sqlc.narg('id') WHERE id <> sqlc.narg('id') RETURNING *;", "int8", false}, + {"positional", "UPDATE things SET optional_id = $1 WHERE id <> $1 RETURNING *;", "int8", true}, + } { + t.Run(tc.name, func(t *testing.T) { + q := compileParameterQuery(t, tc.query) + if len(q.Params) != 1 { + t.Fatalf("parameters = %d, want 1", len(q.Params)) + } + col := q.Params[0].Column + if strings.TrimPrefix(col.DataType, "pg_catalog.") != tc.dataType || col.NotNull != tc.notNull { + t.Fatalf("parameter = %+v, want %s not-null=%v", col, tc.dataType, tc.notNull) + } + }) + } +} + +func TestParameterContextRepeatedRelation(t *testing.T) { + q := compileParameterQuery(t, `WITH previous AS ( + SELECT name FROM things WHERE id = $1 +) +SELECT * FROM things WHERE name IN (SELECT name FROM previous);`) + if len(q.Params) != 1 || !q.Params[0].Column.NotNull || q.Params[0].Column.Name != "id" { + t.Fatalf("parameter lost across repeated relation: %+v", q.Params) + } +} + +func TestParameterContextInsertSelectSameTable(t *testing.T) { + q := compileParameterQuery(t, `INSERT INTO things (id, name, expires_at) +SELECT @id, name, expires_at FROM things WHERE name = @name RETURNING *;`) + if len(q.Params) != 2 { + t.Fatalf("parameters = %d, want 2", len(q.Params)) + } + for i, want := range []string{"id", "name"} { + if col := q.Params[i].Column; col.Name != want || !col.NotNull { + t.Errorf("parameter %d = %+v, want non-null %s", i, col, want) + } + } +} + +func TestParameterContextPreservesSelfJoinAmbiguity(t *testing.T) { + c := parameterCompiler(t, `SELECT lhs.name FROM things lhs JOIN things rhs ON lhs.name = rhs.name WHERE id = $1;`) + err := c.ParseQueries(c.conf.Queries, opts.Parser{}) + errs, ok := err.(*multierr.Error) + if !ok || len(errs.Errs()) != 1 || !strings.Contains(errs.Errs()[0].Err.Error(), "ambiguous") { + t.Fatalf("unqualified self-join parameter: %v, want ambiguity error", err) + } +} + +func compileParameterQuery(t *testing.T, query string) *Query { + t.Helper() + c := parameterCompiler(t, query) + if err := c.ParseQueries(c.conf.Queries, opts.Parser{}); err != nil { + t.Fatal(err) + } + return c.Result().Queries[0] +} + +func parameterCompiler(t *testing.T, query string) *Compiler { + t.Helper() + dir := t.TempDir() + schemaPath, queryPath := filepath.Join(dir, "schema.sql"), filepath.Join(dir, "query.sql") + for path, contents := range map[string]string{ + schemaPath: "CREATE TABLE things (id bigint NOT NULL, name text NOT NULL, optional_id bigint, ended_at timestamptz, expires_at timestamptz NOT NULL);", + queryPath: "-- name: FindThings :many\n" + query, + } { + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + conf := config.SQL{Engine: config.EnginePostgreSQL, Schema: []string{schemaPath}, Queries: []string{queryPath}} + c, err := NewCompiler(conf, config.Combine(config.Config{Version: "2"}, conf), opts.Parser{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close(context.Background()) }) + if err := c.ParseCatalog([]string{schemaPath}); err != nil { + t.Fatal(err) + } + return c +} diff --git a/internal/compiler/parse.go b/internal/compiler/parse.go index 751cb3271a..ba554214bf 100644 --- a/internal/compiler/parse.go +++ b/internal/compiler/parse.go @@ -193,28 +193,130 @@ func rangeVars(root ast.Node) []*ast.RangeVar { return vars } +// scoreParamRefForTypeInference scores a parameter reference based on how good +// its context is for type inference. Higher scores indicate better contexts. +func scoreParamRefForTypeInference(ref paramRef) int { + if ref.parent == nil { + return 0 // No context + } + + switch parent := ref.parent.(type) { + case *ast.TypeCast: + // Explicit type cast - excellent for type inference + return 100 + + case *ast.A_Expr: + // Expression context - quality depends on the operator + if parent.Name != nil && len(parent.Name.Items) > 0 { + if nameStr, ok := parent.Name.Items[0].(*ast.String); ok { + switch nameStr.Str { + case "=", "==", "!=", "<>", "<", "<=", ">", ">=": + // Comparison operations - very good for type inference + return 100 + case "+", "-", "*", "/", "%": + // Mathematical operations - good for type inference + return 90 + case "||": + // String concatenation - good for type inference + return 90 + case "~~", "!~~", "~~*", "!~~*": + // LIKE operations - good for type inference + return 90 + case "IS", "IS NOT": + // IS NULL/IS NOT NULL - poor for type inference + return 0 + default: + return 50 + } + } + } + return 50 // Default for A_Expr without clear operator + + case *ast.BoolExpr: + // Boolean expressions + switch parent.Boolop { + case ast.BoolExprTypeAnd, ast.BoolExprTypeOr: + // Logical operations - still useful but lower priority + return 60 + case ast.BoolExprTypeIsNull, ast.BoolExprTypeIsNotNull: + // IS NULL/IS NOT NULL - poor for type inference + return 20 + case ast.BoolExprTypeNot: + // NOT operations - moderate for type inference + return 50 + default: + return 40 + } + + case *ast.BetweenExpr: + // BETWEEN expressions - good for type inference + return 75 + + case *ast.FuncCall: + // Function call context - depends on function, generally moderate + // sqlc.narg() and similar functions have poor type inference context + if parent.Funcname != nil && len(parent.Funcname.Items) > 0 { + if nameStr, ok := parent.Funcname.Items[0].(*ast.String); ok { + if nameStr.Str == "sqlc.narg" || nameStr.Str == "sqlc.arg" { + // sqlc parameter functions in isolation - poor for type inference + return 30 + } + } + } + return 40 + + case *ast.ResTarget: + // Preserve the existing preference for comparisons over assignments. + // A nullable assignment column does not by itself make every use of + // the parameter nullable; explicit sqlc.narg still takes precedence. + return 60 + + case *ast.In: + // IN expression - good for type inference + return 70 + + case *limitCount, *limitOffset: + // LIMIT/OFFSET - known to be integer, good for type inference + return 90 + + default: + // Unknown context - assign low score + return 10 + } +} + func uniqueParamRefs(in []paramRef, dollar bool) []paramRef { - m := make(map[int]bool, len(in)) - o := make([]paramRef, 0, len(in)) - for _, v := range in { - if !m[v.ref.Number] { - m[v.ref.Number] = true - if v.ref.Number != 0 { - o = append(o, v) + positions := make(map[int]int, len(in)) + out := make([]paramRef, 0, len(in)) + for _, ref := range in { + if ref.ref.Number == 0 { + continue + } + if index, ok := positions[ref.ref.Number]; ok { + if scoreParamRefForTypeInference(ref) > scoreParamRefForTypeInference(out[index]) { + out[index] = ref } + } else { + positions[ref.ref.Number] = len(out) + out = append(out, ref) } } if !dollar { - start := 1 - for _, v := range in { - if v.ref.Number == 0 { - for m[start] { - start++ + next := 1 + for _, ref := range in { + if ref.ref.Number != 0 { + continue + } + for { + if _, used := positions[next]; !used { + break } - v.ref.Number = start - o = append(o, v) + next++ } + ref.ref.Number = next + positions[next] = len(out) + out = append(out, ref) } } - return o + return out } diff --git a/internal/compiler/resolve.go b/internal/compiler/resolve.go index d926f2b1fc..027b517ef7 100644 --- a/internal/compiler/resolve.go +++ b/internal/compiler/resolve.go @@ -50,6 +50,11 @@ func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar, return nil } + // The same unaliased relation can occur in multiple query scopes (for + // example a CTE followed by INSERT into that table). Index it once rather + // than reporting its column twice as an ambiguous parameter context. + // Distinct aliases remain distinct inputs, including in self-joins. + unaliased := make(map[ast.TableName]bool) for _, rv := range rvs { if rv.Relname == nil { continue @@ -72,6 +77,16 @@ func (comp *Compiler) resolveCatalogRefs(qc *QueryCatalog, rvs []*ast.RangeVar, } continue } + if rv.Alias == nil { + identity := *table.Rel + if identity.Schema == "" { + identity.Schema = c.DefaultSchema + } + if unaliased[identity] { + continue + } + unaliased[identity] = true + } err = indexTable(table) if err != nil { return nil, err diff --git a/internal/compiler/result.go b/internal/compiler/result.go index 3647da630f..971a9de42d 100644 --- a/internal/compiler/result.go +++ b/internal/compiler/result.go @@ -7,4 +7,5 @@ import ( type Result struct { Catalog *catalog.Catalog Queries []*Query + Wicked *WickedSchema } diff --git a/internal/compiler/wicked.go b/internal/compiler/wicked.go new file mode 100644 index 0000000000..653953e727 --- /dev/null +++ b/internal/compiler/wicked.go @@ -0,0 +1,47 @@ +package compiler + +import "github.com/sqlc-dev/sqlc/internal/sql/ast" + +// WickedSchema records source ownership without adding generation policy to +// the shared SQL catalog. Dependency tables remain available for type analysis. +type WickedSchema struct { + PrimarySchemaPath string + PrimarySchemaSQL string + PrimaryRelation *ast.TableName +} + +// Model ownership and layout counting are separate legacy conventions. Tables +// with inherited columns (or columns added by ALTER) can own the main model +// even though the CREATE statement does not declare a new column layout. +func wickedRelation(stmt ast.Statement) *ast.TableName { + if stmt.Raw == nil { + return nil + } + switch n := stmt.Raw.Stmt.(type) { + case *ast.CreateTableStmt: + return n.Name + case *ast.CreateTableAsStmt: + if n.Into != nil && n.Into.Rel != nil { + rel, err := ParseTableName(n.Into.Rel) + if err == nil { + return rel + } + } + } + return nil +} + +// Physical partitions do not introduce another logical layout. Ordinary views +// were not marked GenerateModel by the legacy catalog and remain unsupported. +func wickedCreatesLayout(stmt ast.Statement) bool { + if stmt.Raw == nil { + return false + } + switch n := stmt.Raw.Stmt.(type) { + case *ast.CreateTableStmt: + return len(n.Cols) > 0 + case *ast.CreateTableAsStmt: + return true + } + return false +} diff --git a/internal/compiler/wicked_test.go b/internal/compiler/wicked_test.go new file mode 100644 index 0000000000..d66f1d38df --- /dev/null +++ b/internal/compiler/wicked_test.go @@ -0,0 +1,83 @@ +package compiler + +import ( + "context" + "os" + "path/filepath" + "testing" + + goopts "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/opts" +) + +func TestWickedSchemaOwnership(t *testing.T) { + for _, tc := range []struct { + name string + primary string + want string + invalid bool + }{ + {"table", "CREATE TABLE orders (id bigint, book_id bigint);", "orders", false}, + {"inherited", "CREATE TABLE orders () INHERITS (books);", "orders", false}, + {"inherited_renamed", "CREATE TABLE initial () INHERITS (books); ALTER TABLE initial RENAME TO orders;", "orders", false}, + {"empty_then_altered", "CREATE TABLE orders (); ALTER TABLE orders ADD COLUMN id bigint;", "orders", false}, + {"renamed_table", "CREATE TABLE initial (id bigint); ALTER TABLE initial RENAME TO renamed;", "renamed", false}, + {"materialized_view", "CREATE MATERIALIZED VIEW revenues AS SELECT id FROM books;", "revenues", false}, + {"partition", "CREATE TABLE events (id bigint) PARTITION BY RANGE (id); CREATE TABLE events_small PARTITION OF events FOR VALUES FROM (0) TO (100);", "events", false}, + {"two_layouts", "CREATE TABLE a (id bigint); CREATE TABLE b (id bigint);", "", true}, + {"no_layout", "CREATE TYPE category AS ENUM ('a');", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + primary, dependency := filepath.Join(dir, "primary.sql"), filepath.Join(dir, "books.sql") + for path, sql := range map[string]string{primary: tc.primary, dependency: "CREATE TABLE books (id bigint NOT NULL);"} { + if err := os.WriteFile(path, []byte(sql), 0600); err != nil { + t.Fatal(err) + } + } + conf := config.SQL{Engine: config.EnginePostgreSQL, Gen: config.SQLGen{Go: &goopts.Options{SqlPackage: "wpgx"}}} + c, err := NewCompiler(conf, config.Combine(config.Config{Version: "2"}, conf), opts.Parser{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close(context.Background()) }) + err = c.ParseCatalog([]string{primary, dependency}) + if tc.invalid { + if err == nil { + t.Fatal("expected invalid primary layout") + } + return + } + if err != nil { + t.Fatal(err) + } + if c.wicked.PrimaryRelation.Name != tc.want || c.wicked.PrimaryRelation.Schema != "public" { + t.Fatalf("primary = %+v, want public.%s", c.wicked.PrimaryRelation, tc.want) + } + if c.wicked.PrimarySchemaSQL != tc.primary || c.wicked.PrimarySchemaPath != primary { + t.Fatal("primary schema source was changed or replaced by a dependency") + } + }) + } +} + +func TestStandardSchemasKeepUpstreamSemantics(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "schema.sql") + if err := os.WriteFile(file, []byte("CREATE TABLE a (id bigint); CREATE TABLE b (id bigint);"), 0600); err != nil { + t.Fatal(err) + } + conf := config.SQL{Engine: config.EnginePostgreSQL} + c, err := NewCompiler(conf, config.Combine(config.Config{Version: "2"}, conf), opts.Parser{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close(context.Background()) }) + if err := c.ParseCatalog([]string{file}); err != nil { + t.Fatal(err) + } + if c.wicked != nil { + t.Fatal("standard schema acquired wicked metadata") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d3e610ef05..c6da6207b8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -122,11 +122,16 @@ type SQL struct { Analyzer Analyzer `json:"analyzer" yaml:"analyzer"` } +// IsWicked enables the legacy wpgx schema conventions only for that backend. +func (s SQL) IsWicked() bool { + return s.Gen.Go != nil && s.Gen.Go.SqlPackage == "wpgx" +} + // AnalyzerDatabase represents the database analyzer setting. // It can be a boolean (true/false) or the string "only" for database-only mode. type AnalyzerDatabase struct { - value *bool // nil means not set, true/false for boolean values - isOnly bool // true when set to "only" + value *bool // nil means not set, true/false for boolean values + isOnly bool // true when set to "only" } // IsEnabled returns true if the database analyzer should be used. diff --git a/internal/config/validate.go b/internal/config/validate.go index fadef4fb3b..d5d7064952 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -1,7 +1,26 @@ package config +import ( + "fmt" + "path/filepath" +) + func Validate(c *Config) error { + packages := make(map[string]bool) for _, sql := range c.SQL { + if sql.IsWicked() { + if sql.Engine != EnginePostgreSQL { + return fmt.Errorf("wpgx requires the postgresql engine") + } + name := sql.Gen.Go.Package + if name == "" { + name = filepath.Base(sql.Gen.Go.Out) + } + if packages[name] { + return fmt.Errorf("duplicated package name is not allowed: %s", name) + } + packages[name] = true + } if sql.Database != nil { if sql.Database.URI == "" && !sql.Database.Managed { return ErrInvalidDatabase diff --git a/internal/config/wicked_test.go b/internal/config/wicked_test.go new file mode 100644 index 0000000000..3e141ead8e --- /dev/null +++ b/internal/config/wicked_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "testing" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" +) + +func TestWickedPackageUniqueness(t *testing.T) { + for _, driver := range []string{"wpgx", "pgx/v5"} { + c := Config{SQL: []SQL{ + {Engine: EnginePostgreSQL, Gen: SQLGen{Go: &opts.Options{Package: "books", Out: "one", SqlPackage: driver}}}, + {Engine: EnginePostgreSQL, Gen: SQLGen{Go: &opts.Options{Package: "books", Out: "two", SqlPackage: driver}}}, + }} + err := Validate(&c) + if (err != nil) != (driver == "wpgx") { + t.Fatalf("driver=%s validation=%v", driver, err) + } + } +} + +func TestWickedEffectivePackageUniqueness(t *testing.T) { + c := Config{SQL: []SQL{ + {Engine: EnginePostgreSQL, Gen: SQLGen{Go: &opts.Options{Out: "one/books", SqlPackage: "wpgx"}}}, + {Engine: EnginePostgreSQL, Gen: SQLGen{Go: &opts.Options{Out: "two/books", SqlPackage: "wpgx"}}}, + }} + if err := Validate(&c); err == nil { + t.Fatal("duplicate effective package names must not share cache keys") + } +} diff --git a/internal/endtoend/ddl_test.go b/internal/endtoend/ddl_test.go index bed9333743..3f435fb9d9 100644 --- a/internal/endtoend/ddl_test.go +++ b/internal/endtoend/ddl_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "testing" "github.com/sqlc-dev/sqlc/internal/config" @@ -48,6 +49,9 @@ func TestValidSchema(t *testing.T) { for _, path := range pkg.Schema { schema = append(schema, filepath.Join(filepath.Dir(file), path)) } + if pkg.IsWicked() { + slices.Reverse(schema) + } switch pkg.Engine { case config.EnginePostgreSQL: diff --git a/internal/endtoend/testdata/insert_select_param/postgresql/pgx/exec.json b/internal/endtoend/testdata/insert_select_param/postgresql/pgx/exec.json index ee1b7ecd9e..5958e7f5bf 100644 --- a/internal/endtoend/testdata/insert_select_param/postgresql/pgx/exec.json +++ b/internal/endtoend/testdata/insert_select_param/postgresql/pgx/exec.json @@ -1,3 +1,3 @@ { - "contexts": ["managed-db"] + "contexts": ["base", "managed-db"] } diff --git a/internal/endtoend/testdata/insert_select_param/postgresql/pgx/go/query.sql.go b/internal/endtoend/testdata/insert_select_param/postgresql/pgx/go/query.sql.go index eaa742192a..c6b13c8071 100644 --- a/internal/endtoend/testdata/insert_select_param/postgresql/pgx/go/query.sql.go +++ b/internal/endtoend/testdata/insert_select_param/postgresql/pgx/go/query.sql.go @@ -7,8 +7,6 @@ package querytest import ( "context" - - "github.com/jackc/pgx/v5/pgtype" ) const insertSelect = `-- name: InsertSelect :exec @@ -19,8 +17,8 @@ WHERE name = $2 ` type InsertSelectParams struct { - ID pgtype.Int8 - Name pgtype.Text + ID int64 + Name string } func (q *Queries) InsertSelect(ctx context.Context, arg InsertSelectParams) error { diff --git a/internal/info/facts.go b/internal/info/facts.go index 934814e365..d73119f831 100644 --- a/internal/info/facts.go +++ b/internal/info/facts.go @@ -2,4 +2,4 @@ package info // When no version is set, return the next bug fix version // after the most recent tag -const Version = "v1.31.1" +var Version = "v1.31.1" diff --git a/internal/plugin/codegen.pb.go b/internal/plugin/codegen.pb.go index 525ffc72ef..c82aedc46f 100644 --- a/internal/plugin/codegen.pb.go +++ b/internal/plugin/codegen.pb.go @@ -972,6 +972,12 @@ type GenerateRequest struct { SqlcVersion string `protobuf:"bytes,4,opt,name=sqlc_version,proto3" json:"sqlc_version,omitempty"` PluginOptions []byte `protobuf:"bytes,5,opt,name=plugin_options,proto3" json:"plugin_options,omitempty"` GlobalOptions []byte `protobuf:"bytes,6,opt,name=global_options,proto3" json:"global_options,omitempty"` + // Fork metadata is absent for standard generators, including in JSON output. + // + // Types that are assignable to BackendMetadata: + // + // *GenerateRequest_Wicked + BackendMetadata isGenerateRequest_BackendMetadata `protobuf_oneof:"backend_metadata"` } func (x *GenerateRequest) Reset() { @@ -1048,6 +1054,104 @@ func (x *GenerateRequest) GetGlobalOptions() []byte { return nil } +func (m *GenerateRequest) GetBackendMetadata() isGenerateRequest_BackendMetadata { + if m != nil { + return m.BackendMetadata + } + return nil +} + +func (x *GenerateRequest) GetWicked() *WickedMetadata { + if x, ok := x.GetBackendMetadata().(*GenerateRequest_Wicked); ok { + return x.Wicked + } + return nil +} + +type isGenerateRequest_BackendMetadata interface { + isGenerateRequest_BackendMetadata() +} + +type GenerateRequest_Wicked struct { + Wicked *WickedMetadata `protobuf:"bytes,1000,opt,name=wicked,proto3,oneof"` +} + +func (*GenerateRequest_Wicked) isGenerateRequest_BackendMetadata() {} + +// Facts collected by the compiler for wicked generation. Query options remain +// in Query.comments and are interpreted by the backend. +type WickedMetadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PrimarySchemaPath string `protobuf:"bytes,1,opt,name=primary_schema_path,json=primarySchemaPath,proto3" json:"primary_schema_path,omitempty"` + PrimarySchemaSql string `protobuf:"bytes,2,opt,name=primary_schema_sql,json=primarySchemaSql,proto3" json:"primary_schema_sql,omitempty"` + PrimaryRelation *Identifier `protobuf:"bytes,3,opt,name=primary_relation,json=primaryRelation,proto3" json:"primary_relation,omitempty"` + // This means a top-level SELECT, not a proof that a query is read-only. + QueryIsSelect map[string]bool `protobuf:"bytes,4,rep,name=query_is_select,json=queryIsSelect,proto3" json:"query_is_select,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *WickedMetadata) Reset() { + *x = WickedMetadata{} + if protoimpl.UnsafeEnabled { + mi := &file_plugin_codegen_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WickedMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WickedMetadata) ProtoMessage() {} + +func (x *WickedMetadata) ProtoReflect() protoreflect.Message { + mi := &file_plugin_codegen_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WickedMetadata.ProtoReflect.Descriptor instead. +func (*WickedMetadata) Descriptor() ([]byte, []int) { + return file_plugin_codegen_proto_rawDescGZIP(), []int{13} +} + +func (x *WickedMetadata) GetPrimarySchemaPath() string { + if x != nil { + return x.PrimarySchemaPath + } + return "" +} + +func (x *WickedMetadata) GetPrimarySchemaSql() string { + if x != nil { + return x.PrimarySchemaSql + } + return "" +} + +func (x *WickedMetadata) GetPrimaryRelation() *Identifier { + if x != nil { + return x.PrimaryRelation + } + return nil +} + +func (x *WickedMetadata) GetQueryIsSelect() map[string]bool { + if x != nil { + return x.QueryIsSelect + } + return nil +} + type GenerateResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1059,7 +1163,7 @@ type GenerateResponse struct { func (x *GenerateResponse) Reset() { *x = GenerateResponse{} if protoimpl.UnsafeEnabled { - mi := &file_plugin_codegen_proto_msgTypes[13] + mi := &file_plugin_codegen_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1072,7 +1176,7 @@ func (x *GenerateResponse) String() string { func (*GenerateResponse) ProtoMessage() {} func (x *GenerateResponse) ProtoReflect() protoreflect.Message { - mi := &file_plugin_codegen_proto_msgTypes[13] + mi := &file_plugin_codegen_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1085,7 +1189,7 @@ func (x *GenerateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GenerateResponse.ProtoReflect.Descriptor instead. func (*GenerateResponse) Descriptor() ([]byte, []int) { - return file_plugin_codegen_proto_rawDescGZIP(), []int{13} + return file_plugin_codegen_proto_rawDescGZIP(), []int{14} } func (x *GenerateResponse) GetFiles() []*File { @@ -1106,7 +1210,7 @@ type Codegen_Process struct { func (x *Codegen_Process) Reset() { *x = Codegen_Process{} if protoimpl.UnsafeEnabled { - mi := &file_plugin_codegen_proto_msgTypes[14] + mi := &file_plugin_codegen_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1119,7 +1223,7 @@ func (x *Codegen_Process) String() string { func (*Codegen_Process) ProtoMessage() {} func (x *Codegen_Process) ProtoReflect() protoreflect.Message { - mi := &file_plugin_codegen_proto_msgTypes[14] + mi := &file_plugin_codegen_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1154,7 +1258,7 @@ type Codegen_WASM struct { func (x *Codegen_WASM) Reset() { *x = Codegen_WASM{} if protoimpl.UnsafeEnabled { - mi := &file_plugin_codegen_proto_msgTypes[15] + mi := &file_plugin_codegen_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1167,7 +1271,7 @@ func (x *Codegen_WASM) String() string { func (*Codegen_WASM) ProtoMessage() {} func (x *Codegen_WASM) ProtoReflect() protoreflect.Message { - mi := &file_plugin_codegen_proto_msgTypes[15] + mi := &file_plugin_codegen_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1233,7 +1337,7 @@ var file_plugin_codegen_proto_rawDesc = []byte{ 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x63, 0x6d, 0x64, 0x1a, 0x30, 0x0a, 0x04, 0x57, 0x41, 0x53, 0x4d, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x68, 0x61, 0x32, 0x35, 0x36, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x68, 0x61, 0x32, 0x35, 0x36, 0x22, 0x88, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x68, 0x61, 0x32, 0x35, 0x36, 0x22, 0x98, 0x01, 0x0a, 0x07, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, @@ -1242,7 +1346,8 @@ var file_plugin_codegen_proto_rawDesc = []byte{ 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, - 0x52, 0x07, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x22, 0xc1, 0x01, 0x0a, 0x06, 0x53, 0x63, + 0x52, 0x07, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x52, + 0x08, 0x72, 0x61, 0x77, 0x5f, 0x73, 0x71, 0x6c, 0x73, 0x22, 0xc1, 0x01, 0x0a, 0x06, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, @@ -1263,108 +1368,135 @@ var file_plugin_codegen_proto_rawDesc = []byte{ 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x76, 0x61, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x76, 0x61, 0x6c, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, - 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x71, 0x0a, 0x05, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, - 0x24, 0x0a, 0x03, 0x72, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, - 0x52, 0x03, 0x72, 0x65, 0x6c, 0x12, 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, - 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12, - 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x52, 0x0a, 0x0a, 0x49, 0x64, 0x65, - 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, - 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, - 0x67, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x8e, 0x04, - 0x0a, 0x06, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, - 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, - 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, - 0x72, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, - 0x61, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x16, 0x0a, 0x06, - 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6c, 0x65, - 0x6e, 0x67, 0x74, 0x68, 0x12, 0x24, 0x0a, 0x0e, 0x69, 0x73, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x64, - 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x69, 0x73, - 0x4e, 0x61, 0x6d, 0x65, 0x64, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x12, 0x20, 0x0a, 0x0c, 0x69, 0x73, - 0x5f, 0x66, 0x75, 0x6e, 0x63, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0a, 0x69, 0x73, 0x46, 0x75, 0x6e, 0x63, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x14, 0x0a, 0x05, - 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, - 0x70, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, - 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x1f, 0x0a, 0x0b, - 0x74, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0a, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x12, 0x26, 0x0a, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, - 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x73, 0x71, 0x6c, 0x63, - 0x5f, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, - 0x53, 0x71, 0x6c, 0x63, 0x53, 0x6c, 0x69, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x0b, 0x65, 0x6d, 0x62, - 0x65, 0x64, 0x5f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, - 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, - 0x65, 0x72, 0x52, 0x0a, 0x65, 0x6d, 0x62, 0x65, 0x64, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x23, - 0x0a, 0x0d, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x4e, - 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x6e, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, - 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x75, 0x6e, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x12, - 0x1d, 0x0a, 0x0a, 0x61, 0x72, 0x72, 0x61, 0x79, 0x5f, 0x64, 0x69, 0x6d, 0x73, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x09, 0x61, 0x72, 0x72, 0x61, 0x79, 0x44, 0x69, 0x6d, 0x73, 0x22, 0x94, - 0x02, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x12, 0x10, 0x0a, 0x03, 0x63, 0x6d, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x63, - 0x6d, 0x64, 0x12, 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x04, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, - 0x75, 0x6d, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x06, - 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, - 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, - 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, - 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x40, 0x0a, 0x11, 0x69, 0x6e, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x69, 0x6e, - 0x74, 0x6f, 0x5f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, - 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, - 0x65, 0x72, 0x52, 0x11, 0x69, 0x6e, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x74, 0x6f, 0x5f, - 0x74, 0x61, 0x62, 0x6c, 0x65, 0x22, 0x4b, 0x0a, 0x09, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, - 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x06, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x63, 0x6f, - 0x6c, 0x75, 0x6d, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, - 0x67, 0x69, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52, 0x06, 0x63, 0x6f, 0x6c, 0x75, - 0x6d, 0x6e, 0x22, 0x87, 0x02, 0x0a, 0x0f, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, - 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x08, 0x73, 0x65, 0x74, 0x74, - 0x69, 0x6e, 0x67, 0x73, 0x12, 0x29, 0x0a, 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x43, - 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x52, 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x12, - 0x27, 0x0a, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x0d, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, - 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x71, 0x6c, 0x63, - 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x73, 0x71, 0x6c, 0x63, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x26, 0x0a, 0x0e, - 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x6f, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x67, 0x6c, 0x6f, 0x62, 0x61, 0x6c, 0x5f, 0x6f, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x67, 0x6c, - 0x6f, 0x62, 0x61, 0x6c, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x36, 0x0a, 0x10, - 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x22, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x0c, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, - 0x69, 0x6c, 0x65, 0x73, 0x32, 0x4f, 0x0a, 0x0e, 0x43, 0x6f, 0x64, 0x65, 0x67, 0x65, 0x6e, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x3d, 0x0a, 0x08, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, - 0x74, 0x65, 0x12, 0x17, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65, 0x6e, 0x65, - 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x6c, - 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x7c, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x2e, 0x70, 0x6c, 0x75, - 0x67, 0x69, 0x6e, 0x42, 0x0c, 0x43, 0x6f, 0x64, 0x65, 0x67, 0x65, 0x6e, 0x50, 0x72, 0x6f, 0x74, - 0x6f, 0x50, 0x01, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x73, 0x71, 0x6c, 0x63, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x73, 0x71, 0x6c, 0x63, 0x2f, 0x69, 0x6e, - 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0xa2, 0x02, 0x03, - 0x50, 0x58, 0x58, 0xaa, 0x02, 0x06, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0xca, 0x02, 0x06, 0x50, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0xe2, 0x02, 0x12, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5c, 0x47, - 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x06, 0x50, 0x6c, 0x75, - 0x67, 0x69, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x87, 0x01, 0x0a, 0x05, 0x54, 0x61, 0x62, 0x6c, 0x65, + 0x12, 0x24, 0x0a, 0x03, 0x72, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x52, 0x03, 0x72, 0x65, 0x6c, 0x12, 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, + 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, + 0x52, 0x0e, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, + 0x22, 0x52, 0x0a, 0x0a, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x18, + 0x0a, 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, + 0x6d, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x8e, 0x04, 0x0a, 0x06, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, 0x19, + 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, 0x72, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, 0x61, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, + 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x65, 0x6e, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x12, 0x24, 0x0a, 0x0e, 0x69, + 0x73, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0c, 0x69, 0x73, 0x4e, 0x61, 0x6d, 0x65, 0x64, 0x50, 0x61, 0x72, 0x61, + 0x6d, 0x12, 0x20, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x66, 0x75, 0x6e, 0x63, 0x5f, 0x63, 0x61, 0x6c, + 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, 0x73, 0x46, 0x75, 0x6e, 0x63, 0x43, + 0x61, 0x6c, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x74, 0x61, 0x62, + 0x6c, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x05, 0x74, 0x61, + 0x62, 0x6c, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x61, 0x6c, 0x69, + 0x61, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x41, + 0x6c, 0x69, 0x61, 0x73, 0x12, 0x26, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0c, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x22, 0x0a, 0x0d, + 0x69, 0x73, 0x5f, 0x73, 0x71, 0x6c, 0x63, 0x5f, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x18, 0x0d, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x53, 0x71, 0x6c, 0x63, 0x53, 0x6c, 0x69, 0x63, 0x65, + 0x12, 0x33, 0x0a, 0x0b, 0x65, 0x6d, 0x62, 0x65, 0x64, 0x5f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, + 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0a, 0x65, 0x6d, 0x62, 0x65, 0x64, + 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, + 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x6e, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x75, 0x6e, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x72, 0x72, 0x61, 0x79, 0x5f, + 0x64, 0x69, 0x6d, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x61, 0x72, 0x72, 0x61, + 0x79, 0x44, 0x69, 0x6d, 0x73, 0x22, 0xa3, 0x02, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, + 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, + 0x65, 0x78, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x6d, 0x64, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x63, 0x6d, 0x64, 0x12, 0x28, 0x0a, 0x07, 0x63, 0x6f, 0x6c, + 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, + 0x67, 0x69, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, + 0x6d, 0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x50, 0x61, 0x72, + 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, + 0x72, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x06, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x1a, + 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x40, 0x0a, 0x11, 0x69, 0x6e, + 0x73, 0x65, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x74, 0x6f, 0x5f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x11, 0x69, 0x6e, 0x73, 0x65, 0x72, + 0x74, 0x5f, 0x69, 0x6e, 0x74, 0x6f, 0x5f, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x4a, 0x04, 0x08, 0x09, + 0x10, 0x0a, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x4b, 0x0a, 0x09, 0x50, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x6e, 0x75, 0x6d, 0x62, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, + 0x12, 0x26, 0x0a, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x0e, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, + 0x52, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x22, 0xce, 0x02, 0x0a, 0x0f, 0x47, 0x65, 0x6e, + 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x08, + 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, + 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x52, 0x08, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x29, 0x0a, 0x07, 0x63, 0x61, + 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x6c, + 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x52, 0x07, 0x63, 0x61, + 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x12, 0x27, 0x0a, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x12, 0x22, + 0x0a, 0x0c, 0x73, 0x71, 0x6c, 0x63, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x71, 0x6c, 0x63, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x26, 0x0a, 0x0e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x6f, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x70, 0x6c, 0x75, 0x67, + 0x69, 0x6e, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x67, 0x6c, + 0x6f, 0x62, 0x61, 0x6c, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x0e, 0x67, 0x6c, 0x6f, 0x62, 0x61, 0x6c, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x31, 0x0a, 0x06, 0x77, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x18, 0xe8, 0x07, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x57, 0x69, 0x63, + 0x6b, 0x65, 0x64, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x48, 0x00, 0x52, 0x06, 0x77, + 0x69, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x12, 0x0a, 0x10, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, + 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc2, 0x02, 0x0a, 0x0e, 0x57, 0x69, + 0x63, 0x6b, 0x65, 0x64, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2e, 0x0a, 0x13, + 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x70, 0x72, 0x69, 0x6d, 0x61, + 0x72, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x50, 0x61, 0x74, 0x68, 0x12, 0x2c, 0x0a, 0x12, + 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x73, + 0x71, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, + 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x53, 0x71, 0x6c, 0x12, 0x3d, 0x0a, 0x10, 0x70, 0x72, + 0x69, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0f, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, + 0x79, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x51, 0x0a, 0x0f, 0x71, 0x75, 0x65, + 0x72, 0x79, 0x5f, 0x69, 0x73, 0x5f, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x18, 0x04, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x57, 0x69, 0x63, 0x6b, + 0x65, 0x64, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, + 0x49, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x71, + 0x75, 0x65, 0x72, 0x79, 0x49, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x1a, 0x40, 0x0a, 0x12, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x49, 0x73, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x36, + 0x0a, 0x10, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x22, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x0c, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, + 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x32, 0x4f, 0x0a, 0x0e, 0x43, 0x6f, 0x64, 0x65, 0x67, 0x65, + 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x3d, 0x0a, 0x08, 0x47, 0x65, 0x6e, 0x65, + 0x72, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65, + 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x7c, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x2e, 0x70, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x42, 0x0c, 0x43, 0x6f, 0x64, 0x65, 0x67, 0x65, 0x6e, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x73, 0x71, 0x6c, 0x63, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x73, 0x71, 0x6c, 0x63, 0x2f, + 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0xa2, + 0x02, 0x03, 0x50, 0x58, 0x58, 0xaa, 0x02, 0x06, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0xca, 0x02, + 0x06, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0xe2, 0x02, 0x12, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x06, 0x50, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1379,7 +1511,7 @@ func file_plugin_codegen_proto_rawDescGZIP() []byte { return file_plugin_codegen_proto_rawDescData } -var file_plugin_codegen_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_plugin_codegen_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_plugin_codegen_proto_goTypes = []interface{}{ (*File)(nil), // 0: plugin.File (*Settings)(nil), // 1: plugin.Settings @@ -1394,14 +1526,16 @@ var file_plugin_codegen_proto_goTypes = []interface{}{ (*Query)(nil), // 10: plugin.Query (*Parameter)(nil), // 11: plugin.Parameter (*GenerateRequest)(nil), // 12: plugin.GenerateRequest - (*GenerateResponse)(nil), // 13: plugin.GenerateResponse - (*Codegen_Process)(nil), // 14: plugin.Codegen.Process - (*Codegen_WASM)(nil), // 15: plugin.Codegen.WASM + (*WickedMetadata)(nil), // 13: plugin.WickedMetadata + (*GenerateResponse)(nil), // 14: plugin.GenerateResponse + (*Codegen_Process)(nil), // 15: plugin.Codegen.Process + (*Codegen_WASM)(nil), // 16: plugin.Codegen.WASM + nil, // 17: plugin.WickedMetadata.QueryIsSelectEntry } var file_plugin_codegen_proto_depIdxs = []int32{ 2, // 0: plugin.Settings.codegen:type_name -> plugin.Codegen - 14, // 1: plugin.Codegen.process:type_name -> plugin.Codegen.Process - 15, // 2: plugin.Codegen.wasm:type_name -> plugin.Codegen.WASM + 15, // 1: plugin.Codegen.process:type_name -> plugin.Codegen.Process + 16, // 2: plugin.Codegen.wasm:type_name -> plugin.Codegen.WASM 4, // 3: plugin.Catalog.schemas:type_name -> plugin.Schema 7, // 4: plugin.Schema.tables:type_name -> plugin.Table 6, // 5: plugin.Schema.enums:type_name -> plugin.Enum @@ -1418,14 +1552,17 @@ var file_plugin_codegen_proto_depIdxs = []int32{ 1, // 16: plugin.GenerateRequest.settings:type_name -> plugin.Settings 3, // 17: plugin.GenerateRequest.catalog:type_name -> plugin.Catalog 10, // 18: plugin.GenerateRequest.queries:type_name -> plugin.Query - 0, // 19: plugin.GenerateResponse.files:type_name -> plugin.File - 12, // 20: plugin.CodegenService.Generate:input_type -> plugin.GenerateRequest - 13, // 21: plugin.CodegenService.Generate:output_type -> plugin.GenerateResponse - 21, // [21:22] is the sub-list for method output_type - 20, // [20:21] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 13, // 19: plugin.GenerateRequest.wicked:type_name -> plugin.WickedMetadata + 8, // 20: plugin.WickedMetadata.primary_relation:type_name -> plugin.Identifier + 17, // 21: plugin.WickedMetadata.query_is_select:type_name -> plugin.WickedMetadata.QueryIsSelectEntry + 0, // 22: plugin.GenerateResponse.files:type_name -> plugin.File + 12, // 23: plugin.CodegenService.Generate:input_type -> plugin.GenerateRequest + 14, // 24: plugin.CodegenService.Generate:output_type -> plugin.GenerateResponse + 24, // [24:25] is the sub-list for method output_type + 23, // [23:24] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_plugin_codegen_proto_init() } @@ -1591,7 +1728,7 @@ func file_plugin_codegen_proto_init() { } } file_plugin_codegen_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GenerateResponse); i { + switch v := v.(*WickedMetadata); i { case 0: return &v.state case 1: @@ -1603,7 +1740,7 @@ func file_plugin_codegen_proto_init() { } } file_plugin_codegen_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Codegen_Process); i { + switch v := v.(*GenerateResponse); i { case 0: return &v.state case 1: @@ -1615,6 +1752,18 @@ func file_plugin_codegen_proto_init() { } } file_plugin_codegen_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Codegen_Process); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_plugin_codegen_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Codegen_WASM); i { case 0: return &v.state @@ -1627,13 +1776,16 @@ func file_plugin_codegen_proto_init() { } } } + file_plugin_codegen_proto_msgTypes[12].OneofWrappers = []interface{}{ + (*GenerateRequest_Wicked)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_plugin_codegen_proto_rawDesc, NumEnums: 0, - NumMessages: 16, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/protos/plugin/codegen.proto b/protos/plugin/codegen.proto index e6faf19bad..1acf052aa8 100644 --- a/protos/plugin/codegen.proto +++ b/protos/plugin/codegen.proto @@ -44,6 +44,8 @@ message Codegen { } message Catalog { + reserved 5; + reserved "raw_sqls"; string comment = 1; string default_schema = 2; string name = 3; @@ -70,6 +72,8 @@ message Enum { } message Table { + reserved 4; + reserved "generate_model"; Identifier rel = 1; repeated Column columns = 2; string comment = 3; @@ -103,6 +107,8 @@ message Column { } message Query { + reserved 9; + reserved "options"; string text = 1 [json_name = "text"]; string name = 2 [json_name = "name"]; string cmd = 3 [json_name = "cmd"]; @@ -125,6 +131,20 @@ message GenerateRequest { string sqlc_version = 4 [json_name = "sqlc_version"]; bytes plugin_options = 5 [json_name = "plugin_options"]; bytes global_options = 6 [json_name = "global_options"]; + // Fork metadata is absent for standard generators, including in JSON output. + oneof backend_metadata { + WickedMetadata wicked = 1000; + } +} + +// Facts collected by the compiler for wicked generation. Query options remain +// in Query.comments and are interpreted by the backend. +message WickedMetadata { + string primary_schema_path = 1; + string primary_schema_sql = 2; + Identifier primary_relation = 3; + // This means a top-level SELECT, not a proof that a query is read-only. + map query_is_select = 4; } message GenerateResponse { diff --git a/scripts/release.go b/scripts/release.go index a8651a6ee7..87e2d3648b 100755 --- a/scripts/release.go +++ b/scripts/release.go @@ -32,7 +32,7 @@ func main() { } if *docker { - x := "-extldflags \"-static\" -X github.com/sqlc-dev/sqlc/internal/cmd.version=" + version + x := releaseLDFlags(version) args := []string{ "build", "-a", @@ -57,3 +57,8 @@ func main() { log.Fatal("publishing to Equinox has been disabled") } + +func releaseLDFlags(version string) string { + // The CLI and generated headers must use the same version, as in make build. + return "-extldflags \"-static\" -X github.com/sqlc-dev/sqlc/internal/info.Version=" + version +} diff --git a/scripts/test-local/main.go b/scripts/test-local/main.go new file mode 100644 index 0000000000..815bae4905 --- /dev/null +++ b/scripts/test-local/main.go @@ -0,0 +1,158 @@ +// test-local runs a command against PostgreSQL and MySQL containers owned by +// this invocation. It never discovers or reuses a database on the host. +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" + "syscall" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/jackc/pgx/v5" +) + +func main() { os.Exit(run(os.Args[1:])) } + +func run(args []string) int { + // Readiness attempts can encounter a restarting initialization server. + // Report the final readiness error instead of logging every retry. + if err := mysql.SetLogger(log.New(io.Discard, "", 0)); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: test-local -- command [args...]") + return 2 + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + var ids []string + defer func() { + for i := len(ids) - 1; i >= 0; i-- { + cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) + out, err := exec.CommandContext(cleanup, "docker", "rm", "--force", ids[i]).CombinedOutput() + cancel() + if err != nil { + fmt.Fprintf(os.Stderr, "cleanup container %s: %s (%v)\n", ids[i], out, err) + } + } + }() + start := func(image, port string, env ...string) (string, error) { + argv := []string{"run", "--detach", "--publish", "127.0.0.1::" + port, "--label", "sqlc.test-local=true"} + for _, v := range env { + argv = append(argv, "--env", v) + } + argv = append(argv, image) + out, err := exec.CommandContext(ctx, "docker", argv...).Output() + if err != nil { + return "", commandError(err) + } + id := strings.TrimSpace(string(out)) + if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) { + return "", fmt.Errorf("invalid container id: %q", id) + } + ids = append(ids, id) + out, err = exec.CommandContext(ctx, "docker", "port", id, port+"/tcp").Output() + if err != nil { + return "", commandError(err) + } + address := strings.TrimSpace(string(out)) + host, _, err := net.SplitHostPort(address) + if err != nil || host != "127.0.0.1" { + return "", fmt.Errorf("unexpected container address %q", address) + } + return address, nil + } + pgAddr, err := start("postgres:16", "5432", "POSTGRES_PASSWORD=sqlc-local-test") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + myAddr, err := start("mysql:9", "3306", "MYSQL_ROOT_PASSWORD=sqlc-local-test", "MYSQL_DATABASE=dinotest") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + pgURI := "postgres://postgres:sqlc-local-test@" + pgAddr + "/postgres?sslmode=disable" + myURI := "root:sqlc-local-test@tcp(" + myAddr + ")/dinotest?multiStatements=true&parseTime=true" + ready, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if err := waitReady(ready, func(ctx context.Context) error { + conn, err := pgx.Connect(ctx, pgURI) + if err != nil { + return err + } + defer conn.Close(ctx) + return conn.Ping(ctx) + }); err != nil { + fmt.Fprintln(os.Stderr, "PostgreSQL:", err) + return 1 + } + db, err := sql.Open("mysql", myURI) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer db.Close() + if err := waitReady(ready, db.PingContext); err != nil { + fmt.Fprintln(os.Stderr, "MySQL:", err) + return 1 + } + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "POSTGRESQL_SERVER_URI=") && !strings.HasPrefix(entry, "MYSQL_SERVER_URI=") { + cmd.Env = append(cmd.Env, entry) + } + } + cmd.Env = append(cmd.Env, "POSTGRESQL_SERVER_URI="+pgURI, "MYSQL_SERVER_URI="+myURI) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() > 0 { + return exit.ExitCode() + } + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} + +func waitReady(ctx context.Context, check func(context.Context) error) error { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + attempt, cancel := context.WithTimeout(ctx, time.Second) + err := check(attempt) + cancel() + if err == nil { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("database not ready: %w (last error: %v)", ctx.Err(), err) + case <-ticker.C: + } + } +} + +func commandError(err error) error { + var exit *exec.ExitError + if errors.As(err, &exit) { + return fmt.Errorf("docker: %w: %s", err, exit.Stderr) + } + return err +} From 41bb4c1581bcd0eb86a968f480594603f7953602 Mon Sep 17 00:00:00 2001 From: Yumin Xia Date: Wed, 9 Sep 2026 00:17:53 +0000 Subject: [PATCH 2/2] feat(wicked): add the compatible wpgx code generation backend Add the standalone wicked generator, legacy Go mappings and runtime templates, CLI dispatch, generation/consumer regression fixtures, bookstore CI, and usage documentation. Preserve the previously verified product tree while presenting the fork as exactly two linear commits on upstream v1.31.1. --- .github/workflows/wicked.yml | 33 + GUIDE.md | 1249 +++++++++++++++++ README.md | 5 + ...nc-upstream-and-separate-wicked-codegen.md | 651 +++++++++ internal/cmd/generate.go | 4 + internal/cmd/wicked_test.go | 160 +++ internal/codegen/wicked/driver.go | 14 + internal/codegen/wicked/dumploader.go | 78 + internal/codegen/wicked/enum.go | 64 + internal/codegen/wicked/field.go | 143 ++ internal/codegen/wicked/gen.go | 451 ++++++ internal/codegen/wicked/go_type.go | 94 ++ internal/codegen/wicked/imports.go | 514 +++++++ internal/codegen/wicked/option.go | 75 + internal/codegen/wicked/options.go | 82 ++ internal/codegen/wicked/options_test.go | 115 ++ internal/codegen/wicked/postgresql_type.go | 271 ++++ internal/codegen/wicked/query.go | 432 ++++++ internal/codegen/wicked/reserved.go | 67 + internal/codegen/wicked/result.go | 545 +++++++ internal/codegen/wicked/struct.go | 49 + internal/codegen/wicked/template.go | 7 + .../codegen/wicked/templates/template.tmpl | 174 +++ .../wicked/templates/wpgx/copyfromCopy.tmpl | 54 + .../codegen/wicked/templates/wpgx/dbCode.tmpl | 88 ++ .../wicked/templates/wpgx/interfaceCode.tmpl | 12 + .../wicked/templates/wpgx/queryCode.tmpl | 450 ++++++ internal/codegen/wicked/types_test.go | 77 + internal/codegen/wicked/unique_namer.go | 23 + internal/endtoend/testdata/go.mod | 29 + internal/endtoend/testdata/go.sum | 75 + .../wicked_compat/go/compatibility_test.go | 37 + .../endtoend/testdata/wicked_compat/go/db.go | 96 ++ .../testdata/wicked_compat/go/models.go | 19 + .../testdata/wicked_compat/go/query.sql.go | 198 +++ .../testdata/wicked_compat/parents.sql | 7 + .../endtoend/testdata/wicked_compat/query.sql | 11 + .../testdata/wicked_compat/schema.sql | 1 + .../endtoend/testdata/wicked_compat/sqlc.yaml | 15 + .../testdata/wicked_missing_timeout/query.sql | 2 + .../wicked_missing_timeout/schema.sql | 1 + .../testdata/wicked_missing_timeout/sqlc.yaml | 10 + .../wicked_missing_timeout/stderr.txt | 2 + .../testdata/wicked_primary/books.sql | 5 + .../endtoend/testdata/wicked_primary/go/db.go | 97 ++ .../testdata/wicked_primary/go/models.go | 15 + .../testdata/wicked_primary/go/querier.go | 15 + .../testdata/wicked_primary/go/query.sql.go | 131 ++ .../testdata/wicked_primary/primary.sql | 2 + .../testdata/wicked_primary/query.sql | 4 + .../testdata/wicked_primary/sqlc.yaml | 11 + scripts/release_test.go | 56 + wicked_change_logs.md | 33 + 53 files changed, 6853 insertions(+) create mode 100644 .github/workflows/wicked.yml create mode 100644 GUIDE.md create mode 100644 docs/changelogs/2026-09-08-sync-upstream-and-separate-wicked-codegen.md create mode 100644 internal/cmd/wicked_test.go create mode 100644 internal/codegen/wicked/driver.go create mode 100644 internal/codegen/wicked/dumploader.go create mode 100644 internal/codegen/wicked/enum.go create mode 100644 internal/codegen/wicked/field.go create mode 100644 internal/codegen/wicked/gen.go create mode 100644 internal/codegen/wicked/go_type.go create mode 100644 internal/codegen/wicked/imports.go create mode 100644 internal/codegen/wicked/option.go create mode 100644 internal/codegen/wicked/options.go create mode 100644 internal/codegen/wicked/options_test.go create mode 100644 internal/codegen/wicked/postgresql_type.go create mode 100644 internal/codegen/wicked/query.go create mode 100644 internal/codegen/wicked/reserved.go create mode 100644 internal/codegen/wicked/result.go create mode 100644 internal/codegen/wicked/struct.go create mode 100644 internal/codegen/wicked/template.go create mode 100644 internal/codegen/wicked/templates/template.tmpl create mode 100644 internal/codegen/wicked/templates/wpgx/copyfromCopy.tmpl create mode 100644 internal/codegen/wicked/templates/wpgx/dbCode.tmpl create mode 100644 internal/codegen/wicked/templates/wpgx/interfaceCode.tmpl create mode 100644 internal/codegen/wicked/templates/wpgx/queryCode.tmpl create mode 100644 internal/codegen/wicked/types_test.go create mode 100644 internal/codegen/wicked/unique_namer.go create mode 100644 internal/endtoend/testdata/wicked_compat/go/compatibility_test.go create mode 100644 internal/endtoend/testdata/wicked_compat/go/db.go create mode 100644 internal/endtoend/testdata/wicked_compat/go/models.go create mode 100644 internal/endtoend/testdata/wicked_compat/go/query.sql.go create mode 100644 internal/endtoend/testdata/wicked_compat/parents.sql create mode 100644 internal/endtoend/testdata/wicked_compat/query.sql create mode 100644 internal/endtoend/testdata/wicked_compat/schema.sql create mode 100644 internal/endtoend/testdata/wicked_compat/sqlc.yaml create mode 100644 internal/endtoend/testdata/wicked_missing_timeout/query.sql create mode 100644 internal/endtoend/testdata/wicked_missing_timeout/schema.sql create mode 100644 internal/endtoend/testdata/wicked_missing_timeout/sqlc.yaml create mode 100644 internal/endtoend/testdata/wicked_missing_timeout/stderr.txt create mode 100644 internal/endtoend/testdata/wicked_primary/books.sql create mode 100644 internal/endtoend/testdata/wicked_primary/go/db.go create mode 100644 internal/endtoend/testdata/wicked_primary/go/models.go create mode 100644 internal/endtoend/testdata/wicked_primary/go/querier.go create mode 100644 internal/endtoend/testdata/wicked_primary/go/query.sql.go create mode 100644 internal/endtoend/testdata/wicked_primary/primary.sql create mode 100644 internal/endtoend/testdata/wicked_primary/query.sql create mode 100644 internal/endtoend/testdata/wicked_primary/sqlc.yaml create mode 100644 scripts/release_test.go create mode 100644 wicked_change_logs.md diff --git a/.github/workflows/wicked.yml b/.github/workflows/wicked.yml new file mode 100644 index 0000000000..b982ea614f --- /dev/null +++ b/.github/workflows/wicked.yml @@ -0,0 +1,33 @@ +name: wicked +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + bookstore: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: make build COMMIT_HASH=v2.4.0-dev + - name: Check generated consumer and JSON contracts + working-directory: internal/endtoend/testdata + run: go test ./wicked_compat/go + - uses: actions/checkout@v6 + with: + repository: Stumble/bookstore + ref: 2a7f7e396451821a8fe3054eb265037b830af68d + path: bookstore + - name: Check generated API and code + working-directory: bookstore + run: | + make sqlc SQLC="$GITHUB_WORKSPACE/bin/sqlc" + make sqlc-verify SQLC="$GITHUB_WORKSPACE/bin/sqlc" + git diff --exit-code -- pkg/repos + - name: Run runtime contracts + working-directory: bookstore + run: go test -race -count=1 -p 1 -timeout 10m ./pkg/usecases diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000000..ceee35e454 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,1249 @@ +# Intro + +Here we present a combo of using `wicked-sqlc + wpgx + cache` that can: + ++ Generate fully type-safe idiomatic Go code with built-in + + memory-redis cache layer with compression and singleflight protection. + + telemetry: Prometheus and OpenTelemetry (WIP). + + auto-generated Load and Dump function for easy golden testing. ++ Envvar configuration template for Redis and PostgreSQL. ++ A Testsuite for writing golden tests that can + + import data from a test file into tables to setup background data + + dump table to a file in JSON format + + compare current DB data with a golden file + +NOTE: this combo is for PostgreSQL, if you are using MySQL, you can checkout this project: +[Needle](https://github.com/Stumble/needle). It provides the same set of functionalities +as this combo. + +Production versions: + ++ sqlc: v2.3.4-wicked-fork ++ dcache: v0.3.0 (Note: redis/v8 users please use v0.1.4) ++ wgpx: v0.3.1 + +# Sqlc (this wicked fork) + +Although using sqlc might increase the productivity, as you no longer need to manually write the +boilerplate codes while having cache and telemetries out of the box, +it is **NOT** our goal. + +Instead, by adopting to this restricted form, we hope to: + ++ Make it extremely easy to see all possible ways to query DB. By explicitly listing all of them + in the query.sql file, DBAs can examine query patterns and design indexes wisely. In the future, + we might even be able to find out possible slow queries in compile time. ++ Force you to think twice before creating a new query. Some business logics can share the same + query, which means higher cache hit ratio. Sometimes when there are multiple ways to implement a + usecase, choose the one that can reuse existing indexes. + +Sometimes, you might find sqlc too *restricted*, and cannot hold the eager to +write a function that builds the +SQL dynamically based on conditions, **don't** do it, unless it is a must, which is hardly true. +In the end of the day, the so-called backend development is more or less about +building a data-intensive software, where the most common bottleneck, is that fragile database, +which is very costly to scale. + +From another perspective, the time will either be spent on (1) later, when the business grew and +the bottleneck was reached, diagnosing the problem and refactoring your database codes, while +your customers are disappointed, or (2) before the product is launched, writing queries. + +## Install + +```bash +# cgo must be enabled because: https://github.com/pganalyze/pg_query_go +git clone https://github.com/Stumble/sqlc.git +cd sqlc/ +git checkout v2.3.4 +make install +sqlc version +# you shall see: v2.3.4-wicked-fork +``` + +### Upstream synchronization development + +The migration branch applies two commits directly on upstream **v1.31.1**: +core compiler/protocol changes, followed by the wicked backend and its integration. +It uses Go 1.26.8 (the Go +toolchain can select this automatically) and keeps the same `make install`, +`sqlc generate`, `sqlc diff`, and `sql_package: wpgx` entrypoints. It is a single +executable; no additional codegen plugin needs to be installed. + +```bash +git checkout refactor/wicked-on-upstream +make install +# The upstream parser also supports builds without CGO: +make build CGO_ENABLED=0 +``` + +The compiler handles schema dependencies, the primary model, and SQL types. +`internal/codegen/wicked` owns the Go mapping, templates, comment options, cache +keys, timeouts, invalidation, and replica APIs. Standard Go generation remains +in the upstream backend. Wicked facts are transported in `WickedMetadata`; +`-- -- key: value` options use the standard query comments. + +For reproducible migration fixtures, CI builds with +`make build COMMIT_HASH=v2.4.0-dev`. This development label is not a published +release. General compiler fixes are committed in this fork first and are only +proposed upstream after the complete fork and downstream tests pass. + +Compatibility is checked against v2.3.4, including the full `github.com/google/uuid` +type identity, inherited primary models, generated Row types, and JSON/cache +payloads. In wpgx mode, `go_struct_tag` remains column-scoped; `db_type` overrides +select Go types but do not change struct tags. Use a column override when a tag +change is intended. `sqlc.narg()` explicitly requests a nullable parameter; a +repeated parameter otherwise retains the legacy preference for comparisons over +assignments. These rules do not constitute a full SQL nullability proof. + +Developer checks: + +```bash +make proto BUF='go run github.com/bufbuild/buf/cmd/buf@v1.72.0' +go run ./scripts/test-local -- go test -count=1 -timeout 20m ./... +make build-endtoend +cd internal/endtoend/testdata && go test ./wicked_compat/go +``` + +The local test runner owns temporary PostgreSQL/MySQL containers and uses random +ports. The bookstore test suite similarly owns its PostgreSQL/Redis containers; +it must not reuse a shared database or Redis server. The migration preserves +existing runtime dependencies in the example. + +## Getting started + +It is recommended to read [Sqlc doc](https://docs.sqlc.dev/en/stable/) to get some +general ideas of how to use sqlc. In the following example, we will pay more +attention to things that are different to official sqlc. + +In this tutorial, we will build a online bookstore, with unit tests, to demonstrate how to use this combo. +The project can be found here: [bookstore](https://github.com/Stumble/bookstore). + +### Project structure + +After `go mod init`, we created a `sqlc.yaml` file that manages the code generation, under `pkg/repos/`. +This will be the root directory for our data access layer. + +First, let's start with building a table that stores book information. + +```bash +. +├── go.mod +└── pkg + └── repos + ├── books + │   ├── query.sql + │   └── schema.sql + └── sqlc.yaml +``` + +Initially, the YAML configuration file looks like this: + +```yaml +version: '2' +sql: + - schema: books/schema.sql + queries: books/query.sql + engine: postgresql + gen: + go: + sql_package: wpgx + package: books + out: books +``` + +It configures sqlc to generate Go code for `books` table based on the schema and queries SQL file, +under `books/` directory, relatively to `sqlc.yaml` file. +The only thing different from the official sqlc is the `sql_package` option. This wicked fork will +use `wpgx` package as the SQL driver, so you have to set `sql_package` to this value. + +### Schema + +A schema file is 1-to-1 mapped to a logical table. That is, you need to write 1 schema file for +each **logical** table in DB. To be more clear: + ++ 1 schema file for 1 normal physical table. ++ For **Declarative Partitioning**, the table declaration and all its partitions can be, and should + be placed into one schema file, as they are logically one table. ++ For a **Materialized View**, one schema file per view is required. Ordinary + `CREATE VIEW` is currently not supported as the primary wicked model. + +You can and you should list all the **constraints and indexes** in the schema file. In the future, +we might have some static analyze tool to check for slow queries. Also, listing them here will +make code viewers' lives much easier. + +Different from the official sqlc, for each schema section in the sqlc.yaml file, +only the **first** schema file in the array will be considered as the source of generating Go struct. +For example, if the config is `- schema: ["t1.sql", "t2.sql"]`, +forked sqlc will only generate a Go struct for +the first (and the only) table definition in `t1.sql`. If there are two table creation statements, +sqlc will error out. +Schema files after the first one are used as references for column types. + +Now let's look into `books/schema.sql` file. + +```SQL +CREATE TYPE category AS ENUM ( + 'computer_science', + 'philosophy', + 'comic' +); + +CREATE TABLE IF NOT EXISTS books ( + id BIGSERIAL GENERATED ALWAYS AS IDENTITY, + name VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + metadata JSON, + category category NOT NULL, + price DECIMAL(10,2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT books_id_pkey PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS book_name_idx ON books (name); +CREATE INDEX IF NOT EXISTS book_category_id_idx ON books (category, id); +``` + +We defined a table called books, using id as primary key, with two indexes. +There are two interesting columns: + ++ Column `category` is of type `book_category`. Sqlc will generate new type `BookCategory` in `models.go` + file, with `Scan` and `Value` methods to allow it to be used by the pgx driver. + Unlike tables, all enum types will be generated in the model file, if the schema file is referenced. ++ Column `price` will be of type `pgtype.Numeric`, which is defined in `github.com/jackc/pgx/v5/pgtype`. + This is because that there is no native type in GO to represent a decimal number. + +The generated `models.go` file would contain a struct that represents a *row* of the table. + +```go +type Book struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Metadata []byte `json:"metadata"` + Category BookCategory `json:"category"` + Price pgtype.Numeric `json:"price"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +Then, let's create another table for storing users. + +```sql +CREATE TABLE IF NOT EXISTS users ( + id INT GENERATED ALWAYS AS IDENTITY, + name VARCHAR(255) NOT NULL, + metadata JSON, + image TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT users_id_pkey PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS users_created_at_idx + ON Users (CreatedAt); +CREATE UNIQUE INDEX IF NOT EXISTS users_lower_name_idx + ON Users ((lower(Name))) INCLUDE (ID); +``` + +#### Reference other schema + +When the schema file (e.g., creating a view), +or the queries (e.g., joining other tables) in the +`query.sql` file referenced other tables, you must list those dependencies in the schema section. +The order of tables in the array must be a topological sort of the dependency graph. +Another way to say it: it is just like C headers, but you list them reversely. + +For example, when creating a table of orders that looks like: + +```sql +CREATE TABLE IF NOT EXISTS orders ( + id INT GENERATED BY DEFAULT AS IDENTITY, + user_id INT references users(ID) ON DELETE SET NULL, + book_id INT references books(ID) ON DELETE SET NULL, + price BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_deleted BOOLEAN NOT NULL, + CONSTRAINT orders_id_pkey PRIMARY KEY (id) +); +``` + +If we add a query that joins books and users with the order table, for example, + +```sql +-- name: GetOrderByID :one +-- -- timeout : 500ms +SELECT + orders.ID, + orders.user_id, + orders.book_id, + orders.created_at, + users.name AS user_name, + users.image AS user_thumbnail, + books.name AS book_name, + books.price As book_price, + books.metadata As book_metadata +FROM + orders + INNER JOIN books ON orders.book_id = books.id + INNER JOIN users ON orders.user_id = users.id +WHERE + orders.is_deleted = FALSE; +``` + +we must list the schema file of books and users after orders table in the configuration file. + +```yaml +- schema: + - orders/schema.sql + - books/schema.sql + - users/schema.sql + queries: orders/query.sql + ... +``` + +Otherwise, sqlc will complain + +```text +orders/query.sql:1:1: relation "books" does not exist +orders/query.sql:45:1: relation "users" does not exist +``` + +Another example is the `revenues` table schema. It is a materialized view + +```sql +CREATE MATERIALIZED VIEW IF NOT EXISTS by_book_revenues AS + SELECT + books.id, + books.name, + books.category, + books.price, + books.created_at, + sum(orders.price) AS total, + sum( + CASE WHEN + (orders.created_at > now() - interval '30 day') + THEN orders.price ELSE 0 END + ) AS last30d + FROM + books + LEFT JOIN orders ON books.id = orders.book_id + GROUP BY + books.id; +``` + +Because this table is depending on both orders and books, in the schema file we must list them after +the revenue table. + +```yaml +- schema: + - revenues/schema.sql + - orders/schema.sql + - books/schema.sql +``` + +Lastly, each schema file will be saved into a string named `Schema`, defined in the `models.go`. +They are made there to be convenient for you to setup DB for unit tests. +Also, it is a good practice to always include the `IF NOT EXISTS` clause when creating tables and indexes. + +### Query + +`query.sql` file is where your define all the possible ways to access to the table. Each table +must have 1 query file. +Queries can access all the table columns as long as their tables are listed in the schema section in +the configuration. We have seen an example, `GetOrderByID`, where the query joins other tables. + +NOTE: Starting from v2.2.0, **all** queries must have `timeout` option configured. If timeout option is missing, +sqlc generation will failed with error messages like + +``` +ERROR: orders/GetOrderByID does not have a timeout option +ERROR: *pkg*/*name* does not have a timeout option +``` + +Here is an example of listing all books of a category, with using id +as the cursor for pagination. + +```sql +-- name: ListByCategory :many +-- -- timeout : 500ms +SELECT * +FROM + books +WHERE + category = @category AND id > @after +ORDER BY + id +LIMIT @first; +``` + +This wicked forked sqlc adds 3 abilities to query: cache, timeout and invalidate. + +All of them are added by extending sqlc to allow passing additional options per each query. +Originally, you can only specify name and the type of result in the comments before SQL. +The new feature allows you to pass any options to codegen backend by adding comments starts with `-- --`. + +For example, this will generate code that caches the result of all books for 10 minutes, with 500 milliseconds timeout. + +```sql +-- name: GetAllBooks :many +-- -- timeout : 500ms +-- -- cache : 10m +SELECT * FROM books; +``` + +Btw, this syntax looks very similar as passing arguments to the underlying script in npm. + +```bash +npm run server -- --port=8080 // invokes `run server script with --port=8080` +``` + +#### Cache + +Cache accepts a [Go time.Duration format](https://pkg.go.dev/maze.io/x/duration#ParseDuration) as the +only argument, which specify how long the result will be cached, if a cache is configured +in the queries struct. If no cache is injected, caching is not possible and duration will be ignored. + +The best practice is to cache frequently queried objects, especially + ++ cache results that we know how to invalidate for longer time, in most cases, they are result of single + rows. For example, a row of book information can be cached for a long time, because we know when the book + information will be updated so that we can apply invalidate accordingly. ++ cache results that we do not know how to invalidate for a shorter time. For example, a list of top seller + books, because it is hard for us to know if we should invalidate the cache of that list when we are updating + information of some books, (unless you do some fancy bloom-filter stuff..). + +#### Use Read Replica + +We support heterogeneous database replicas, meaning that you can not only use physical replia that is exactly the same as +the primary instance, but also logical replicas that may have different schema, like additional materialzied views, plugins, +or only some part of the table. + +Replicas are managed by the wpgx pool object. `ReadOnlyQueries` exposes eligible +`:one`/`:many` queries classified as top-level SELECT statements. Set +`-- -- allow_replica: false` to omit a query from this API. This preserves the +existing classification; it does not prove that a SELECT has no writing CTE, +row lock, or function side effect. Use read-only database permissions for replica +connections, and choose replica access according to the query's consistency needs. + +Example: + +```go +replicaName := wpgx.ReplicaName("R1") +r1, _ := suite.Pool.WQuerier(&replicaName) +cond := "b%" +rst, err := suite.usecase.books.UseReplica(r1).GetBookBySpec(ctx, books.GetBookBySpecParams{ + Name: &cond, +}) +``` + +To setup managed read replica, you will configure them in the environment variables, like: + +``` +POSTGRES_REPLICAPREFIXES=R1,R2 +R1_NAME=R1 +R1_HOST=localhost +R1_PORT=5433 +... +``` + +#### Timeout + +Because setting timeout for queries is such an important practice, starting from v2.2.0, we make this a mandatory option. +If cache is enabled, the timeout duration applies to the whole "query" process, including trying to read from cache and +actual database query. + +The following code shows how to use set a timeout option for a query. + +```sql +-- name: GetAllBooks :many +-- -- timeout : 500ms +SELECT * FROM books; +``` + +The benefits of adding timeout to queries are: + ++ Resource control and DoS prevention: by setting a timeout, you can prevent a query from running too long and consuming too much resource. + Note that it is possible to construct a set of queries that one depends on the result of some other, so that when + processing them concurrently, it may result longer and longer lock wait duration and eventually bring down the database, because + all the connections, CPU resources are consumed by the waiting queries. ++ Prevents deadlocks: it prevents a query from blocking other queries. + +#### Invalidate + +When we mutate the state of table, we should proactively invalidate some cache values. + +TBD: How the invalidate option support this feature and how it works in Transaction. + +#### Best practices + ++ When storing time in DB, **always** use `timestamptz`, the date type with timezone and + **never** use `timestamp` or the alias `timestamp without timezone` type, unless you know + what you are doing. In most cases, the timestamp without timezone type is not what you + wished for, pgx/v5 *correctly* implemented its semantic so it may [confuses people](https://github.com/jackc/pgx/issues/1195). In general, it represents: the same 'time' in different + timezones, but different physical point of time in the universe:), e.g., if the new release + of a game goes live at 8:00AM in all the timezones. ++ Use `@arg_name` to explicitly name all the arguments for the query. If somehow not working, try to use + `sqlc.arg()`, or `sqlc.narg()` if appropriate. + It is highly recommended to read [this doc](https://docs.sqlc.dev/en/latest/howto/named_parameters.html). ++ DO NOT mix `$`, `@` and `sqlc.arg()/sqlc.narg()` in one SQL query. Each query should purely use one kind + of parameter style. ++ Use `::type` postgreSQL type conversion to hint sqlc for arguments that their types are hard or + impossible to be inferred. + +#### Known issues + ++ Wicked `:batch*` and `:execlastid` commands are not supported and now fail + generation explicitly. `:copyfrom` remains supported; its template applies the + timeout but does not implement cache/invalidate options. ++ Experimental database-only analysis cannot provide the primary wicked model + and is rejected. Ordinary upstream generation keeps its own behavior. + ++ `from unnest(array1, arry2)` is not supported yet. Use `select unnest(array1), unnest(array1)` instead. + Note, when the arrays are not all the same length then the shorter ones are padded with NULLs. ++ In some cases, you must put a space before the "@" symbol for named parameter, + For example, a statement like `select ... where a=@a` + cannot be correctly parsed by sqlc. You must change it to `select ... a = @a`. + You shall notice this type of error after code generation, as you will see that some parameters are + missing in the generated code and an incorrect SQL is used for query (still including @). ++ Enum type support is very limited. First, you cannot use copyfrom for when the column is + an enum type. Also, when using enum type in any clause, e.g., `enum_col = ANY(@xxx::enum_type[])`, it won't work. You have to do `enum_col = ANY(@xxx::text[]::enum_type[])`, and + unfortunately the parameters type will become string array, instead of exptected enum array. + +#### Case study + +##### Bulk insert and upsert + +If data will not violate any constraints, you can just use copyfrom. +When a constraint fails, an error is throw, and none of data are copied (it is rolled back). + +```sql +-- name: BulkInsert :copyfrom +INSERT INTO books ( + name, description, metadata, category, price +) VALUES ( + $1, $2, $3, $4, $5 +); +``` + +But If you want to implement bulk upsert, the best practice is to use `unnest` function to pass each +column as an array. For example, the following query will generate a bulk upsert method. + +```sql +-- name: UpsertUsers :exec +insert into users + (name, metadata, image) +select + unnest(@name::VARCHAR(255)[]), + unnest(@metadata::JSON[]), + unnest(@image::TEXT[]) +on conflict ON CONSTRAINT users_lower_name_key do +update set + metadata = excluded.metadata, + image = excluded.image; +``` + +The generated Go code will look like: + +```go +type UpsertUsersParams struct { + Name []string + Metadata [][]byte + Image []string +} + +func (q *Queries) UpsertUsers(ctx context.Context, arg UpsertUsersParams) error { + _, err := q.db.WExec(ctx, "UpsertUsers", upsertUsers, + arg.Name, arg.Metadata, arg.Image) + // ... +} +``` + +##### Other bulk operations + +When you have too many parameters in a query, it can become slow. +To operate on data in bulk, it is a good practice to use `select UNNEST(@array_arg)...` to build +an intermediate table, and then use that table. + +For example, to select based on different conditions, you can: + +```sql +-- name: ListOrdersByUserAndBook :many +SELECT * FROM orders +WHERE + (user_id, book_id) IN ( + SELECT + UNNEST(@user_id::int[]), + UNNEST(@book_id::int[]) +); +``` + +To update different rows to different values, you can: + +```sql +-- name: BulkUpdate :exec +UPDATE orders +SET + price=temp.price, + book_id=temp.book_id +FROM + ( + SELECT + UNNEST(@id::int[]) as id, + UNNEST(@price::bigint[]) as price, + UNNEST(@book_id::int[]) as book_id + ) AS temp +WHERE + orders.id=temp.id; +``` + +##### Partial update + +If you wish to write one SQL update statement that only update some columns, +based on the arguments at runtime, +you can use the following trick that use `sqlc.narg` to generate nullable parameters and use +`coalesce` function, so that a column is set to the new value, if not null, or unchanged. + +However, please note this trick CANNOT handle this case: when a column is nullable, you cannot set it +to null, using this trick. Also, You MUST write some unit tests to check if the SQL would work as expected. + +```sql +-- name: PartialUpdateByID :exec +UPDATE books +SET + description = coalesce(sqlc.narg('description'), description), + metadata = coalesce(sqlc.narg('meta'), metadata), + price = coalesce(sqlc.narg('price'), price), + updated_at = NOW() +WHERE + id = sqlc.arg('id'); +``` + +##### Versatile query + +Although it is **not** recommended, you can use `coalesce` function and `sqlc.narg` +to build a versatile query that filter rows based different sets of conditions. + +```sql +-- NOTE: dummy is a null-able column. +-- name: GetBookBySpec :one +-- -- cache : 10m +SELECT * FROM books WHERE + name LIKE coalesce(sqlc.narg('name'), name) AND + price = coalesce(sqlc.narg('price'), price) AND + (sqlc.narg('dummy')::int is NULL or dummy_field = sqlc.narg('dummy')); +``` + +However, please note that you **CANNOT** apply this trick on null-able columns. +The reason is: null never equals to null. In the above example, if we change the cond around +'dummy' column to `dummy = coalesce(sqlc.narg('dummy'), dummy)`, all rows will be filtered out +when `sqlc.narg('dummy')` is substituted by `null`. +The correct way to shown in the example: instead of use 'coalesce', explicitly check if the value +is null. + +##### Refresh materialized view + +Refresh statement is supported, you can just list it as a query. + +```sql +-- name: Refresh :exec +REFRESH MATERIALIZED VIEW CONCURRENTLY by_book_revenues; +``` + +**NOTE**: +You should use `CONCURRENTLY` to refresh the materialized view without locking out concurrent selects on the materialized view. Without this option a refresh which affects a lot of rows will tend to use fewer resources and complete more quickly, but could block other connections which are trying to read from the materialized view. This option may be faster in cases where a small number of rows are affected. + +This option is **only allowed** if there is at least one `UNIQUE` index on the materialized view which uses only column names and includes all rows; that is, it must not be an expression index or include a WHERE clause. + +This option **may not** be used when the materialized view is not already populated. So for the first time, you need to populate it with non concurrent refresh. + +### SQL Naming conventions + +In short, for table and column names, always use 'snake_case'. +More details: [Naming Conventions](https://www.geeksforgeeks.org/postgresql-naming-conventions/) + +Indexes should be named in the following way: + +```text +{tablename}_{columnname(s)}_{suffix} +``` + +where the suffix is one of the following: + ++ ``pkey`` for a Primary Key constraint; ++ ``key`` for a Unique constraint; ++ ``excl`` for an Exclusion constraint; ++ ``idx`` for any other kind of index; ++ ``fkey`` for a Foreign key; ++ ``check`` for a Check constraint; + +If the name is too long, (max length is 63), try to use shorter names for column names. + +Table Partitions should be named as + +```text +{{tablename}}_{{partition_name}} +``` + +where the partition name should represent how the table is partitioned. +For example: + +```sql +CREATE TABLE measurement ( + city_id int not null, + logdate date not null, + peaktemp int, + unitsales int +) PARTITION BY RANGE (logdate); + +CREATE TABLE measurement_y2006m02 PARTITION OF measurement + FOR VALUES FROM ('2006-02-01') TO ('2006-03-01'); +``` + +#### Work with legacy project and CamelCase-style names + +If you are working with a legacy codebase that its DB does not follow the above +naming convention, for example, used CamelCase style for column names, there are +some caveats you must pay attention to. + +First, please note that, in PostgreSQL, identifiers (including column names) that are **not double-quoted** are folded to lowercase, while +column names that were created with double-quotes and thereby retained uppercase letters +(and/or other syntax violations) have to be double-quoted for the rest of their life. + +Here's an example. + +```sql +CREATE TABLE IF NOT EXISTS test ( + id INT GENERATED ALWAYS AS IDENTITY, + CamelCase INT, + snake_case INT, + CONSTRAINT test_id_pkey PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS test2 ( + id INT GENERATED ALWAYS AS IDENTITY, + "CamelCase" INT, + snake_case INT, + CONSTRAINT test2_id_pkey PRIMARY KEY (id) +); +``` + +The column `CamelCase` in table `test` were not created with double-quotes, so internally, the name +was actually stored in the lower-cased string. But `test2.CamelCase` did, so the name is stored in its +original camcal-case style. See below logs from psql. + +```psql +# \d test + Table "public.test" + Column | Type | Collation | Nullable | Default +------------|---------|-----------|----------|------------------------------ + id | integer | | not null | generated always as identity + camelcase | integer | | | + snake_case | integer | | | + +# \d test2 + Table "public.test2" + Column | Type | Collation | Nullable | Default +------------|---------|-----------|----------|------------------------------ + id | integer | | not null | generated always as identity + CamelCase | integer | | | + snake_case | integer | | | +``` + +Differences of accessing these two tables: + +```sql +-- This is okay!, all identifiers will be lowered-cased if not quoted. +insert into test ( + CaMelCASe, snake_case) +values ( + 1, 2); + +-- NOT okay! +-- ERROR: column "camelcase" of relation "test2" does not exist +-- LINE 2: CamelCase, snake_case) +insert into test2 ( + CamelCase, snake_case) +values ( + 1, 2); + +-- The right way to work with table test2. +insert into test2 ( + "CamelCase", snake_case) +values ( + 1, 2); + +-- Another example of quoting identifiers. +select t2."CamelCase" from test2 as t2; +``` + +Unfortunately, sqlc can not check for errors if you forgot to quote identifiers correctly, for now. +So you need to be very careful if the column names were actually stored in CamelCase. + +Second, if you want to preserve the CamelCase name in go, use rename in the `sqlc.yaml` configuration, +for example, + +```yaml +version: '2' +overrides: + go: + rename: + createdat: CreatedAt + updatedat: UpdatedAt +sql: + .... +``` + +# DCache + +[DCache](https://github.com/Stumble/dcache) is the core of protecting the database. + +# WPgx + +[WPgx](https://github.com/Stumble/wpgx) stands for 'wrapped-Pgx'. It simply wraps the common +query and execute functions of pgx driver to add prometheus and open telemetry tracer. + +In addition to original pgx functions, we added a `PostExec(fn PostExecFunc)` to both +normal connection type `WConn` and transaction type `WTx`. The `fn` will be executed after +the 'transaction' is successfully committed. A common usecase is to run cache invalidation +functions post execution. + +The code of wpgx is very simple, the best way to understand it is to read its source codes. + +## Telemetry + +### Prometheus + ++ {appName}_wpgx_conn_pool{name="max_conns/total_conns/...."}: connection pool gauges. ++ {appName}_wpgx_request_total{name="$queryName"}: number of DB hits for each query. ++ {appName}_wpgx_latency_milliseconds{name="$queryName"}: histogram of SQL execution duration. + +### Open Telemetry + +TBD. + +### Transaction + +You should use `Transact` function to make a transaction. + +```go +func (u *Usecase) ListNewComicBookTx(ctx context.Context, bookName string, price float32) (id int, err error) { + rst, err := u.pool.Transact(ctx, pgx.TxOptions{}, func(ctx context.Context, tx *wpgx.WTx) (any, error) { + booksTx := u.books.WithTx(tx) + activitiesTx := u.activities.WithTx(tx) + id, err := booksTx.InsertAndReturnID(ctx, books.InsertAndReturnIDParams{ + Name: bookName, + Description: "book desc", + Metadata: []byte("{}"), + Category: books.BookCategoryComic, + Price: 0.1, + }) + if err != nil { + return nil, err + } + if id == nil { + return nil, fmt.Errorf("nil id?") + } + param := strconv.Itoa(int(*id)) + err = activitiesTx.Insert(ctx, activities.InsertParams{ + Action: "list a new comic book", + Parameter: ¶m, + }) + return int(*id), err + }) + if err != nil { + return 0, err + } + return rst.(int), nil +} +``` + +This example can be found in bookstore example project. + +# Unit testing + +Most Unit tests follows this pattern: + +1. Setup dependencies like DB, Redis and etc.. [X] +2. Load background data into DB. [X] +3. Run functions the test hopes to check. +4. Verify output of the function is expected. +5. Verify DB state is expected. [X] + +Steps with [X] mark indicate that we can use boilerplate function or code generated from +the `sqlc + wpgx` combo. + +For example, to test a 'search book by names' usecase, the unit test may: + +1. Setup a *Wpgx.pool that connects to the DB instance and pass it to the usecase. +2. Insert some book items into books table. +3. Run the search usecase function. +4. Expect the number of returned value to be N. +5. Verify that books table has not been changed at all, but the search_activity table does + have a new entry. + +It is **highly recommended** to read [this example](https://github.com/Stumble/bookstore/blob/main/pkg/usecases/usecase_test.go), which is the example code that +leverages auto-generated code to test the above usecase. + +The workflow usually is: +(You may need to `export POSTGRES_APPNAME=xxxtests`). + +1. Write tests. +2. Run `go test -update` to automatically generate golden files. +3. Verify that DB states in auto-generated golden files are expected. +4. Commit it and run `go test` again and in the future. + +## Setup DB connection for the test + +PgxTestSuite is a Testify.testsuite with some helper functions for easy-writing (1), (2) and (5). + +First, your test suite needs to embed the WPgxTestSuite and initialize it with a wpgx.Config. +Like the below example, you can directly use the configuration of envvar. You can also +create the suite via `NewWPgxTestSuiteFromConfig`, if you hope to pass a Config. + +One caveat: You must set POSTGRES_APPNAME envvar if you want to use the default +NewWPgxTestSuiteFromEnv, +because it is required. For example, you can do `export POSTGRES_APPNAME=xxxtests`. + +```go +import ( + "github.com/stumble/wpgx/testsuite" +) + +type myTestSuite struct { + *testsuite.WPgxTestSuite +} + +func newMyTestSuite() *myTestSuite { + return &myTestSuite{ + WPgxTestSuite: testsuite.NewWPgxTestSuiteFromEnv("testDbName", []string{ + `CREATE TABLE IF NOT EXISTS books ( + // ..... + );`, + // add other create table / index / type SQL here, so that they will be + // executed before each test. + // If you are using sqlc-generated code, you can just add all the xxxrepo.Schema here. + }), + } +} + +func TestMyTestSuite(t *testing.T) { + suite.Run(t, newMyTestSuite()) +} +``` + +Please also note that you **must** to override the `SetupTest()`, by calling the embedded +one first, then initialize member variables of the testsuite. + +```go +func (suite *usecaseTestSuite) SetupTest() { + suite.WPgxTestSuite.SetupTest() + // make sure all test targets are initialized after ^ function. + // because DB will be dropped and connections will be terminated. + suite.usecase = NewUsecase(suite.GetPool()) +} +``` + +For every test case, `SetupTest` will be automatically triggered at the beginning. But if you +hope to run sub-tests under one test case function, you will need to manually call +this function. This is a common pattern if you write table-based tests, for example, + +```go + for _, tc := range []struct { + tcName string + input string + expectedErr error + } { + {"case1", "a", nil}, + {"case2", "b", nil}, + } { + suite.Run(tc.tcName, func() { + suite.SetupTest() + //.... unit test logics + }) + } + +``` + +## Loader and Dumper + +The testsuite defined two interfaces: + +```go +type Loader interface { + Load(data []byte) error +} +type Dumper interface { + Dump() ([]byte, error) +} +``` + +They are **table-scope** loader and dumper that can load/dump table from/to bytes. +The wicked-fork sqlc will automatically generate load and dump functions for each table schema. +You just need to create a wrapper struct to implement these two interface. + +```go +type booksTableSerde struct { + books *books.Queries +} + +func (b booksTableSerde) Load(data []byte) error { + err := b.books.Load(context.Background(), data) + if err != nil { + return err + } + // most of loader should just end here and return nil, + // but if you have a serial type in the table schema, + // we need to reset its next value after manual insertions. + // example SQL: + // SELECT setval(seq_name, (SELECT MAX(id) FROM books)+1, false) + // FROM PG_GET_SERIAL_SEQUENCE('books', 'id') as seq_name + return b.books.RefreshIDSerial(context.Background()) +} + +func (b booksTableSerde) Dump() ([]byte, error) { + return b.books.Dump(context.Background(), func(m *books.Book) { + m.CreatedAt = time.Unix(0, 0).UTC() + m.UpdatedAt = time.Unix(0, 0).UTC() + }) +} +``` + +Usually, you would want to set some time-related or any other 'flying' values to a fixed +value before dumping them, to avoid creating flaky tests. Like in this example, `CreatedAt` and +`UpdateAt` are set by DB's `NOW()` function. Comparing these values are likely never going to +result an equal. + +## Testsuite helpers + +The testsuite provides these 3 helper functions: + +```go +// load state into memory from file. +func (suite *WPgxTestSuite) LoadState(filename string, loader Loader); +// dump table state to file name via dumper. Hardly directly used, mostly indirectly called +// by Golden(..). +func (suite *WPgxTestSuite) DumpState(filename string, dumper Dumper); +// dump table state via dumper to memory, and load testdata/xxx/yyy.${tableName}.golden +// into memory and then compare these two. +func (suite *WPgxTestSuite) Golden(tableName string, dumper Dumper); +``` + +Example code snippets: + +```go + suite.Run(tc.tcName, func() { + // for sub-tests, must manually rerun SetupTest(). + suite.SetupTest() + + // must init after the last SetupTest() + bookserde := booksTableSerde{books: suite.usecase.books} + + // load state + suite.LoadState("TestUsecaseTestSuite/TestSearch.books.input.json", bookserde) + + // run search + rst, err := suite.usecase.Search(context.Background(), tc.s) + + // check return value + suite.Equal(tc.expectedErr, err) + suite.Equal(tc.n, rst) + + // verify db state + suite.Golden("books_table", bookserde) + suite.Golden("activitives_table", activitiesTableSerde{ + activities: suite.usecase.activities}) + }) +``` + +### LoadState + +You can use this function to populate table with data from a JSON file. Rows in the +JSON file will be appended to the table. + +```go + // must init after the last SetupTest() + bookserde := booksTableSerde{books: suite.usecase.books} + // load state + suite.LoadState("TestUsecaseTestSuite/TestSearch.books.input.json", bookserde) +``` + +### LoadStateTmpl + +Besides loading table state with raw JSON file, you can also load a +[go template](https://pkg.go.dev/text/template) that can be executed with runtime variables +into a JSON file. Templates can be more readable than raw JSON file if there are many +repeated values. It is extremely useful when you hope to test queries related to time. + +For example, when if you have a materialized view that collects the trading volume of the +last 30 days. + +```sql +CREATE MATERIALIZED VIEW IF NOT EXISTS by_book_revenues AS + SELECT + books.id, + sum(orders.price) AS total, + sum( + CASE WHEN + (orders.created_at > now() - interval '30 day') + THEN orders.price ELSE 0 END + ) AS last30d + FROM + books + LEFT JOIN orders ON books.id = orders.book_id + GROUP BY + books.id; +``` + +There is no elegant way for you to change the return value of `now()`. So, the recommended +solution is to load table state with rows of relative time to `now()`. + +You can create a order template file like the following: + +```template +[ + { + "order_id": "ff", + "price": 0.111, + "book_id": 1, + "created_at": "{{.P12H}}", + }, + { + "order_id": "ee", + "price": 3, + "book_id": 1, + "created_at": "{{.P48H}}", + }, + { + "order_id": "zz", + "price": 0.222, + "book_id": 1, + "created_at": "{{.P35D}}", + } +] +``` + +When you use this template into state using + +```go +now := time.Now() +tp := &TimePointSet{ + Now: now, + P12H: now.Add(-12 * time.Hour).Format(time.RFC3339), + P48H: now.Add(-48 * time.Hour).Format(time.RFC3339), + P35D: now.Add(-35 * 24 * time.Hour).Format(time.RFC3339), +} +suite.LoadStateTmpl("orders.json.tmpl", suite.OrdersSerde(), tp) +``` + +there will be 3 orders in the table, with created_at of 12 hours / 48 hours / 35 days ago, relative to the current time. + +### Compare table state using Golden + +After you ran the logics you hope to test, you can use compare the DB state using Golden. +For every table you hope to verify, you need to call one Golden function. + +For the first time, you can use `go test -update` to automatically generate the golden files. + +```go + // verify db state + suite.Golden("books_table", bookserde) + suite.Golden("activitives_table", activitiesTableSerde{ + activities: suite.usecase.activities}) +``` + +### Compare value using GoldenVarJSON + +When you have some huge variables that you do not want to write lengthy value checks +, you can use `GoldenVarJSON`, as long as the variables are JSON marshall-able, +and they are exported, (they starts with a CAPITAL letter). + +So instead of, + +```go + suite.Equal("haha", var1.Name) + suite.Equal(123, var1.ID) + suite.Equal(NewVar2(1,2,3,4), var2) +``` + +you can write following two lines and then use `go test -update` to generate +the 'expected' value for the time. After manually verifying that they are truly expected, +you can just commit files into git. Then, you are covered. + +```go + suite.GoldenVarJSON("var1", var1) + suite.GoldenVarJSON("var2", var2) +``` + +Files are saved in the same directory as other golden files, under the +directory of the test case, with suffix of `.var.golden`. + +## Known issues + +1. Cannot use auto-generated loader + if the table has any `GENERATED ALWAYS AS IDENTITY` column. + Unless required by business logics, it is recommended to use `GENERATED BY DEFAULT AS IDENTITY`. + +# FAQ + +## Refresh materialized view using pg_cron + +### Installation + +See [this doc](https://docs.amazonaws.cn/en_us/AmazonRDS/latest/UserGuide/PostgreSQL_pg_cron.html) of how to enable pg_cron for RDS PostgreSQL. + +### Refresh materialized views example + +```sql +SELECT cron.schedule( + 'hourly_refresh_all_materialized_views', + '0 * * * *', + $CRON$ + REFRESH MATERIALIZED VIEW CONCURRENTLY book_metrics; + REFRESH MATERIALIZED VIEW CONCURRENTLY customer_metrics; + $CRON$ +); +``` + +### Setup log clean-up job + +```sql +SELECT cron.schedule('0 0 * * *', $CRON$ DELETE + FROM cron.job_run_details + WHERE end_time < now() - interval '7 days' $CRON$); +``` + +### Other Commands + +List scheduled jobs: + +```sql +select * from cron.job; +``` + +Turn ON/OFF jobs. + +```sql +-- deactivate +update cron.job set active = false where jobid = $1; +-- activate +update cron.job set active = true where jobid = $1; +``` + +Unschedule + +```sql +SELECT cron.unschedule(@jobid); +``` + +Get job run logs: + +```sql +SELECT * FROM cron.job_run_details; +``` + +setting up cross-db jobs, extra steps are required: + +```sql +UPDATE cron.job SET database = 'otherDB' WHERE jobid = $1; +``` diff --git a/README.md b/README.md index 43fed122b9..f0880b1920 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # sqlc: A SQL Compiler +This is the **wicked fork** of sqlc, with a dedicated Go backend for wpgx and +dcache. See [GUIDE.md](GUIDE.md) for its schema conventions, timeout/cache options, +installation, and migration testing. The current migration is two commits on +upstream v1.31.1: core changes, then the wicked backend, preserving the v2.3.4 API. + ![go](https://github.com/sqlc-dev/sqlc/workflows/go/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/sqlc-dev/sqlc)](https://goreportcard.com/report/github.com/sqlc-dev/sqlc) diff --git a/docs/changelogs/2026-09-08-sync-upstream-and-separate-wicked-codegen.md b/docs/changelogs/2026-09-08-sync-upstream-and-separate-wicked-codegen.md new file mode 100644 index 0000000000..a4983a331e --- /dev/null +++ b/docs/changelogs/2026-09-08-sync-upstream-and-separate-wicked-codegen.md @@ -0,0 +1,651 @@ +# refactor: sync upstream and separate wicked code generation + +> Publication update (2026-09-09): the user requested a clean upstream-based PR +> containing exactly two commits: core changes, then the wicked backend. The +> earlier merge-based plan and commit references below are historical; the final +> publication structure is recorded at the end of this document. + +## 1. Background and Current State + +### Objective and agreed scope + +将 Stumble/sqlc 同步到现代上游,并重新划分编译器与 wicked Go backend 的职责。 +用户已明确选择:需要改动 sqlc 前端的能力继续在 fork 中维护;其余定制尽量归入生成后端。 +不再以“完全不改上游”为约束,也不为此额外引入配置 wrapper、第二套 SQL parser 或类型分析器。 +所有补丁,包括准备贡献上游的通用编译器修复,都先以 commit 形式保存在自己的 fork。 +先完成 fork 的实现及整体验证,确认自己的整套流程可用后,再整理通用补丁向上游提交 PR。 +上游是否接收不阻塞本次迁移。 + +目标仍是保持现有下游的安装、配置、SQL 和业务调用习惯。以下保留批准的架构和实现设计, +实施结果、验证证据和已知限制见第 7-8 节。 + +### Verified implementation and history + +- 当前 wicked 基线:`v2.3.4` / `88a55112526b360dcf46ccdefe9e586e57106e27`。 +- 与上游共同祖先:`e6c7cb31f7f218874ce5dcf883964d9e7a5328cd`,2023-09-20。 +- fork 相对共同祖先有 23 个独有提交,净修改 45 个文件。 +- 实验使用官方稳定版 `v1.31.1`;也检查了上游 main `3c2546a4b` 的接口与近期架构变化。 + 最终同步目标版本属于后续设计需要明确的版本选择;本文不把 main 与稳定版视为相同代码。 +- `d431dc897`:自定义注释、wpgx 生成器、主模型选择、schema 逆序加载、Schema/Load/Dump 等初始实现。 +- `a2d6677ef`、`dad80f45f`:CountIntent 统计以及 SELECT 默认值。 +- `35a0729e3`:副本支持、ReadOnlyQueries、allow_replica。 +- `8993e7966`:按参数引用上下文选择类型推导位置。 +- `45952f709`:wpgx JSON/JSONB 映射到 json.RawMessage。 +- `ae8e08c80`、`88a551125`:并发失效的数据竞争修复、nil cache 保护。 + +当前前端会解析 `-- -- key: value`,把结果放入 Query.options,并直接依赖 Go generator 的选项常量。 +它还反转展开后的 schema 文件列表,以原来第一份 schema 标记主模型,并保存其原始 SQL。 +Go generator 根据这些信息生成 wpgx/dcache 调用、类型映射、失效参数、Schema/Load/Dump 和副本 API。 + +### Diagnostic evidence + +在隔离目录使用官方 v1.31.1 和仅捕获请求的 process plugin: + +- bookstore 保留原 schema 顺序时,revenues 的物化视图报 `relation "books" does not exist`。 +- 仅在实验配置中切换生成入口、反转依赖顺序后,5 个包、41 条查询全部通过编译。 +- 41 条查询的自定义注释全部进入标准 Query.comments;无需为注释选项新增协议字段。 +- 标准请求包含分析后的表、列、参数、SQL 和注释,但没有旧 fork 的主模型标记、原始 schema SQL 或完整 AST。 +- `sqlc.narg('id') IS NULL OR id = sqlc.narg('id')` 得到 any。 + 只在第二次引用加 bigint cast 仍为 any;第一次引用加 cast,或先出现列比较,则得到 nullable int8。 + +这些是编译及请求传递实验,不是 wicked backend 迁移、数据库执行或完整 E2E 的验证。 +bookstore 当前快照为 `20cb452`,停在 2024 年,不能单独证明后续 wicked 修复已被保留。 + +### Terminology and known drift + +- 已确认的“主”规范是第一份 schema 与主模型选择。 +- 数据库 PRIMARY KEY、主模型和 cache key 是不同概念。现有 wicked 专有实现中没有发现额外的主键推导器; + Dump 目前按可排序 Go 字段选择排序列,源码仍把索引信息列为将来可能使用的输入。 + 用户提到的“主 key”暂不据此扩展出新的主键功能,后续以具体既有代码为范围。 +- 旧副本检查只判断顶层 SelectStmt,不能证明无写入 CTE、行锁或函数副作用。 +- GUIDE 部分版本与类型描述早于当前代码;兼容行为以当前 v2.3.4 实现和真实调用为依据。 + +## 2. Problem Model and End-to-End Behavior + +### Causal problem + +多年未同步让上游编译器改进与 wicked 特性混在同一旧实现里。 +旧的编译器还承担生成选项的解释和默认策略,使通用 SQL 分析与特定 Go backend 相互依赖。 +此次目标是恢复上游演进能力,同时明确哪些定制应随编译器维护,哪些由 wicked generator 独立维护。 + +### Behaviors and compatibility + +- **B1 — Existing entrypoint:** 下游继续使用 wicked-sqlc 的发布与安装方式,以及现有 `sqlc generate`、 + `sqlc diff` 和 `gen.go.sql_package: wpgx` 配置习惯。后端的逻辑独立不要求用户另外安装工具。 +- **B2 — Schema conventions:** 保留现有主 schema、主模型、依赖 schema、分区和物化视图的已实现约定。 + schema 加载和对象身份由前端处理,backend 消费明确的主模型与源 schema 信息。 +- **B3 — Query options:** 保留已有注释语法。backend 从标准 comments 解析 timeout/cache/invalidate/ + count_intent/allow_replica,并负责这些生成选项的格式、默认值和组合校验。 +- **B4 — Compiler facts:** 前端负责 SQL 解析和类型推导,同时提供生成所需的语句结构事实。 + 语句类型与 `:one/:many` 等结果基数注解保持区分;结构事实不被描述为完整的只读证明。 +- **B5 — Generated contracts:** 保留现有 Go API、nullable 与 JSON 类型约定、cache key、失效参数、 + :one 无结果行为、Schema/Load/Dump、WithTx 和 UseReplica 等调用契约。 + 生成文本的排版与内部组织不作为逐字节兼容承诺。 +- **B6 — Runtime semantics:** 迁移须保留 timeout、缓存命中/回源、无 cache、事务提交后的失效、回滚、 + 多失效目标以及副本方法的现有语义;2025/2026 年修复属于兼容基线。 +- **B7 — General fixes:** 对已证实需要保留的参数推导等通用修复,在 fork 中实现可独立测试的补丁。 + 这些修复先提交到 fork,与 wicked 专属修改一同完成整体验证,再进入上游 PR 阶段。 + 不要求用户为避免维护 fork 而批量改写 SQL,也不把类型推导转移到 backend。 + +### Failure and degraded behavior + +- **F1 — Invalid schema:** 主模型选择有歧义或 schema 依赖不可解析时,生成应失败并定位到输入,不能静默选错表。 +- **F2 — Invalid options:** 缺失必需 timeout、无效时长、未知选项、无效失效目标等继续得到明确的生成错误。 +- **F3 — Inference regression:** 上游更新导致参数/返回类型退化或已有 SQL 无法编译时,作为具体迁移回归处理。 + backend 不通过猜测 Go 类型掩盖分析错误;既有合法动态类型不能被一概判错。 +- **F4 — Replica policy:** 单独讨论是否加强对写入 CTE、行锁和函数副作用的限制; + 不能把更严格策略偷偷混入“行为无感”的迁移中。 +- **F5 — Missing metadata:** wicked backend 缺少必要主模型或语句信息时明确拒绝生成,不靠重新猜测源文件归属补救。 + +### Non-goals and authority + +首轮不附带升级 wpgx/dcache 的运行时设计、不新增主键相关产品能力、不重写通用 SQL 分析器, +也不以零 upstream diff 为验收指标。贡献上游属于 fork 完成整体验证之后的阶段; +在此之前不提交上游 PR,也不将通用修复仅保存在上游分支而遗漏自己的 fork。 +仓库发布、生产部署和下游批量更新仍是各自的后续工作,本文不报告它们已完成。 + +## 3. Research, Findings, and Architecture Decision + +### Alternatives discussed + +| Direction | Consequence | Disposition | +|-----------|-------------|-------------| +| 继续在原 Go generator 上叠加全部定制并整体合并上游 | 保留熟悉的结构,但前端和生成策略继续耦合,合并与回归边界较差 | 不作为本次目标架构 | +| 不改上游,增加配置 wrapper,并在 backend 重新解析 SQL | 可追求原版依赖,但增加配置转换、源文件传递和重复解析,类型推导差异仍需处理 | 用户已明确放弃零修改约束 | +| 维护必要的 sqlc fork 改动,独立维护 wicked backend | 前端保留必要语义与通用修复,生成策略归后端,按职责同步上游 | 用户选择的方向 | + +### Selected responsibilities + +| Responsibility | Owner | +|----------------|-------| +| SQL grammar, catalog, column/parameter inference | Upstream compiler plus necessary generic fixes | +| Wicked schema load order, primary schema/model identification, source preservation | Wicked-specific frontend integration in the fork | +| Statement structure facts needed by generation | Frontend, using the already available AST | +| Transport of comments, catalog, types, and necessary metadata | Explicit compiler/backend contract | +| Comment option parsing, defaults, and generation-policy validation | Wicked backend | +| PostgreSQL-to-Go mapping, templates, cache keys, timeout and invalidation code | Wicked backend | +| ReadOnlyQueries / UseReplica / CountIntent generation policies | Wicked backend, consuming frontend facts | +| Whole-config conventions such as package-name uniqueness | Wicked configuration validation at the generation entrypoint | +| Existing command/config selection and packaging | Fork entrypoint and release integration | + +### Decisions + +- **D1 — Fork where useful:** 必要的前端修改直接在 fork 中完成。改动位置以职责和信息可得性决定, + 不以差异行数为唯一目标,不为绕开前端修改而引入另一套 parser。 +- **D2 — Separate generic patches:** 类型推导等通用修复与 wicked 规范分开维护,具备独立的行为回归证据。 + 通用修复也先形成自己 fork 内可独立审阅和提取的 commits。 + 旧补丁的行为诉求需要保留,旧评分算法并不因此自动成为最终实现或已达到上游接受标准。 +- **D3 — Independent generator:** wicked generator 使用独立的生成边界,避免把其规则继续散落进上游默认 Go generator。 + 保持一个面向用户的工具分发;初期内置 backend 或插件的具体承载形式在实现设计中确定。 +- **D4 — Facts across the boundary:** 前端传递主模型、必要源信息和语句结构;backend 解释选项并决定生成策略。 + 消除当前编译器对 wicked Go 选项常量的依赖。字段和编码方式在实现设计中明确,允许必要的协议扩展。 +- **D5 — Reuse comments:** 自定义选项复用标准 Query.comments,不继续保留仅为了传递这些选项而增加的前端解析与 Query.options。 +- **D6 — Scoped conventions:** wicked schema 和生成约定在 wicked 路径中生效,尽量保持上游默认路径的语义与测试可维护。 +- **D7 — Compatibility-led migration:** 以 v2.3.4 的真实调用、生成契约与运行行为为基线,bookstore 为现有样例证据之一。 + 单独识别上游新能力、必要兼容修复和有意行为变化,避免无意丢失旧修复。 +- **D8 — Fork first, upstream afterward:** 固定顺序为 fork 内实现并提交全部补丁 → 完成生成、回归和下游整体验证 + → 整理可通用的补丁并向上游提交 PR。单个补丁测试通过不等于 fork 整体验证完成; + 上游贡献不与尚未验证完成的迁移并行进行,也不作为自己 fork 可用的前置条件。 + +### Risks and unresolved design details + +- **R1 — Version baseline:** 实验覆盖 v1.31.1,不代表已验证当前 main。同步目标版本及其新增编译路径需在后续设计中明确。 +- **R2 — Protocol boundary:** 上游标准协议没有主模型标记和完整语句信息。必要字段、兼容方式和 backend 的承载形式尚未设计。 +- **R3 — Replica semantics:** 顶层 SELECT 不等于完整只读或业务允许副本。首轮兼容范围与额外修正要明确区分。 +- **R4 — Inference correctness:** 参数上下文评分只是旧实现,最终补丁需要可重复、稳定且有行为覆盖的推导规则。 +- **R5 — Consumer coverage:** bookstore 停留在 2024 年,仍需对后续 API/类型/失效修复和实际下游使用面补齐验证。 +- **R6 — Key terminology:** 当前确认的是主 schema/主模型与既有 cache key;额外数据库主键规则如确有需求,须先定位具体实现。 +- **R7 — Validation status:** 此文完成架构记录,不代表实现、生成产物对比或数据库 E2E 已通过。 + +### Primary references + +- [Wicked guide](https://github.com/Stumble/sqlc/blob/88a55112526b360dcf46ccdefe9e586e57106e27/GUIDE.md) +- [Original schema conventions](https://github.com/Stumble/sqlc/blob/88a55112526b360dcf46ccdefe9e586e57106e27/internal/compiler/compile.go) +- [Query classification and inference changes](https://github.com/Stumble/sqlc/blob/88a55112526b360dcf46ccdefe9e586e57106e27/internal/compiler/parse.go) +- [Generation policy and invalidation wiring](https://github.com/Stumble/sqlc/blob/88a55112526b360dcf46ccdefe9e586e57106e27/internal/codegen/golang/result.go) +- [Runtime templates](https://github.com/Stumble/sqlc/tree/88a55112526b360dcf46ccdefe9e586e57106e27/internal/codegen/golang/templates/wpgx) +- [Bookstore](https://github.com/Stumble/bookstore/tree/20cb452) +- [Standard plugin protocol](https://github.com/sqlc-dev/sqlc/blob/v1.31.1/protos/plugin/codegen.proto) +- [Upstream generator dispatch](https://github.com/sqlc-dev/sqlc/blob/v1.31.1/internal/cmd/generate.go) +- [Official Go plugin extraction](https://github.com/sqlc-dev/sqlc-gen-go) +- [Plugin documentation](https://docs.sqlc.dev/en/v1.31.1/guides/plugins.html) +- [PostgreSQL hot standby restrictions](https://www.postgresql.org/docs/current/hot-standby.html) + +## 4. Implementation Design + +### 4.1 Baseline, repository scope, and integration + +实现方案默认选上游稳定版 **v1.31.1**,保留当前 **v2.3.4** 为 wicked 行为基线。 +当前 main 包含额外分析器与方言改造,暂不并入本轮;用户如选择 main,需先更新本节及相关验证设计。 +首版将独立 backend 编译进现有 sqlc 二进制,不引入另外安装的 process/WASM 插件。 + +- 主要仓库:Stumble/sqlc。本地工作目录 `/home/forge/sqlc`,分支 `refactor/upstream-sync`,起点为原 fork main。 +- 测试样例仓库:Stumble/bookstore。只调整测试环境、回归用例、必要测试输入与生成样例;不改变业务实现来适配错误的生成 API。 + 开始修改时增加同名本地 changelog,引用本记录。 +- 上游代码通过正常 merge 纳入,保留 fork 历史和旧标签;不改写 main、不强推、不执行伪合并。 + 合并冲突以 v1.31.1 的结构为基础解决,再按下面的职责移植 wicked 行为。 +- 通用推导修复与 wicked 专用代码形成可独立审阅的 commits。最终所有必要补丁都必须存在于自己的 fork。 +- 不修改生产服务、数据库 schema/migrations、运行环境或当前其他工作区的下游生成文件。 + +已完成的规划验证:merge-tree 预检报告 20 个冲突文件,没有执行工作树合并。 +v2.3.4 源码已构建成隔离基线二进制,并在 bookstore 的隔离快照重生成成功; +重生成后的全部 Go 包及测试编译通过(仅编译,未连接数据库执行)。 +相对 bookstore 保存的 v2.3.0 产物,有 16 个文件变化,包括 json.RawMessage 和失效修复。 +后续差分以这份重新生成的 v2.3.4 产物为基线,避免把历史差异计入本次迁移。 + +### 4.2 Exact change map + +| Location | Responsibility / intended change | +|----------|----------------------------------| +| `internal/config/config.go`, `validate.go` | 识别现有 wpgx 配置;wicked 范围内的有效 package 名唯一性校验;复用上游其他配置验证 | +| `internal/compiler/compile.go`, `engine.go`, `result.go`, new `wicked.go` | wicked schema 逆序加载、主 schema/主对象识别与保存;普通路径继续使用上游行为 | +| `internal/compiler/parse.go` | 通用参数引用选择修复;删除旧版生成选项解析/策略依赖 | +| `protos/plugin/codegen.proto`, generated `internal/plugin/*` | 增加隔离的 wicked 元数据契约,保留标准 comments;使用生成器更新绑定 | +| `internal/cmd/shim.go` | 将编译结果、主对象和 AST 结构事实转换为生成请求 | +| `internal/cmd/generate.go` | wpgx 配置选择独立 wicked handler;保留标准 Go/JSON/process/WASM dispatch | +| new `internal/codegen/wicked/` | 独立 Go backend,基于现代生成接口移植 wicked 类型/参数模型、选项、模板、imports、cache key 与 Dump/Load | +| `internal/codegen/golang/` | 回到上游默认 generator 实现,移除夹杂其中的 wicked 分支和旧专用文件 | +| `internal/compiler/*_test.go`, `internal/cmd/*_test.go`, `internal/codegen/wicked/*_test.go` | 编译事实、协议、生成行为与选项错误的测试 | +| `internal/endtoend/testdata/wicked_*` | CLI 生成/诊断 fixtures;正确期望由检查后的 baseline 和具体行为断言确定 | +| new `scripts/test-local/` | 为上游测试启动本次专属 PostgreSQL/MySQL,向测试进程传入 URI,并按容器 ID 清理 | +| `Makefile`, `go.mod`, `go.sum`, CI workflows, `GUIDE.md` | 新版工具链、单二进制构建安装、可重复 proto 生成、测试入口、最新使用与贡献说明 | +| bookstore `pkg/usecases/*_test.go`, new `internal/testenv/`, `Makefile`, CI | 专属 PostgreSQL/Redis 环境、事务/缓存/timeout/副本运行回归 | +| bookstore `pkg/repos/*/*.go`, affected goldens | 从新工具生成并审阅,与当前 fork baseline 对照;不手改生成代码 | + +领域参考中的数据库安全、serde 和测试隔离原则适用;这是 CLI/compiler 项目, +不引入 Alva 服务端 testsuite、网关鉴权或完整 alva-local-dev 服务栈。 +“业务服务不直接测试生成 repos”的惯例不适用于生成器自身的输出验证。 + +### 4.3 Compiler/backend contract + +标准 GenerateRequest / GenerateResponse、Query.comments、catalog 和 SQL 类型字段继续使用上游结构。 +专用信息集中到一个契约内,不增加一套通用自定义参数协议。 + +拟定的核心 proto 增量(现有字段省略): + +```proto +message GenerateRequest { + oneof backend_metadata { + WickedMetadata wicked = 1000; + } +} + +message WickedMetadata { + string primary_schema_path = 1; + string primary_schema_sql = 2; + Identifier primary_relation = 3; + map query_is_select = 4; +} +``` + +- `primary_relation` 使用 catalog/schema/name 身份,不能只用表名猜测;视图也使用 relation 身份。 +- `query_is_select` 的 key 是同一 query set 内已校验唯一的查询名;每条具名查询必须有记录。 + false 与缺少 key 不同,缺少记录是明确的接口错误。 +- 该 bool 精确表达旧代码的“顶层 SelectStmt”,不命名为 read_only,也不新增未使用的行锁/副作用分析。 +- 只有 wicked 模式附带元数据。oneof 用于保留普通请求的 JSON 形状:上游 JSON generator 使用 + EmitUnpopulated,而未设置的 oneof 不被输出;该兼容点必须有测试。 +- 1000 是本 fork 使用的增量字段号,后续同步仍须核对冲突,不假定高编号永不被上游占用。 +- 旧 fork 删除的 Query.options 9、Catalog.raw_sqls 5、Table.generate_model 4 保留 reserved tag/name,防止误复用。 + 旧 fork 的内部 proto 不是下游应用 API,本次由新结构替换,不承诺旧自定义插件二进制可直接加载。 +- 不传完整 AST,不在 backend 依赖 compiler、catalog 的内部 Go 对象或重新读取 SQL 文件。 + +backend 的入口与上游一致: + +```go +func Generate(ctx context.Context, req *plugin.GenerateRequest) (*plugin.GenerateResponse, error) +``` + +Go 配置走标准 PluginOptions / GlobalOptions。backend 局部适配 legacy wpgx 名称后复用 Go options 的解析能力, +其中覆盖规则/rename 的优先级须通过 fixture 与 v2.3.4 对照。普通 Go generator 不增加 wpgx 校验例外。 +生成阶段只返回输出文件或错误,不直接写磁盘,也不修改传入请求,避免多个输出消费者相互影响。 + +### 4.4 Schema and query pipeline + +代表流程是 bookstore 的 revenues query set: + +1. 配置识别为 wicked,使用上游 sqlpath 展开 schema 路径;在改变顺序前保留原第一份文件的身份。 +2. 对 wicked 路径反转展开后的文件列表,先解析 books,再解析 orders,最后解析 revenues。 + 普通上游路径保持原顺序。 +3. 用现有 parser/catalog 构建对象;在读取每份文件时应用旧的一文件一个逻辑布局约定, + 物理分区的无新列布局不会被当作第二个主模型。 +4. 在原第一份文件中识别主对象,保留用于生成 Schema 的源内容;依赖对象仍留在 catalog 参与查询分析。 +5. 类型分析完成后,cmd shim 从 Query.RawStmt 记录顶层 SELECT 与否,并把主对象信息写入 WickedMetadata。 +6. backend 仅为主对象输出主模型,保留原来的 enum 与结果结构处理;用传入的源内容生成 Schema、Load、Dump。 + +优先在 compiler.Result 的私有 wicked 数据中保留主对象身份,避免给所有 catalog.Update 调用添加生成策略参数。 +对原始导出 Schema 与用于分析的预处理 SQL 分开处理:保留 v2.3.4 的导出语义,并使用上游解析所需的 migration/psql 预处理。 +空 schema、多个主布局、无法匹配 catalog 对象必须报错;错误包含配置/文件及对象上下文。 + +当前源码的普通 ViewStmt 没有旧 GenerateModel 标记,不能仅凭 GUIDE 就声称已有完整支持。 +基线 fixtures 将记录普通视图、物化视图与分区的实际差别;首轮不把新增普通视图能力作为隐藏目标。 +新的实验性 database-only 分析如无法提供可靠主模型,wicked 路径须明确诊断,不输出缺失模型的代码。 + +### 4.5 Generic inference patch + +移植现有“同一参数使用信息更充分的引用上下文”修复,不在迁移中设计第二套类型分析器。 + +- 按参数编号聚合已有 paramRef,保留首次出现顺序;选择原 wicked 评分较高的引用,同分保持首次引用。 +- 已有评分语义:显式 cast/比较 100;算术/连接/LIKE/limit/offset 90;BETWEEN 75;IN 70; + 结果目标及 AND/OR 60;一般表达式/NOT 50;函数/其他布尔 40;空上下文与 NULL 判断低优先级。 + 具体分支以旧补丁为迁移依据,并用真实 SQL 推导结果验证,不能用评分表本身作为全部测试断言。 +- 初版曾把 INSERT/UPDATE 赋值目标提到 100 来规避重复表引用的歧义;下游审计证实这会无意改变 nullable。 + 复核后的修复恢复旧评分 60,在 resolveCatalogRefs 中仅去重重复的无别名 relation。 + 同一表跨 CTE/主语句不会再被重复计数;不同 alias 仍保留,真实 self-join 歧义不被掩盖。 +- 保留未编号参数的编号分配和 named/narg 的 nullability 信息;之后仍使用上游排序及 resolveCatalogRefs。 +- 不用 map 的迭代顺序决定参数顺序。复杂或互相矛盾的 SQL 类型约束继续遵循编译器已有诊断能力, + 该补丁不被描述为完整的约束求解器。 +- `IS NULL` 在前、cast 在后、比较在后、多次引用、显式 nullable、单次引用、参数编号与重复执行稳定性为关键覆盖。 + +这项修复形成独立 commit。先在 fork 验证通用和 wicked 两条生成路径,之后再准备上游 PR; +上游评审需要的进一步算法整理仍应先提交并验证在自己的 fork。 + +### 4.6 Backend policy and runtime compatibility + +注释处理:对每条 comment 去除外围空白;以 `--` 开头的项按首个冒号分割 key/value。 +保留旧的重复 key 后者覆盖行为、时长最小 1ms、必需 timeout、失效目标存在且已启用缓存等规则。 +无效选项由 backend 返回带 package/query 的错误,CLI 返回失败;不把 wicked 校验重新放进 metadata parser。 + +`count_intent`、`allow_replica` 默认值及 SELECT 的 invalidate 禁止规则由 backend 使用 query_is_select 决定。 +首轮保留现有 SELECT 判断和各模板的调用行为;加强行锁、写入 CTE 或函数副作用限制属于单独的行为变更。 + +以 v2.3.4 模板和调用契约为标准,保留: + +- 主模型字段、nullable enum 包装、JSON RawMessage、UUID/time/numeric/range 等映射;Go 类型选择与 SQL 类型推导分开。 +- 原有 query_parameter_limit、参数结构、返回结构、指针和失效参数签名,以及 :one 无结果返回 nil,nil。 +- 模型 JSON tags、enum helper 等既有规则;不笼统强制所有 options,以免顺带改变 enum tags 等旧行为。 +- cache key 的 package/query 前缀、参数顺序、nil 表达与长 key 哈希;跨版本缓存兼容属于验收项。 +- timeout 覆盖缓存读取及数据库调用;失效回调继续捕获原 ctx,保留提交后执行、nil cache 保护和并发局部错误变量。 +- 无参数失效、多目标失效、**T 失效参数、copyfrom、UseReplica/AsReadOnly/WithTx/WithCache、Schema/Load/Dump。 +- 原来已有的错误返回和日志语义,不在迁移时另改 PostExec 的错误传播契约。 + +批量 :batch* 在旧 wicked 中仍有未完成实现(模板调用 WGConn 不提供的 SendBatch)。 +标准上游 batch 能力继续测试;wicked 未支持的组合给出明确诊断,不把生成无法编译的代码算作支持。 +任何已在真实下游工作而未被上述清单覆盖的情况,都以基线与调用证据补入验证范围。 + +### 4.7 Build, test environment, and delivery + +- 初始工具链按上游使用 1.26.2;CI 后续安全核查要求升级到同系列修复版本 1.26.8, + go.mod 的最低版本、工具链和 CI 安全检查保持一致。主机 GOTOOLCHAIN=auto。 + 保留默认 CGO 构建,另验证上游提供的非 CGO 路径。旧 v2.3.4 基线可使用当前已成功的构建方式。 +- `make proto` 保留 Buf 生成路径;增加 BUF 参数,允许使用固定版本 + `go run github.com/bufbuild/buf/cmd/buf@v1.72.0`。当前没有 buf/protoc,不能手改生成绑定代替生成。 +- 保留 wicked 版本标识、make build/install 的使用方式;普通上游 CI 与 fork 的 wicked 回归检查均要保留。 +- 上游内置 Docker 测试 helper 使用固定容器名和 5432/3306,不能在共享主机直接依赖自动启动路径。 + `scripts/test-local` 启动本次专属 postgres:16/mysql:9 容器并使用随机宿主端口, + 以它创建的连接串覆盖测试子进程的 POSTGRESQL_SERVER_URI/MYSQL_SERVER_URI。 + 复用上游测试的 URI 接口,保持默认测试代码不变;正常退出、失败和信号退出均按本次容器 ID 清理。 +- bookstore 的现有测试硬编码 Redis 6379,且会 FlushAll;wpgx suite 会删除并重建测试库。 + 新 `internal/testenv` 为测试拥有独立 PostgreSQL 和 Redis 容器、随机宿主端口,并将显式测试配置传给 suite。 + 清理只针对本次创建的容器 ID;不借用当前工作区的数据库/Redis,不执行全局容器清理。 +- bookstore 继续固定 wpgx v0.3.1、dcache v0.1.3 的运行时依赖;测试基础设施调整不附带运行时升级。 + 副本测试延续同实例的独立连接配置,验证生成 API/路由行为,不声称验证真实复制延迟。 +- 生产无需 DDL、数据回填或部署操作。回退为重新使用 v2.3.4 工具和对应已提交产物。 +- 完成 fork、样例、文档及回归验证后,再进入自己仓库的发布流程;上游 PR 排在整体验证之后。 + +### Serial Implementation Checklist + +- [x] 保存并审阅 v2.3.4 对照产物,建立生成签名/cache-key/运行行为 fixtures(B1/B5/B6;结构检查与基线)。 +- [x] 将 v1.31.1 纳入工作分支,按新接口独立迁移 wicked backend,恢复构建和普通生成路径(B1/D1/D3/D6;沿用上游测试)。 +- [x] 定义并生成 WickedMetadata,接入主 schema/主对象和 query_is_select,完成 scoped dispatch(B2/B4/F1/F5;契约与 CLI 测试)。 +- [x] 独立移植通用参数引用修复,用真实 SQL 推导回归验证后形成独立 commit(B7/F3/D2/D8;test-first)。 +- [x] 完成注释选项、类型/模板、失效签名与 key、helper 的迁移,逐项对照基线(B3/B5/B6/F2/F4;测试与迁移并行)。 +- [x] 在 bookstore 增加本地 changelog、隔离 testenv 和运行回归,重生成并审阅样例及必要 goldens(B1/B5/B6;回归 test-first)。 +- [x] 更新 GUIDE、构建/安装/proto 和 CI,运行第 5 节检查,记录结果并审阅完整 diff(所有 B/F/R;现有覆盖加新增回归)。 +- [x] 完成自己的 fork 全部补丁与整体验证,保留独立通用修复;上游 PR 为验证完成后的独立后续工作(D8)。 + +## 5. Verification and E2E Design + +### 5.1 Required evidence + +**E2E Required: yes.** 生成成功和 Go 编译不能证明事务失效、缓存、timeout 和副本调用语义。 +这里的完整链路是本地 CLI → 生成 Go → bookstore 调用 → 专属 PostgreSQL/Redis, +不涉及 Alva 网关/服务栈、生产、真实设备或云端数据库。 + +| Behavior | Evidence / plausible regression detected | +|----------|------------------------------------------| +| B1/D6 | 原 wpgx YAML 无需用户修改;普通 PostgreSQL/MySQL/SQLite fixtures 与插件 tests;发现全局施加 wicked 限制 | +| B2/F1/F5 | 主 schema 在首位、依赖在后、物化视图/分区、多个逻辑表/缺少对象;验证模型身份和 Schema 源文 | +| B3/F2 | comments 选项、重复 key、未知 key、缺 timeout、无效 duration/失效目标;检查明确错误与退出码 | +| B4/F4 | SELECT、WITH SELECT、INSERT/UPDATE/DELETE RETURNING 的事实及方法集;锁/写 CTE 单列旧行为 | +| B5 | 生成 Go AST/API 对照、编译使用点、显式类型与 cache-key 断言;发现类型/参数/标签/key 漂移 | +| B6 | 真实 PG/Redis 的缓存回源、无结果、nil cache、提交/回滚、多目标失效、context 和 replica 用例 | +| B7/F3 | SQL 参数推导 fixtures + 标准生成路径;发现仍为 any、nullable 丢失、编号/顺序不稳定 | +| D4 | protobuf round-trip、缺失 metadata、普通请求无 wicked JSON 字段;发现契约或默认生成接口污染 | +| D8 | commit 历史检查 + 全套验证记录,确认通用修复存在于自己的最终 fork | + +### 5.2 Representative compiler and generator tests + +下面为关键行为的代表性测试设计,helper 将在测试中使用真实 compiler/request,不模拟推导结果: + +```go +func TestWickedNullableParameterContext(t *testing.T) { + // things.id 是 bigint NOT NULL;narg 显式指定参数可空。 + got := compileFixture(t, "nullable_first.sql") + p := onlyParameter(t, got, "FindThings") + require.Equal(t, "int8", canonicalSQLType(p.Column)) + require.False(t, p.Column.NotNull) + // 同样输入重复生成,并检查按参数编号排列的输出一致。 +} + +func TestWickedPrimaryRelation(t *testing.T) { + req := compileWickedFixture(t, "materialized_view_primary") + require.Equal(t, "by_book_revenues", req.GetWicked().PrimaryRelation.Name) + generated := generateWicked(t, req) + require.Equal(t, []string{"ByBookRevenue"}, exportedMainModels(t, generated)) + require.Equal(t, fixturePrimarySQL(t), exportedSchemaValue(t, generated)) +} +``` + +schema/model 名称以 fixture 自己定义的名字为期望,不从被测算法生成期望值。 +生成文本 golden 作为辅助证据:所有变更必须人工检查,API 断言解析 Go AST/类型信息, +关键 runtime 行为使用实际调用验证。允许解释清楚的格式/版本注释变化,不用全量接受快照掩盖回归。 +普通请求 JSON 比较先规范化 JSON,不能依赖 protojson 的空白格式稳定。 + +### 5.3 Representative runtime test + +以下展示提交/回滚的核心断言;fixture 提供既有 Book 和显式缓存/DB 计数,避免仅靠 TTL 或 sleep 判断失效: + +```go +// 在有缓存的 q 上读取并缓存原有 Book。 +before, err := q.GetBookByID(ctx, id) +require.NoError(t, err) +require.NotNil(t, before) + +abort := errors.New("rollback fixture") +_, err = pool.Transact(ctx, pgx.TxOptions{}, func(ctx context.Context, tx *wpgx.WTx) (any, error) { + err := q.WithTx(tx).UpdateBookByID(ctx, books.UpdateBookByIDParams{ + ID: id, Description: "changed", Meta: before.Metadata, Price: before.Price, + }, &id) + if err != nil { return nil, err } + return nil, abort +}) +require.ErrorIs(t, err, abort) +after, err := q.GetBookByID(ctx, id) +require.NoError(t, err) +require.Equal(t, before.Description, after.Description) +// 额外断言回滚没有触发失效/额外回源;相同更新成功提交后,后续读取必须得到 changed。 +``` + +其他运行覆盖: + +- 缓存命中不增加数据库调用;缓存缺失时回源,cache=nil 的读写/失效都正常。 +- 事务尚未提交时失效回调未执行;成功提交才失效,回滚保持原缓存与数据库状态。 +- 多目标、无参数及 **T 失效参数使用正确 key;-race 覆盖旧的共享 err 竞争。 +- 注入阻塞的数据库/缓存路径与 context deadline,验证 timeout 生效;不依赖精确毫秒计时。 +- :one 无行得到 nil,nil;数据库错误得到 error;:many 错误路径不会发生类型断言 panic。 +- 使用已命名 replica 连接执行允许的方法;生成 API 检查禁止普通写入方法出现在 ReadOnlyQueries。 +- 复制、Load/Dump 和 JSON/nullable 数据 round-trip;检查 v2.3.4 已改变的 JSON golden,不能沿用旧 base64 期望。 + +### 5.4 Commands and prerequisites + +以下为实施后必跑命令,当前不是已通过的结果: + +```bash +# Stumble/sqlc 工作分支 +make proto BUF='go run github.com/bufbuild/buf/cmd/buf@v1.72.0' +make build +go run ./scripts/test-local -- go test -count=1 ./internal/compiler ./internal/config ./internal/cmd ./internal/codegen/wicked/... +go run ./scripts/test-local -- go test -count=1 ./internal/endtoend -run 'TestReplay/base/wicked_' +go run ./scripts/test-local -- go test -count=1 -timeout 20m ./... +make build-endtoend +CGO_ENABLED=0 go build -o bin/sqlc-nocgo ./cmd/sqlc + +# Stumble/bookstore,Makefile 增加 SQLC 变量以明确待测二进制 +make sqlc SQLC=/home/forge/sqlc/bin/sqlc +make sqlc-verify SQLC=/home/forge/sqlc/bin/sqlc +go test -run '^$' ./... +make test +go test -race -count=1 -p 1 -timeout 10m ./pkg/usecases +make lint-fix +``` + +完整上游测试通过专属容器的显式 URI 使用其既有测试环境接口,并确认实际数据库用例未被静默跳过。 +需要的插件测试工具按上游 CI 固定版本准备;远端下载失败或托管服务不可用应单列,不报告为全部通过。 +wicked 生成 fixture 不依赖数据库在线;bookstore E2E 必须实际运行在上述专属容器。 +bookstore 的 `make test` 将调用隔离 testenv;不使用现有固定端口的 docker-start/FlushAll 组合访问共享实例。 + +sqlc 仓库没有 make lint-fix,记录 lint 不可用,不发明替代 lint 命令; +proto 生成、编译、必要的 gofmt 和 CI 既定检查仍需完成。bookstore 有 make lint-fix,执行后排除无关格式变动。 +生成和 proto 二次运行要无额外 diff;对所有产品内容变更运行相应新检查,最终 review 后执行完整 E2E。 + +## 6. Human Decisions and Interaction + +- 架构已确认:必要的前端改动保留在 fork,生成策略集中于 wicked backend。 +- 所有补丁先形成自己 fork 的 commits;自己的整套工具与下游验证通过后,再向上游提 PR。 +- 自定义参数复用 comments;保持现有下游输入、安装习惯和生成 API。 +- 用户已批准本代码级计划,并授权连续完成实现、验证、自己的 fork push 和 PR 后汇报。 +- 执行基线:上游 v1.31.1、同一二进制内的独立 backend、保留现有副本判断,bookstore 为运行验证样例。 +- 通用修复继续先提交在自己的 fork;上游贡献遵守 D8 的整体验证前置条件。 + +## 7. Outcome and Evidence + +### Result and review + +本小节记录首次发布时的验证;后续真实下游审计推翻了其中“B5/R5 已充分覆盖”的判断。 +最终以本文件末尾的 post-publication follow-up 和新验证结果为准,不把初版 CI 全绿当作全面兼容的证明。 + +上游 v1.31.1 通过正常 merge 纳入;wicked 生成器位于独立包,按旧 wpgx 配置选择。 +前端保留 schema 约定和类型推导,元数据使用批准的 oneof 契约,comments 负责选项传递。 +通用参数推导补丁为独立 commit `c7f1ceb23`,没有向上游提前提交 PR。 + +按行为、架构、测试、数据/兼容、运维和文档检查了本次自有差异;上游导入部分与 v1.31.1 tag 对齐。 +检查并修复了以下问题,之后重新执行对应测试与最终完整验证: + +- 赋值目标的参数绑定优先级,避免 INSERT … SELECT + CTE 在 managed-db 模式下丢失参数信息。 +- 新版 pg_catalog 类型限定与旧 JSON/UUID 等 Go 类型映射,以及 db_type overrides 的匹配。 +- Go options 解析保留 Catalog 上下文,防止列 override panic;保持局部 rename 优先和旧 override 顺序。 +- 主表身份跟随后续 ALTER/RENAME,再生成最终的规范化标识。 +- emit_interface 的返回指针与 invalidate 参数签名;有生成 fixture 和编译检查。 +- 版本信息改为可通过原 Makefile 链接参数设置,保留 wicked 标识。 + +| IDs | Result | Evidence | +|-----|--------|----------| +| B1, D1/D3/D6 | DONE | 原 bookstore YAML、单二进制、普通 Go/JSON/插件测试、CGO/非 CGO 构建 | +| B2, F1/F5 | DONE | 普通表、分区、物化视图、重命名、错误布局、主源保存 tests | +| B3, F2 | DONE | 注释选项、重复键、时长/未知选项/失效目标验证、缺 timeout CLI fixture | +| B4, F4, D4/D5 | DONE | QueryIsSelect、INSERT RETURNING 方法集、proto round-trip、普通 JSON 无额外字段 | +| B5 | DONE | bookstore 42 条查询的 17 个 Go 文件与相同输入的 v2.3.4 产物仅版本注释不同 | +| B6 | DONE | 实际 PostgreSQL/Redis 的缓存、提交/回滚、失效、nil cache、JSON、copyfrom、timeout、replica tests | +| B7, F3, D2/D8 | DONE | 独立 commit、nullable 参数和赋值绑定 tests、原上游对照、完整上游测试 | +| R1/R2/R4/R5/R6/R7 | Resolved for this migration | 已明确基线、契约、算法修复、样例覆盖和主模型范围;没有新增主键产品能力 | +| R3 | Retained limitation | 保持原 SELECT 分类;不声称能证明任意 SQL 完整只读 | + +### Final local verification + +工作目录 `/home/forge/sqlc`,下列检查均已通过: + +- `make build COMMIT_HASH=v2.4.0-dev`。 +- `go test -count=1 ./internal/compiler ./internal/cmd ./internal/codegen/wicked ./internal/config`。 +- `PATH=/home/forge/sqlc/bin:$PATH go run ./scripts/test-local -- go test -count=1 -timeout 20m ./...`。 +- `PATH=/home/forge/sqlc/bin:$PATH go run ./scripts/test-local -- go test -count=1 -tags=examples -timeout 20m ./...`, + 含上游全部数据库/生成测试;最终 internal/endtoend 用时约 148 秒,PG/MySQL 使用本次专属容器。 +- `make build-endtoend`,含新增 wicked 主模型/interface fixture。 +- `CGO_ENABLED=0 go build -ldflags='-X github.com/sqlc-dev/sqlc/internal/info.Version=v2.4.0-dev-wicked-fork' -o bin/sqlc-nocgo ./cmd/sqlc`。 +- `bin/sqlc-nocgo diff -f /home/forge/bookstore/pkg/repos/sqlc.yaml`,通过。 +- `make proto BUF='go run github.com/bufbuild/buf/cmd/buf@v1.72.0'`,重复生成无额外差异;Buf lint 通过。 +- git diff whitespace 检查与提交范围/暂存区凭据扫描通过。sqlc 没有 make lint-fix,未另造 lint 命令; + Buf 自身的 schema 检查与 Go 编译/测试照常执行。 + +Bookstore 的 `make test`、Go 1.25.7 与 Go 1.26.2 的 race suite、`make lint-fix` 和生成 diff 全部通过。 +14 个 suite 用例及两个 search 子用例使用真实 PostgreSQL/Redis;详情见其本地 changelog。 +三处 JSON golden 的变化属于已有 v2.3.4 RawMessage 行为,已逐个检查。 +未运行生产数据库或完整 Alva 服务栈,因为本次明确验证的是 CLI → Go → 数据库/缓存链路。 + +## 8. Remaining Work + +- 已发布 [sqlc #16](https://github.com/Stumble/sqlc/pull/16) 与 + [bookstore #2](https://github.com/Stumble/bookstore/pull/2)。CI 的最终状态以对应 PR 为准;不自动合并或发布 release。 +- 在自己的整套验证通过的基础上,独立整理通用类型推导补丁的上游 PR。 +- 已保留的限制:顶层 SELECT 分类不等于完整只读证明;ordinary view 不能作为 wicked 主模型; + wicked batch/execlastid 和 database-only 分析不支持;copyfrom 不实现 cache/invalidate 选项。 +- 样例 replica 用例验证 API/连接选择,不模拟物理复制延迟;wpgx/dcache 运行时版本未升级。 + +### CI security follow-up + +首次发布后,GitHub 上的六个平台构建、完整 Go 测试、Buf 和 bookstore 验证均通过。 +安全检查发现 v1.31.1 上游依赖中的 GO-2026-6061(grpc)和 GO-2026-5970(x/text)。 +本地按实际构建工具链扫描还发现 Go 1.26.2 标准库及 x/net 的修复需求。 +修复范围限定为 grpc v1.82.1、x/text v0.39.0、x/net v0.55.0 及其必要传递依赖, +并将工具链升级为 Go 1.26.8,安全检查也固定到该构建版本。 +这不改变编译器的 v1.31.1 代码基线或下游 wpgx/dcache 版本;修改后重新进行完整验证。 + +安全修复后的再次验证全部通过:Go 1.26.8 下的 focused tests、完整 examples/endtoend 套件 +(internal/endtoend 约 152 秒)、CGO/非 CGO 构建、生成 diff 和 bookstore race suite。 +`go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...` 报告 0 个可达漏洞; +工具另提示一个未被调用到的导入包/模块公告,不属于可达代码路径的失败项。 + +参考:[Go security advisories](https://pkg.go.dev/vuln/GO-2026-6061)、 +[x/text advisory](https://pkg.go.dev/vuln/GO-2026-5970)、 +[Go release history](https://go.dev/doc/devel/release#go1.26.8)。 + +### Post-publication compatibility follow-up + +用户要求扩大旧版本对照,随后授权修改当前 PR,并用新 binary 验证 Alva mono repo;允许少量有依据的改进。 +这轮使用追加 commits,不重写已推送历史,不修改 Alva 原工作区、业务代码或生产数据库。 + +审计确认的修复/取舍: + +- A1:恢复 google/uuid,增加实际 import/类型身份及 nullable UUID 的编译断言。 +- A2:恢复旧参数上下文评分,修复重复无别名表的假歧义;保留 nullable 赋值、narg 及真实 self-join 的含义。 +- A3:主模型候选与新布局计数分开;继承表、空 CREATE 后 ALTER、重命名及分区继续保留。 +- A4:Docker release 使用 info.Version,与 make build、CLI 和生成文件版本一致;新增实际二进制测试。 +- A5:保留 exec + RETURNING 曾导出的 Row 类型;不在迁移中顺带删除公共 Go 类型。 +- A6:保留旧 column-only struct tags,避免默认启用 db_type tags 后改变 JSON/共享缓存格式。 +- 保留合理改善:接口签名修正、局部变量冲突修正、CopyFrom 原始列名、内建类型识别;不为了逐字一致恢复已证实旧缺陷。 + +新的 wicked_compat fixture 来自 v2.3.4 的相同 SQL/config 生成结果,覆盖继承模型、Google UUID、Row API 和 JSON tags。 +测试对旧 JSON payload 进行实际编解码,CI 编译消费者;不再只比较类型名称字符串。 +参数编译器测试先验证旧签名回归及重复 relation 假歧义均失败,再修复;发布测试实际构建并运行二进制。 + +完整上游测试发现 insert_select_param 之前只在 managed-db 模式下测试:官方 v1.31.1 本地 compile +对合法的 INSERT … SELECT FROM 同表错误报告 name 歧义,数据库 fallback 丢失了静态参数 nullability。 +新去重逻辑让本地分析成功,参数成为非空 int64/string;已将该 fixture 扩为 base + managed-db, +使用生成器更新其输出并核对仅移除 pgtype 导入、两个字段类型变化。这是有独立证据的通用修正, +不是为了让失败测试通过而回避差异;Alva 218 组输入与旧 v2.3.4 生成结果仍一致。 + +执行清单(本次最终证据将补在此处): + +- [x] 修复 A1–A6 并加入针对性回归。 +- [x] 重新执行完整上游测试、fixture 编译/编解码和 bookstore 数据库/race 验证。 +- [x] 在隔离副本重新生成 mono repo 的全部 wpgx 配置,逐项审阅差异并验证真实消费者(trex 全量本地编译限制见下)。 +- [x] 重跑双版本矩阵,归类少量合理差异,复核完整 net diff。 + +发布沿用 sqlc #16 与 bookstore #2;当前 HEAD 的 CI 和审查反馈状态以 PR 页面为准,不自动合并。 + +#### Final follow-up evidence + +修复提交:`cead568ed`(通用 compiler)与 `62d9e5b26`(wicked/release 兼容性)。 +最终 net diff 已按行为、职责边界、测试、数据兼容、构建和文档重新审阅;默认 Go backend 保持 upstream v1.31.1 源码。 + +sqlc 工作目录 `/home/forge/sqlc`: + +- `make build COMMIT_HASH=v2.4.0-dev`,最终二进制从干净 `62d9e5b26` 构建。 +- CGO 与非 CGO 的 compiler/cmd/wicked/config `go test -count=1` 均通过。 +- `GOMAXPROCS=2 PATH=/home/forge/sqlc/bin:$PATH go run ./scripts/test-local -- go test -p 2 -parallel 8 -count=1 -tags=examples -timeout 20m ./...` 全部通过;endtoend 144.467s,含 base/managed-db、SQLite/MySQL/PostgreSQL、插件及新 fixture。 +- `GOTOOLCHAIN=go1.26.8 make build-endtoend` 通过;testdata module 中 `go test -count=1 ./wicked_compat/go` 通过。 +- `CGO_ENABLED=0 go build -ldflags='-X github.com/sqlc-dev/sqlc/internal/info.Version=v2.4.0-dev-wicked-fork' -o bin/sqlc-nocgo ./cmd/sqlc` 通过;该 binary 的 bookstore `diff` 通过。 +- `GOMEMLIMIT=1GiB GOMAXPROCS=2 go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...`:0 个可达漏洞;另有未调用到的导入包/模块公告。 +- 按 PR base 扫描全部新增提交:未发现凭据;本轮没有 proto 修改,协议 round-trip/标准请求测试随全套重新通过。 + +bookstore 工作目录 `/home/forge/bookstore`:新 binary 的 `make sqlc`、`make sqlc-verify` 和 `git diff --exit-code -- pkg/repos` 均通过,17 个输出无需更新。 +`GOFLAGS=-count=1 make test` 通过(8.690s);Go 1.25.7 race suite 通过(11.851s),Go 1.26.8 race suite 通过(13.768s);`make lint-fix` 为 0 issues。 + +消费者在 `/tmp/sqlc-mono-validation.regHRh/{baseline,current}` 的独立副本中验证: + +- 扫描覆盖 8 个仓库、226 组 wpgx 配置;689 个本次生成的 Go 文件与同输入 v2.3.4 产物仅版本注释不同。 +- alva-backend、jagent、alfs、connectors、llm-data、synthdb、forge 的全量 Go 编译通过;go.work 中的本地模块使用明确的 `./path/...` 一起编译。jagent 按其 AGENTS.md 使用 Clang 21 与 `CGO_CXXFLAGS=-nostdinc++`。 +- trex 的 36 个新生成文件与当前已提交文件也仅版本注释不同;`go build -p 1 ./internal/repos/...` 通过。其同一源码提交 `56027c847620d0a16285ed4d1736301ac1980e99` 的 CI、lint 和镜像构建已确认成功。 +- **验证限制:** trex 完整本地构建的 CCXT 第三方包超过本环境内存软限制。默认构建及限制优化/并发的诊断尝试均已取消;不计为通过。采用本地生成包编译、源代码等价对照与同源码既有 CI 证据;未修改依赖版本、业务代码或提高宿主机限额。 +- 与更老的已提交生成代码相比,差异为旧 fork 已有的 nil-cache/并发失效修复,以及一处手写生成 wrapper 的等价模板化;均已审阅。原 mono repo 工作区及其子模块改动完整保留,没有提交消费者生成文件。 + +双版本矩阵 147 组:113 组生成成功且仅版本注释不同,23 组为已解释的接口/变量冲突/CopyFrom/内建类型识别改善;另有 3 组旧版可生成但无法编译的 batch、3 组新版修复的关键字输入、5 组两版均拒绝的非法 CopyFrom。 +成功生成案例的编译结果已单独分类,没有新引入的“旧包可编译、新包不可编译”;没有把旧版本本来失败的组合计为新回归。 + +资源诊断记录:并行 CCXT 构建引起 cgroup memory.high 压力,期间 bookstore 短 deadline 与一次上游 20m 测试超时;安全扫描亦主动终止。 +停止重型编译后,未修改 SQL timeout、生产代码或测试期望,重新执行得到上述完整通过结果。所有本次测试容器均清理。 +未运行完整 Alva 服务栈/生产数据库;这次的运行时 E2E 边界仍是已批准的 CLI → bookstore → 自有 PostgreSQL/Redis。 + +### Upstream-based two-commit publication (2026-09-09) + +用户要求 PR 直接从 upstream 起步,不再把 merge upstream 的历史放进 review。 +该决定替代第 4.1 节中的 merge-based publication 方式,不改变已验证的版本或代码行为。 + +- 准确基线:upstream v1.31.1,`a95e91d70ad9e1181253c333a1cfdd75ae4b95a5`。 +- PR 仍在 Stumble/sqlc:base 为新建的 `integration/upstream-v1.31.1`,初始指向上述原始上游提交; + head 为 `refactor/wicked-on-upstream`。不向 sqlc-dev/sqlc 发 PR。 +- 只有两个线性提交,区间中没有 merge commit: + 1. `feat(core): apply wicked compiler and protocol changes to upstream`:编译器、schema/配置约定、 + 协议及事实传递、公共版本/构建、安全依赖、核心测试与测试工具。 + 2. `feat(wicked): add the compatible wpgx code generation backend`:独立生成器、模板、CLI 注册、 + backend/消费者契约测试、样例 CI、GUIDE 和本迁移记录。 +- CLI 注册与依赖 backend 的集成测试位于第二个提交,避免核心提交引用尚未存在的生成器。 +- 旧的 `refactor/upstream-sync` 分支及 #16 保留历史;新 PR 替代旧 review,不重写旧分支或 main。 + 合并新 PR 更新的是 integration 分支;迁移 main/默认分支需之后明确决定。 + +结构验证:在补充本段和更新 README/GUIDE 的分支说明之前,重建的 index tree 与已验证的 +`aa0ff2cdb` 完全相同(tree `1c3c895c10634e2292636bbe68b17370f673bd61`)。 +最终与该版本相比仅说明文档有变化(README/GUIDE/本记录,以及历史 wicked_change_logs 的行尾空格清理), +产品代码、依赖、协议、模板、生成文件和 CI 配置逐字一致。 +因此之前的 8 仓库/226 配置兼容性结果及其 trex 资源限制继续适用;不因重排提交而重复高资源消费者构建。 +核心提交已独立通过 CLI 构建及 compiler/config/cmd 检查;完整树继续执行构建、focused tests、 +生成契约/JSON tests 和 bookstore diff,最终 CI 状态以新 PR 当前 HEAD 为准。 diff --git a/internal/cmd/generate.go b/internal/cmd/generate.go index 05b5445ebb..1d91e3bc8b 100644 --- a/internal/cmd/generate.go +++ b/internal/cmd/generate.go @@ -17,6 +17,7 @@ import ( "github.com/sqlc-dev/sqlc/internal/codegen/golang" genjson "github.com/sqlc-dev/sqlc/internal/codegen/json" + "github.com/sqlc-dev/sqlc/internal/codegen/wicked" "github.com/sqlc-dev/sqlc/internal/compiler" "github.com/sqlc-dev/sqlc/internal/config" "github.com/sqlc-dev/sqlc/internal/config/convert" @@ -381,6 +382,9 @@ func codegen(ctx context.Context, combo config.CombinedSettings, sql OutputPair, case sql.Gen.Go != nil: out = combo.Go.Out handler = ext.HandleFunc(golang.Generate) + if sql.Gen.Go.SqlPackage == "wpgx" { + handler = ext.HandleFunc(wicked.Generate) + } opts, err := json.Marshal(sql.Gen.Go) if err != nil { return "", nil, fmt.Errorf("opts marshal failed: %w", err) diff --git a/internal/cmd/wicked_test.go b/internal/cmd/wicked_test.go new file mode 100644 index 0000000000..b6884c5a59 --- /dev/null +++ b/internal/cmd/wicked_test.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + goast "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + goopts "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/codegen/wicked" + "github.com/sqlc-dev/sqlc/internal/compiler" + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/opts" + "github.com/sqlc-dev/sqlc/internal/plugin" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func TestWickedGenerationContract(t *testing.T) { + dir := writeWickedFixture(t, "-- -- timeout: 500ms\n-- -- cache: 1m\n") + var stderr bytes.Buffer + output, err := Generate(context.Background(), dir, "", &Options{Env: Env{NoRemote: true}, Stderr: &stderr}) + if err != nil { + t.Fatalf("generate: %v: %s", err, &stderr) + } + methods := map[string]bool{} + modelFields := map[string]string{} + for name, source := range output { + file, err := parser.ParseFile(token.NewFileSet(), name, source, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + if fn, ok := decl.(*goast.FuncDecl); ok && fn.Recv != nil { + star := fn.Recv.List[0].Type.(*goast.StarExpr) + receiver := star.X.(*goast.Ident).Name + methods[receiver+"."+fn.Name.Name] = true + } + if gen, ok := decl.(*goast.GenDecl); ok { + for _, spec := range gen.Specs { + ts, ok := spec.(*goast.TypeSpec) + if !ok || ts.Name.Name != "Book" { + continue + } + st := ts.Type.(*goast.StructType) + for _, field := range st.Fields.List { + if sel, ok := field.Type.(*goast.SelectorExpr); ok { + modelFields[field.Names[0].Name] = sel.X.(*goast.Ident).Name + "." + sel.Sel.Name + } + } + } + } + } + } + for _, method := range []string{"Queries.GetBook", "ReadOnlyQueries.GetBook", "Queries.CreateBook", "Queries.Load", "Queries.Dump", "Queries.WithTx", "Queries.UseReplica"} { + if !methods[method] { + t.Errorf("missing method %s", method) + } + } + if methods["ReadOnlyQueries.CreateBook"] { + t.Fatal("INSERT RETURNING was exposed on ReadOnlyQueries") + } + if modelFields["Metadata"] != "json.RawMessage" { + t.Fatalf("metadata type = %q", modelFields["Metadata"]) + } +} + +func TestWickedMissingTimeout(t *testing.T) { + dir := writeWickedFixture(t, "") + var stderr bytes.Buffer + _, err := Generate(context.Background(), dir, "", &Options{Env: Env{NoRemote: true}, Stderr: &stderr}) + if err == nil || !strings.Contains(stderr.String(), "GetBook does not have a timeout") { + t.Fatalf("error=%v stderr=%s", err, &stderr) + } +} + +func TestWickedProtocolFacts(t *testing.T) { + dir := writeWickedFixture(t, "-- -- timeout: 500ms\n") + conf := config.SQL{ + Engine: config.EnginePostgreSQL, Schema: []string{filepath.Join(dir, "schema.sql")}, Queries: []string{filepath.Join(dir, "query.sql")}, + Gen: config.SQLGen{Go: &goopts.Options{SqlPackage: "wpgx", Package: "books", Out: "books"}}, + } + combined := config.Combine(config.Config{Version: "2"}, conf) + c, err := compiler.NewCompiler(conf, combined, opts.Parser{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close(context.Background()) }) + if err := c.ParseCatalog(conf.Schema); err != nil { + t.Fatal(err) + } + if err := c.ParseQueries(conf.Queries, opts.Parser{}); err != nil { + t.Fatal(err) + } + req := codeGenRequest(c.Result(), combined) + facts := req.GetWicked() + if facts == nil || facts.PrimaryRelation.Name != "books" || !facts.QueryIsSelect["GetBook"] { + t.Fatalf("facts=%+v", facts) + } + if v, present := facts.QueryIsSelect["CreateBook"]; !present || v { + t.Fatal("INSERT RETURNING is not a SELECT") + } + blob, err := proto.Marshal(req) + if err != nil { + t.Fatal(err) + } + var roundtrip plugin.GenerateRequest + if err := proto.Unmarshal(blob, &roundtrip); err != nil { + t.Fatal(err) + } + if !proto.Equal(req, &roundtrip) { + t.Fatal("metadata lost in protobuf transport") + } + req.PluginOptions, err = json.Marshal(conf.Gen.Go) + if err != nil { + t.Fatal(err) + } + before := proto.Clone(req) + if _, err := wicked.Generate(context.Background(), req); err != nil { + t.Fatal(err) + } + if !proto.Equal(before, req) { + t.Fatal("generator modified its input request") + } + standard := proto.Clone(req).(*plugin.GenerateRequest) + standard.BackendMetadata = nil + blob, err = (protojson.MarshalOptions{EmitUnpopulated: true}).Marshal(standard) + if err != nil { + t.Fatal(err) + } + var fields map[string]any + if err := json.Unmarshal(blob, &fields); err != nil { + t.Fatal(err) + } + if _, found := fields["wicked"]; found { + t.Fatal("standard request JSON contains wicked metadata") + } +} + +func writeWickedFixture(t *testing.T, options string) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "sqlc.yaml": "version: '2'\nsql:\n- schema: schema.sql\n queries: query.sql\n engine: postgresql\n gen:\n go:\n sql_package: wpgx\n package: books\n out: books\n", + "schema.sql": "CREATE TABLE books (id bigint NOT NULL, metadata jsonb);", + "query.sql": "-- name: GetBook :one\n" + options + "SELECT * FROM books WHERE id = @id;\n\n-- name: CreateBook :one\n-- -- timeout: 500ms\nINSERT INTO books (id,metadata) VALUES (@id,@metadata) RETURNING *;\n", + } + for name, source := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(source), 0600); err != nil { + t.Fatal(err) + } + } + return dir +} diff --git a/internal/codegen/wicked/driver.go b/internal/codegen/wicked/driver.go new file mode 100644 index 0000000000..737739f443 --- /dev/null +++ b/internal/codegen/wicked/driver.go @@ -0,0 +1,14 @@ +package wicked + +import "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + +func parseDriver(sqlPackage string) opts.SQLDriver { + switch sqlPackage { + case opts.SQLPackagePGXV4: + return opts.SQLDriverPGXV4 + case opts.SQLPackagePGXV5: + return opts.SQLDriverPGXV5 + default: + return opts.SQLDriverLibPQ + } +} diff --git a/internal/codegen/wicked/dumploader.go b/internal/codegen/wicked/dumploader.go new file mode 100644 index 0000000000..23baab1e6a --- /dev/null +++ b/internal/codegen/wicked/dumploader.go @@ -0,0 +1,78 @@ +package wicked + +import ( + "fmt" + "strings" +) + +type DumpLoader struct { + MainStruct *Struct +} + +func (d DumpLoader) MainStructName() string { + return d.MainStruct.Name +} + +func (d DumpLoader) Fields(prefix string) string { + if d.MainStruct == nil { + panic("no MainStruct in DumpLoader") + } + var fields []string + for _, f := range d.MainStruct.Fields { + fields = append(fields, prefix+f.Name) + } + return strings.Join(fields, ",") +} + +func (d DumpLoader) FieldDBNames() string { + if d.MainStruct == nil { + panic("no MainStruct in DumpLoader") + } + var fields []string + for _, f := range d.MainStruct.Fields { + fields = append(fields, f.DBName) + } + return strings.Join(fields, ",") +} + +func (d DumpLoader) DumpSortByFields() string { + if d.MainStruct == nil { + panic("no MainStruct in DumpLoader") + } + var fields []string + for _, f := range d.MainStruct.Fields { + switch f.Type { + // TODO(yumin): + // best-effort sorting for now. Once we pass table indexes to codegen + // we can just use index information. + case "int", "int16", "int32", "int64", "float32", "float64", "string", "bool", "time.Time": + fields = append(fields, f.DBName) + case "*int", "*int16", "*int32", "*int64", "*float32", "*float64", "*string", "*bool", "*time.Time": + fields = append(fields, f.DBName) + default: + continue + } + } + return strings.Join(fields, ",") +} + +func (d DumpLoader) ParamList() string { + if d.MainStruct == nil { + panic("no MainStruct in DumpLoader") + } + var vals []string + for i := range d.MainStruct.Fields { + vals = append(vals, fmt.Sprintf("$%d", i+1)) + } + return strings.Join(vals, ",") +} + +func (d DumpLoader) DumpSQL() string { + return fmt.Sprintf(`SELECT %s FROM \"%s\" ORDER BY %s ASC;`, + d.FieldDBNames(), d.MainStruct.Table.Name, d.DumpSortByFields()) +} + +func (d DumpLoader) LoadSQL() string { + return fmt.Sprintf(`INSERT INTO \"%s\" (%s) VALUES (%s);`, + d.MainStruct.Table.Name, d.FieldDBNames(), d.ParamList()) +} diff --git a/internal/codegen/wicked/enum.go b/internal/codegen/wicked/enum.go new file mode 100644 index 0000000000..43014867cb --- /dev/null +++ b/internal/codegen/wicked/enum.go @@ -0,0 +1,64 @@ +package wicked + +import ( + "strings" + "unicode" +) + +type Constant struct { + Name string + Type string + Value string +} + +type Enum struct { + Name string + Comment string + Constants []Constant + NameTags map[string]string + ValidTags map[string]string +} + +func (e Enum) NameTag() string { + return TagsToString(e.NameTags) +} + +func (e Enum) ValidTag() string { + return TagsToString(e.ValidTags) +} + +func enumReplacer(r rune) rune { + if strings.ContainsRune("-/:_", r) { + return '_' + } else if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') { + return r + } else { + return -1 + } +} + +// EnumReplace removes all non ident symbols (all but letters, numbers and +// underscore) and returns valid ident name for provided name. +func EnumReplace(value string) string { + return strings.Map(enumReplacer, value) +} + +// EnumValueName removes all non ident symbols (all but letters, numbers and +// underscore) and converts snake case ident to camel case. +func EnumValueName(value string) string { + parts := strings.Split(EnumReplace(value), "_") + for i, part := range parts { + parts[i] = titleFirst(part) + } + + return strings.Join(parts, "") +} + +func titleFirst(s string) string { + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + + return string(r) +} diff --git a/internal/codegen/wicked/field.go b/internal/codegen/wicked/field.go new file mode 100644 index 0000000000..a7eed0105b --- /dev/null +++ b/internal/codegen/wicked/field.go @@ -0,0 +1,143 @@ +package wicked + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +type Field struct { + Name string // CamelCased name for Go + DBName string // Name as used in the DB + Type string + Tags map[string]string + Comment string + Column *plugin.Column + // EmbedFields contains the embedded fields that require scanning. + EmbedFields []Field +} + +func (gf Field) Tag() string { + return TagsToString(gf.Tags) +} + +func (gf Field) HasSqlcSlice() bool { + return gf.Column.IsSqlcSlice +} + +func TagsToString(tags map[string]string) string { + if len(tags) == 0 { + return "" + } + tagParts := make([]string, 0, len(tags)) + for key, val := range tags { + tagParts = append(tagParts, fmt.Sprintf("%s:%q", key, val)) + } + sort.Strings(tagParts) + return strings.Join(tagParts, " ") +} + +func JSONTagName(name string, options *opts.Options) string { + style := options.JsonTagsCaseStyle + idUppercase := options.JsonTagsIdUppercase + if style == "" || style == "none" { + return name + } else { + return SetJSONCaseStyle(name, style, idUppercase) + } +} + +func SetCaseStyle(name string, style string) string { + switch style { + case "camel": + return toCamelCase(name) + case "pascal": + return toPascalCase(name) + case "snake": + return toSnakeCase(name) + default: + panic(fmt.Sprintf("unsupported JSON tags case style: '%s'", style)) + } +} + +func SetJSONCaseStyle(name string, style string, idUppercase bool) string { + switch style { + case "camel": + return toJsonCamelCase(name, idUppercase) + case "pascal": + return toPascalCase(name) + case "snake": + return toSnakeCase(name) + default: + panic(fmt.Sprintf("unsupported JSON tags case style: '%s'", style)) + } +} + +var camelPattern = regexp.MustCompile("[^A-Z][A-Z]+") + +func toSnakeCase(s string) string { + if !strings.ContainsRune(s, '_') { + s = camelPattern.ReplaceAllStringFunc(s, func(x string) string { + return x[:1] + "_" + x[1:] + }) + } + return strings.ToLower(s) +} + +func toCamelCase(s string) string { + return toCamelInitCase(s, false) +} + +func toPascalCase(s string) string { + return toCamelInitCase(s, true) +} + +func toCamelInitCase(name string, initUpper bool) string { + out := "" + for i, p := range strings.Split(name, "_") { + if !initUpper && i == 0 { + out += p + continue + } + if p == "id" { + out += "ID" + } else { + out += strings.Title(p) + } + } + return out +} + +func toJsonCamelCase(name string, idUppercase bool) string { + out := "" + idStr := "Id" + + if idUppercase { + idStr = "ID" + } + + for i, p := range strings.Split(name, "_") { + if i == 0 { + out += p + continue + } + if p == "id" { + out += idStr + } else { + out += strings.Title(p) + } + } + return out +} + +func toLowerCase(str string) string { + if str == "" { + return "" + } + + return strings.ToLower(str[:1]) + str[1:] +} diff --git a/internal/codegen/wicked/gen.go b/internal/codegen/wicked/gen.go new file mode 100644 index 0000000000..8890c0cb47 --- /dev/null +++ b/internal/codegen/wicked/gen.go @@ -0,0 +1,451 @@ +package wicked + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "go/format" + "strings" + "text/template" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/codegen/sdk" + "github.com/sqlc-dev/sqlc/internal/metadata" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +type tmplCtx struct { + Q string + Package string + SQLDriver opts.SQLDriver + Enums []Enum + Structs []Struct + GoQueries []Query + SqlcVersion string + DumpLoader *DumpLoader + RawSchemaSQL string + + // The query file currently being rendered by this generation invocation. + SourceName string + + EmitJSONTags bool + JsonTagsIDUppercase bool + EmitDBTags bool + EmitPreparedQueries bool + EmitInterface bool + EmitEmptySlices bool + EmitMethodsWithDBArgument bool + EmitEnumValidMethod bool + EmitAllEnumValues bool + UsesCopyFrom bool + UsesBatch bool + OmitSqlcVersion bool + BuildTags string + WrapErrors bool +} + +func (t *tmplCtx) OutputQuery(sourceName string) bool { + return t.SourceName == sourceName +} + +func (t *tmplCtx) codegenDbarg() string { + if t.EmitMethodsWithDBArgument { + return "db DBTX, " + } + return "" +} + +// Called as a global method since subtemplate queryCodeStdExec does not have +// access to the toplevel tmplCtx +func (t *tmplCtx) codegenEmitPreparedQueries() bool { + return t.EmitPreparedQueries +} + +func (t *tmplCtx) codegenQueryMethod(q Query) string { + db := "q.db" + if t.EmitMethodsWithDBArgument { + db = "db" + } + + switch q.Cmd { + case ":one": + if t.EmitPreparedQueries { + return "q.queryRow" + } + return db + ".QueryRowContext" + + case ":many": + if t.EmitPreparedQueries { + return "q.query" + } + return db + ".QueryContext" + + default: + if t.EmitPreparedQueries { + return "q.exec" + } + return db + ".ExecContext" + } +} + +func (t *tmplCtx) codegenQueryRetval(q Query) (string, error) { + switch q.Cmd { + case ":one": + return "row :=", nil + case ":many": + return "rows, err :=", nil + case ":exec": + return "_, err :=", nil + case ":execrows", ":execlastid": + return "result, err :=", nil + case ":execresult": + if t.WrapErrors { + return "result, err :=", nil + } + return "return", nil + default: + return "", fmt.Errorf("unhandled q.Cmd case %q", q.Cmd) + } +} + +func Generate(ctx context.Context, req *plugin.GenerateRequest) (*plugin.GenerateResponse, error) { + if req == nil || req.Settings == nil || req.Catalog == nil { + return nil, errors.New("wicked: missing settings or catalog") + } + options, err := parseOptions(req) + if err != nil { + return nil, err + } + + if err := opts.ValidateOpts(options); err != nil { + return nil, err + } + + if req.GetWicked() == nil || req.GetWicked().PrimaryRelation == nil { + return nil, errors.New("wicked: missing primary schema metadata") + } + if strings.TrimSpace(req.GetWicked().PrimarySchemaSql) == "" { + return nil, errors.New("wicked: missing primary schema source") + } + for _, query := range req.Queries { + switch query.Cmd { + case metadata.CmdOne, metadata.CmdMany, metadata.CmdExec, metadata.CmdExecRows, metadata.CmdExecResult, metadata.CmdCopyFrom: + default: + return nil, fmt.Errorf("wicked: %s: unsupported command %s", query.Name, query.Cmd) + } + } + if req.Settings.Engine != "postgresql" { + return nil, errors.New("wicked: only PostgreSQL is supported") + } + enums := buildEnums(req, options) + structs := buildStructs(req, options) + queries, err := buildQueries(req, options, structs) + if err != nil { + return nil, err + } + + if options.OmitUnusedStructs { + enums, structs = filterUnusedStructs(enums, structs, queries) + } + + if err := buildQueryInvalidates(queries); err != nil { + return nil, err + } + var missing []error + for _, q := range queries { + if q.Option.Timeout == 0 { + missing = append(missing, fmt.Errorf("%s/%s does not have a timeout option", options.Package, q.MethodName)) + } + } + if len(missing) != 0 { + return nil, errors.Join(missing...) + } + if err := validate(options, enums, structs, queries); err != nil { + return nil, err + } + + return generate(req, options, enums, structs, queries) +} + +func validate(options *opts.Options, enums []Enum, structs []Struct, queries []Query) error { + enumNames := make(map[string]struct{}) + for _, enum := range enums { + enumNames[enum.Name] = struct{}{} + enumNames["Null"+enum.Name] = struct{}{} + } + structNames := make(map[string]struct{}) + for _, struckt := range structs { + if _, ok := enumNames[struckt.Name]; ok { + return fmt.Errorf("struct name conflicts with enum name: %s", struckt.Name) + } + structNames[struckt.Name] = struct{}{} + } + if !options.EmitExportedQueries { + return nil + } + for _, query := range queries { + if _, ok := enumNames[query.ConstantName]; ok { + return fmt.Errorf("query constant name conflicts with enum name: %s", query.ConstantName) + } + if _, ok := structNames[query.ConstantName]; ok { + return fmt.Errorf("query constant name conflicts with struct name: %s", query.ConstantName) + } + } + return nil +} + +func generate(req *plugin.GenerateRequest, options *opts.Options, enums []Enum, structs []Struct, queries []Query) (*plugin.GenerateResponse, error) { + i := &importer{ + Options: options, + Queries: queries, + Enums: enums, + Structs: structs, + } + + dumploader, err := buildDumpLoader(structs) + if err != nil { + return nil, err + } + tctx := tmplCtx{ + DumpLoader: dumploader, + RawSchemaSQL: req.GetWicked().PrimarySchemaSql, + EmitInterface: false, + EmitJSONTags: true, + JsonTagsIDUppercase: false, + EmitDBTags: false, + EmitPreparedQueries: false, + EmitEmptySlices: false, + EmitMethodsWithDBArgument: false, + EmitEnumValidMethod: true, + EmitAllEnumValues: true, + UsesCopyFrom: usesCopyFrom(queries), + UsesBatch: usesBatch(queries), + SQLDriver: parseDriver(options.SqlPackage), + Q: "`", + Package: options.Package, + Enums: enums, + Structs: structs, + SqlcVersion: req.SqlcVersion, + BuildTags: options.BuildTags, + OmitSqlcVersion: options.OmitSqlcVersion, + WrapErrors: options.WrapErrors, + } + + if tctx.UsesCopyFrom && !tctx.SQLDriver.IsPGX() && options.SqlDriver != opts.SQLDriverGoSQLDriverMySQL { + return nil, errors.New(":copyfrom is only supported by pgx and github.com/go-sql-driver/mysql") + } + + if tctx.UsesCopyFrom && options.SqlDriver == opts.SQLDriverGoSQLDriverMySQL { + if err := checkNoTimesForMySQLCopyFrom(queries); err != nil { + return nil, err + } + tctx.SQLDriver = opts.SQLDriverGoSQLDriverMySQL + } + + if tctx.UsesBatch && !tctx.SQLDriver.IsPGX() { + return nil, errors.New(":batch* commands are only supported by pgx") + } + + funcMap := template.FuncMap{ + "lowerTitle": sdk.LowerTitle, + "comment": sdk.DoubleSlashComment, + "escape": sdk.EscapeBacktick, + "imports": i.Imports, + "hasImports": i.HasImports, + "hasPrefix": strings.HasPrefix, + + // These methods are Go specific, they do not belong in the codegen package + // (as that is language independent) + "dbarg": tctx.codegenDbarg, + "emitPreparedQueries": tctx.codegenEmitPreparedQueries, + "queryMethod": tctx.codegenQueryMethod, + "queryRetval": tctx.codegenQueryRetval, + } + + tmpl := template.Must( + template.New("table"). + Funcs(funcMap). + ParseFS( + templates, + "templates/*.tmpl", + "templates/*/*.tmpl", + ), + ) + + output := map[string]string{} + + execute := func(name, templateName string) error { + imports := i.Imports(name) + replacedQueries := replaceConflictedArg(imports, queries) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + tctx.SourceName = name + tctx.GoQueries = replacedQueries + err := tmpl.ExecuteTemplate(w, templateName, &tctx) + w.Flush() + if err != nil { + return err + } + code, err := format.Source(b.Bytes()) + if err != nil { + return fmt.Errorf("formatting generated %s: %w", name, err) + } + + if templateName == "queryFile" && options.OutputFilesSuffix != "" { + name += options.OutputFilesSuffix + } + + if !strings.HasSuffix(name, ".go") { + name += ".go" + } + output[name] = string(code) + return nil + } + + dbFileName := "db.go" + if options.OutputDbFileName != "" { + dbFileName = options.OutputDbFileName + } + modelsFileName := "models.go" + if options.OutputModelsFileName != "" { + modelsFileName = options.OutputModelsFileName + } + querierFileName := "querier.go" + if options.OutputQuerierFileName != "" { + querierFileName = options.OutputQuerierFileName + } + copyfromFileName := "copyfrom.go" + if options.OutputCopyfromFileName != "" { + copyfromFileName = options.OutputCopyfromFileName + } + + batchFileName := "batch.go" + if options.OutputBatchFileName != "" { + batchFileName = options.OutputBatchFileName + } + + if err := execute(dbFileName, "dbFile"); err != nil { + return nil, err + } + if err := execute(modelsFileName, "modelsFile"); err != nil { + return nil, err + } + if options.EmitInterface { + if err := execute(querierFileName, "interfaceFile"); err != nil { + return nil, err + } + } + if tctx.UsesCopyFrom { + if err := execute(copyfromFileName, "copyfromFile"); err != nil { + return nil, err + } + } + if tctx.UsesBatch { + if err := execute(batchFileName, "batchFile"); err != nil { + return nil, err + } + } + + files := map[string]struct{}{} + for _, gq := range queries { + files[gq.SourceName] = struct{}{} + } + + for source := range files { + if err := execute(source, "queryFile"); err != nil { + return nil, err + } + } + resp := plugin.GenerateResponse{} + + for filename, code := range output { + resp.Files = append(resp.Files, &plugin.File{ + Name: filename, + Contents: []byte(code), + }) + } + + return &resp, nil +} + +func usesCopyFrom(queries []Query) bool { + for _, q := range queries { + if q.Cmd == metadata.CmdCopyFrom { + return true + } + } + return false +} + +func usesBatch(queries []Query) bool { + for _, q := range queries { + for _, cmd := range []string{metadata.CmdBatchExec, metadata.CmdBatchMany, metadata.CmdBatchOne} { + if q.Cmd == cmd { + return true + } + } + } + return false +} + +func checkNoTimesForMySQLCopyFrom(queries []Query) error { + for _, q := range queries { + if q.Cmd != metadata.CmdCopyFrom { + continue + } + for _, f := range q.Arg.CopyFromMySQLFields() { + if f.Type == "time.Time" { + return fmt.Errorf("values with a timezone are not yet supported") + } + } + } + return nil +} + +func filterUnusedStructs(enums []Enum, structs []Struct, queries []Query) ([]Enum, []Struct) { + keepTypes := make(map[string]struct{}) + + for _, query := range queries { + if !query.Arg.isEmpty() { + keepTypes[query.Arg.Type()] = struct{}{} + if query.Arg.IsStruct() { + for _, field := range query.Arg.Struct.Fields { + keepTypes[field.Type] = struct{}{} + } + } + } + if query.hasRetType() { + keepTypes[query.Ret.Type()] = struct{}{} + if query.Ret.IsStruct() { + for _, field := range query.Ret.Struct.Fields { + keepTypes[strings.TrimPrefix(field.Type, "[]")] = struct{}{} + for _, embedField := range field.EmbedFields { + keepTypes[embedField.Type] = struct{}{} + } + } + } + } + } + + keepEnums := make([]Enum, 0, len(enums)) + for _, enum := range enums { + _, keep := keepTypes[enum.Name] + _, keepNull := keepTypes["Null"+enum.Name] + _, keepPointer := keepTypes["*"+enum.Name] + if keep || keepNull || keepPointer { + keepEnums = append(keepEnums, enum) + } + } + + keepStructs := make([]Struct, 0, len(structs)) + for _, st := range structs { + if _, ok := keepTypes[st.Name]; ok { + keepStructs = append(keepStructs, st) + } + } + + return keepEnums, keepStructs +} diff --git a/internal/codegen/wicked/go_type.go b/internal/codegen/wicked/go_type.go new file mode 100644 index 0000000000..f17551c0a1 --- /dev/null +++ b/internal/codegen/wicked/go_type.go @@ -0,0 +1,94 @@ +package wicked + +import ( + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/codegen/sdk" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +func addExtraGoStructTags(tags map[string]string, req *plugin.GenerateRequest, options *opts.Options, col *plugin.Column) { + for _, override := range options.Overrides { + oride := override.ShimOverride + if oride.GoType.StructTags == nil { + continue + } + // Preserve column-scoped tags. Applying previously ignored db_type + // tags here would silently change JSON and shared cache payloads. + if !override.Matches(col.Table, req.Catalog.DefaultSchema) { + // Different table. + continue + } + cname := col.Name + if col.OriginalName != "" { + cname = col.OriginalName + } + if !sdk.MatchString(oride.ColumnName, cname) { + // Different column. + continue + } + // Add the extra tags. + for k, v := range oride.GoType.StructTags { + tags[k] = v + } + } +} + +func goType(req *plugin.GenerateRequest, options *opts.Options, col *plugin.Column) string { + // Check if the column's type has been overridden + for _, override := range options.Overrides { + oride := override.ShimOverride + + if oride.GoType.TypeName == "" { + continue + } + cname := col.Name + if col.OriginalName != "" { + cname = col.OriginalName + } + sameTable := override.Matches(col.Table, req.Catalog.DefaultSchema) + if oride.Column != "" && sdk.MatchString(oride.ColumnName, cname) && sameTable { + if col.IsSqlcSlice { + return "[]" + oride.GoType.TypeName + } + return oride.GoType.TypeName + } + } + typ := goInnerType(req, options, col) + if col.IsSqlcSlice { + return "[]" + typ + } + if col.IsArray { + return strings.Repeat("[]", int(col.ArrayDims)) + typ + } + return typ +} + +func goInnerType(req *plugin.GenerateRequest, options *opts.Options, col *plugin.Column) string { + // Preserve the legacy combined override order (global entries first). + for _, override := range options.Overrides { + oride := override.ShimOverride + if oride.GoType.TypeName == "" { + continue + } + if overrideMatchesColumn(override, col) { + return oride.GoType.TypeName + } + } + + return postgresType(req, options, col) +} + +// PostgreSQL catalog updates may qualify built-in types which were previously +// unqualified (notably json/jsonb). Preserve configured wicked type overrides +// across that representation change, without conflating user-defined schemas. +func overrideMatchesColumn(override opts.Override, col *plugin.Column) bool { + if override.DBType == "" { + return false + } + want := strings.TrimPrefix(override.DBType, "pg_catalog.") + got := strings.TrimPrefix(sdk.DataType(col.Type), "pg_catalog.") + notNull := col.NotNull || col.IsArray + return want == got && override.Nullable != notNull && override.Unsigned == col.Unsigned +} diff --git a/internal/codegen/wicked/imports.go b/internal/codegen/wicked/imports.go new file mode 100644 index 0000000000..1bc34b36f5 --- /dev/null +++ b/internal/codegen/wicked/imports.go @@ -0,0 +1,514 @@ +package wicked + +import ( + "fmt" + "sort" + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/metadata" +) + +type fileImports struct { + Std []ImportSpec + Dep []ImportSpec +} + +type ImportSpec struct { + ID string + Path string +} + +func (s ImportSpec) String() string { + if s.ID != "" { + return fmt.Sprintf("%s %q", s.ID, s.Path) + } else { + return fmt.Sprintf("%q", s.Path) + } +} + +func mergeImports(imps ...fileImports) [][]ImportSpec { + if len(imps) == 1 { + return [][]ImportSpec{ + imps[0].Std, + imps[0].Dep, + } + } + + var stds, pkgs []ImportSpec + seenStd := map[string]struct{}{} + seenPkg := map[string]struct{}{} + for i := range imps { + for _, spec := range imps[i].Std { + if _, ok := seenStd[spec.Path]; ok { + continue + } + stds = append(stds, spec) + seenStd[spec.Path] = struct{}{} + } + for _, spec := range imps[i].Dep { + if _, ok := seenPkg[spec.Path]; ok { + continue + } + pkgs = append(pkgs, spec) + seenPkg[spec.Path] = struct{}{} + } + } + return [][]ImportSpec{stds, pkgs} +} + +type importer struct { + Options *opts.Options + Queries []Query + Enums []Enum + Structs []Struct +} + +func (i *importer) usesType(typ string) bool { + for _, strct := range i.Structs { + for _, f := range strct.Fields { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, typ) { + return true + } + } + } + return false +} + +func (i *importer) HasImports(filename string) bool { + imports := i.Imports(filename) + return len(imports[0]) != 0 || len(imports[1]) != 0 +} + +func (i *importer) Imports(filename string) [][]ImportSpec { + dbFileName := "db.go" + if i.Options.OutputDbFileName != "" { + dbFileName = i.Options.OutputDbFileName + } + modelsFileName := "models.go" + if i.Options.OutputModelsFileName != "" { + modelsFileName = i.Options.OutputModelsFileName + } + querierFileName := "querier.go" + if i.Options.OutputQuerierFileName != "" { + querierFileName = i.Options.OutputQuerierFileName + } + copyfromFileName := "copyfrom.go" + if i.Options.OutputCopyfromFileName != "" { + copyfromFileName = i.Options.OutputCopyfromFileName + } + batchFileName := "batch.go" + if i.Options.OutputBatchFileName != "" { + batchFileName = i.Options.OutputBatchFileName + } + + switch filename { + case dbFileName: + return mergeImports(i.dbImports()) + case modelsFileName: + return mergeImports(i.modelImports()) + case querierFileName: + return mergeImports(i.interfaceImports()) + case copyfromFileName: + return mergeImports(i.copyfromImports()) + case batchFileName: + return mergeImports(i.batchImports()) + default: + return mergeImports(i.queryImports(filename)) + } +} + +func (i *importer) dbImports() fileImports { + return fileImports{Dep: []ImportSpec{{Path: "github.com/stumble/dcache"}, {Path: "github.com/stumble/wpgx"}}} +} + +var stdlibTypes = map[string]string{ + "json.RawMessage": "encoding/json", + "time.Time": "time", + "net.IP": "net", + "net.HardwareAddr": "net", + "netip.Addr": "net/netip", + "netip.Prefix": "net/netip", +} + +var pqtypeTypes = map[string]struct{}{ + "pqtype.CIDR": {}, + "pqtype.Inet": {}, + "pqtype.Macaddr": {}, + "pqtype.NullRawMessage": {}, +} + +func buildImports(options *opts.Options, queries []Query, uses func(string) bool) (map[string]struct{}, map[ImportSpec]struct{}) { + pkg := make(map[ImportSpec]struct{}) + std := make(map[string]struct{}) + + if uses("sql.Null") { + std["database/sql"] = struct{}{} + } + + sqlpkg := parseDriver(options.SqlPackage) + for _, q := range queries { + if q.Cmd == metadata.CmdExecResult { + switch sqlpkg { + case opts.SQLDriverPGXV4: + pkg[ImportSpec{Path: "github.com/jackc/pgconn"}] = struct{}{} + case opts.SQLDriverPGXV5: + pkg[ImportSpec{Path: "github.com/jackc/pgx/v5/pgconn"}] = struct{}{} + default: + std["database/sql"] = struct{}{} + } + } + } + + for typeName, pkg := range stdlibTypes { + if uses(typeName) { + std[pkg] = struct{}{} + } + } + + if uses("pgtype.") { + if sqlpkg == opts.SQLDriverPGXV5 { + pkg[ImportSpec{Path: "github.com/jackc/pgx/v5/pgtype"}] = struct{}{} + } else { + pkg[ImportSpec{Path: "github.com/jackc/pgtype"}] = struct{}{} + } + } + + for typeName := range pqtypeTypes { + if uses(typeName) { + pkg[ImportSpec{Path: "github.com/sqlc-dev/pqtype"}] = struct{}{} + break + } + } + + overrideTypes := map[string]string{} + for _, override := range options.Overrides { + o := override.ShimOverride + if o.GoType.BasicType || o.GoType.TypeName == "" { + continue + } + overrideTypes[o.GoType.TypeName] = o.GoType.ImportPath + } + + _, overrideNullTime := overrideTypes["pq.NullTime"] + if uses("pq.NullTime") && !overrideNullTime { + pkg[ImportSpec{Path: "github.com/lib/pq"}] = struct{}{} + } + _, overrideUUID := overrideTypes["uuid.UUID"] + if uses("uuid.UUID") && !overrideUUID { + pkg[ImportSpec{Path: "github.com/google/uuid"}] = struct{}{} + } + _, overrideNullUUID := overrideTypes["uuid.NullUUID"] + if uses("uuid.NullUUID") && !overrideNullUUID { + pkg[ImportSpec{Path: "github.com/google/uuid"}] = struct{}{} + } + _, overrideVector := overrideTypes["pgvector.Vector"] + if uses("pgvector.Vector") && !overrideVector { + pkg[ImportSpec{Path: "github.com/pgvector/pgvector-go"}] = struct{}{} + } + + // Custom imports + for _, override := range options.Overrides { + o := override.ShimOverride + + if o.GoType.BasicType || o.GoType.TypeName == "" { + continue + } + _, alreadyImported := std[o.GoType.ImportPath] + hasPackageAlias := o.GoType.Package != "" + if (!alreadyImported || hasPackageAlias) && uses(o.GoType.TypeName) { + pkg[ImportSpec{Path: o.GoType.ImportPath, ID: o.GoType.Package}] = struct{}{} + } + } + + return std, pkg +} + +func (i *importer) interfaceImports() fileImports { + std, pkg := buildImports(i.Options, i.Queries, func(name string) bool { + for _, q := range i.Queries { + if q.hasRetType() { + if usesBatch([]Query{q}) { + continue + } + if hasPrefixIgnoringSliceAndPointerPrefix(q.Ret.Type(), name) { + return true + } + } + for _, f := range q.Arg.Pairs() { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + return false + }) + + std["context"] = struct{}{} + + return sortedImports(std, pkg) +} + +func (i *importer) modelImports() fileImports { + std, pkg := buildImports(i.Options, nil, i.usesType) + + if len(i.Enums) > 0 { + std["fmt"] = struct{}{} + std["database/sql/driver"] = struct{}{} + } + + return sortedImports(std, pkg) +} + +func sortedImports(std map[string]struct{}, pkg map[ImportSpec]struct{}) fileImports { + pkgs := make([]ImportSpec, 0, len(pkg)) + for spec := range pkg { + pkgs = append(pkgs, spec) + } + stds := make([]ImportSpec, 0, len(std)) + for path := range std { + stds = append(stds, ImportSpec{Path: path}) + } + sort.Slice(stds, func(i, j int) bool { return stds[i].Path < stds[j].Path }) + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Path < pkgs[j].Path }) + return fileImports{stds, pkgs} +} + +func (i *importer) queryImports(filename string) fileImports { + var gq []Query + anyNonCopyFrom := false + for _, query := range i.Queries { + if usesBatch([]Query{query}) { + continue + } + if query.SourceName == filename { + gq = append(gq, query) + if query.Cmd != metadata.CmdCopyFrom { + anyNonCopyFrom = true + } + } + } + + std, pkg := buildImports(i.Options, gq, func(name string) bool { + for _, q := range gq { + if q.hasRetType() { + if q.Ret.EmitStruct() { + for _, f := range q.Ret.Struct.Fields { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + if hasPrefixIgnoringSliceAndPointerPrefix(q.Ret.Type(), name) { + return true + } + } + // Check the fields of the argument struct if it's emitted + if q.Arg.EmitStruct() { + for _, f := range q.Arg.Struct.Fields { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + // Check the argument pairs inside the method definition + for _, f := range q.Arg.Pairs() { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + return false + }) + + sliceScan := func() bool { + for _, q := range gq { + if q.hasRetType() { + if q.Ret.IsStruct() { + for _, f := range q.Ret.Struct.Fields { + if strings.HasPrefix(f.Type, "[]") && f.Type != "[]byte" { + return true + } + for _, embed := range f.EmbedFields { + if strings.HasPrefix(embed.Type, "[]") && embed.Type != "[]byte" { + return true + } + } + } + } else { + if strings.HasPrefix(q.Ret.Type(), "[]") && q.Ret.Type() != "[]byte" { + return true + } + } + } + if !q.Arg.isEmpty() { + if q.Arg.IsStruct() { + for _, f := range q.Arg.Struct.Fields { + if strings.HasPrefix(f.Type, "[]") && f.Type != "[]byte" && !f.HasSqlcSlice() { + return true + } + } + } else { + if strings.HasPrefix(q.Arg.Type(), "[]") && q.Arg.Type() != "[]byte" && !q.Arg.HasSqlcSlices() { + return true + } + } + } + } + return false + } + + // Search for sqlc.slice() calls + sqlcSliceScan := func() bool { + for _, q := range gq { + if q.Arg.HasSqlcSlices() { + return true + } + } + return false + } + + if anyNonCopyFrom { + std["context"] = struct{}{} + } + + sqlpkg := parseDriver(i.Options.SqlPackage) + if sqlcSliceScan() && !sqlpkg.IsPGX() { + std["strings"] = struct{}{} + } + if sliceScan() && !sqlpkg.IsPGX() { + pkg[ImportSpec{Path: "github.com/lib/pq"}] = struct{}{} + } + + if i.Options.WrapErrors { + std["fmt"] = struct{}{} + } + + for _, path := range []string{"context", "time", "fmt", "encoding/json", "crypto/sha256", "encoding/hex", "sync"} { + std[path] = struct{}{} + } + for _, q := range gq { + if q.Cmd == metadata.CmdOne { + pkg[ImportSpec{Path: "github.com/jackc/pgx/v5"}] = struct{}{} + } + } + pkg[ImportSpec{Path: "github.com/rs/zerolog/log"}] = struct{}{} + return sortedImports(std, pkg) +} + +func (i *importer) copyfromImports() fileImports { + copyFromQueries := make([]Query, 0, len(i.Queries)) + for _, q := range i.Queries { + if q.Cmd == metadata.CmdCopyFrom { + copyFromQueries = append(copyFromQueries, q) + } + } + std, pkg := buildImports(i.Options, copyFromQueries, func(name string) bool { + for _, q := range copyFromQueries { + if q.hasRetType() { + if strings.HasPrefix(q.Ret.Type(), name) { + return true + } + } + if !q.Arg.isEmpty() { + if strings.HasPrefix(q.Arg.Type(), name) { + return true + } + } + } + return false + }) + + std["context"] = struct{}{} + std["time"] = struct{}{} + if i.Options.SqlDriver == opts.SQLDriverGoSQLDriverMySQL { + std["io"] = struct{}{} + std["fmt"] = struct{}{} + std["sync/atomic"] = struct{}{} + pkg[ImportSpec{Path: "github.com/go-sql-driver/mysql"}] = struct{}{} + pkg[ImportSpec{Path: "github.com/hexon/mysqltsv"}] = struct{}{} + } + + return sortedImports(std, pkg) +} + +func (i *importer) batchImports() fileImports { + batchQueries := make([]Query, 0, len(i.Queries)) + for _, q := range i.Queries { + if usesBatch([]Query{q}) { + batchQueries = append(batchQueries, q) + } + } + std, pkg := buildImports(i.Options, batchQueries, func(name string) bool { + for _, q := range batchQueries { + if q.hasRetType() { + if q.Ret.EmitStruct() { + for _, f := range q.Ret.Struct.Fields { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + if hasPrefixIgnoringSliceAndPointerPrefix(q.Ret.Type(), name) { + return true + } + } + if q.Arg.EmitStruct() { + for _, f := range q.Arg.Struct.Fields { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + for _, f := range q.Arg.Pairs() { + if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) { + return true + } + } + } + return false + }) + + std["context"] = struct{}{} + std["errors"] = struct{}{} + sqlpkg := parseDriver(i.Options.SqlPackage) + switch sqlpkg { + case opts.SQLDriverPGXV4: + pkg[ImportSpec{Path: "github.com/jackc/pgx/v4"}] = struct{}{} + case opts.SQLDriverPGXV5: + pkg[ImportSpec{Path: "github.com/jackc/pgx/v5"}] = struct{}{} + } + + return sortedImports(std, pkg) +} + +func trimSliceAndPointerPrefix(v string) string { + v = strings.TrimPrefix(v, "[]") + v = strings.TrimPrefix(v, "*") + return v +} + +func hasPrefixIgnoringSliceAndPointerPrefix(s, prefix string) bool { + trimmedS := trimSliceAndPointerPrefix(s) + trimmedPrefix := trimSliceAndPointerPrefix(prefix) + return strings.HasPrefix(trimmedS, trimmedPrefix) +} + +func replaceConflictedArg(imports [][]ImportSpec, queries []Query) []Query { + m := make(map[string]struct{}) + for _, is := range imports { + for _, i := range is { + paths := strings.Split(i.Path, "/") + m[paths[len(paths)-1]] = struct{}{} + } + } + + replacedQueries := make([]Query, 0, len(queries)) + for _, query := range queries { + if _, exist := m[query.Arg.Name]; exist { + query.Arg.Name = toCamelCase(fmt.Sprintf("arg_%s", query.Arg.Name)) + } + replacedQueries = append(replacedQueries, query) + } + return replacedQueries +} diff --git a/internal/codegen/wicked/option.go b/internal/codegen/wicked/option.go new file mode 100644 index 0000000000..f4bed97ce4 --- /dev/null +++ b/internal/codegen/wicked/option.go @@ -0,0 +1,75 @@ +package wicked + +import ( + "fmt" + "strings" + "time" +) + +const ( + WPgxOptionKeyCache = "cache" + WPgxOptionKeyInvalidate = "invalidate" + WpgxOptionKeyCountIntent = "count_intent" + WpgxOptionKeyTimeout = "timeout" + WpgxOptionKeyAllowReplica = "allow_replica" +) + +type WPgxOption struct { + Cache time.Duration + Invalidates []string + CountIntent bool + Timeout time.Duration + AllowReplica bool +} + +func parseOption(options map[string]string, queryNames map[string]bool) (rv WPgxOption, err error) { + for k, v := range options { + switch k { + case WPgxOptionKeyCache: + rv.Cache, err = time.ParseDuration(v) + if err != nil { + return + } + if rv.Cache < 1*time.Millisecond { + return rv, fmt.Errorf("cache duration too short: %s", v) + } + case WPgxOptionKeyInvalidate: + trimed := strings.Trim(v, " []") + fnNames := strings.Split(trimed, ",") + for _, rawFnName := range fnNames { + queryName := strings.TrimSpace(rawFnName) + if !queryNames[queryName] { + return rv, fmt.Errorf("Unknown to invalidate query: %s", queryName) + } + rv.Invalidates = append(rv.Invalidates, queryName) + } + case WpgxOptionKeyCountIntent: + if v == "true" { + rv.CountIntent = true + } else if v == "false" { + rv.CountIntent = false + } else { + return rv, fmt.Errorf("Unknown count_intent value: %s", v) + } + case WpgxOptionKeyTimeout: + rv.Timeout, err = time.ParseDuration(v) + if err != nil { + return + } + if rv.Timeout < 1*time.Millisecond { + return rv, fmt.Errorf("timeout duration too short: %s", v) + } + case WpgxOptionKeyAllowReplica: + if v == "true" { + rv.AllowReplica = true + } else if v == "false" { + rv.AllowReplica = false + } else { + return rv, fmt.Errorf("Unknown allow_replica value: %s", v) + } + default: + return rv, fmt.Errorf("Unknown option: %s", k) + } + } + return +} diff --git a/internal/codegen/wicked/options.go b/internal/codegen/wicked/options.go new file mode 100644 index 0000000000..9be401a771 --- /dev/null +++ b/internal/codegen/wicked/options.go @@ -0,0 +1,82 @@ +package wicked + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +var wickedReservedNames = map[string]bool{"load": true, "dump": true, "check": true} + +// Reuse upstream's override and rename parsing without teaching the standard Go +// generator about wpgx. Only this private copy is normalized to its pgx driver. +func parseOptions(req *plugin.GenerateRequest) (*opts.Options, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(req.PluginOptions, &raw); err != nil { + return nil, fmt.Errorf("wicked options: %w", err) + } + if raw == nil { + return nil, fmt.Errorf("wicked: missing Go options") + } + raw["sql_package"] = json.RawMessage(`"pgx/v5"`) + blob, err := json.Marshal(raw) + if err != nil { + return nil, err + } + copy := &plugin.GenerateRequest{Settings: req.Settings, Catalog: req.Catalog, PluginOptions: blob, GlobalOptions: req.GlobalOptions} + options, err := opts.Parse(copy) + if err != nil { + return nil, err + } + // Legacy wicked configurations use local rename entries over global ones. + // Upstream opts.Parse currently applies them in the opposite order. + if rename, present := raw["rename"]; present { + var local map[string]string + if err := json.Unmarshal(rename, &local); err != nil { + return nil, err + } + if options.Rename == nil { + options.Rename = make(map[string]string) + } + for key, value := range local { + options.Rename[key] = value + } + } + return options, nil +} + +func parseQueryOptions(comments []string, isSelect bool, names map[string]bool) (WPgxOption, error) { + values := make(map[string]string) + for _, comment := range comments { + body := strings.TrimSpace(comment) + if !strings.HasPrefix(body, "--") { + continue + } + key, value, ok := strings.Cut(body[2:], ":") + if !ok { + return WPgxOption{}, fmt.Errorf("invalid query option: %s", comment) + } + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + for _, key := range []string{WpgxOptionKeyCountIntent, WpgxOptionKeyAllowReplica} { + value, present := values[key] + if !present { + if isSelect { + values[key] = "true" + } else { + values[key] = "false" + } + } else if value != "true" && value != "false" { + return WPgxOption{}, fmt.Errorf("invalid %s value: %s", key, value) + } else if !isSelect && value == "true" { + return WPgxOption{}, fmt.Errorf("%s requires a SELECT statement", key) + } + } + if _, present := values[WPgxOptionKeyInvalidate]; isSelect && present { + return WPgxOption{}, fmt.Errorf("invalidate cannot be used on a SELECT statement") + } + return parseOption(values, names) +} diff --git a/internal/codegen/wicked/options_test.go b/internal/codegen/wicked/options_test.go new file mode 100644 index 0000000000..2b1a4c4cd3 --- /dev/null +++ b/internal/codegen/wicked/options_test.go @@ -0,0 +1,115 @@ +package wicked + +import ( + "context" + "testing" + "time" + + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +func TestLegacyOptionPrecedence(t *testing.T) { + req := &plugin.GenerateRequest{ + Catalog: &plugin.Catalog{DefaultSchema: "custom"}, + PluginOptions: []byte(`{"package":"books","sql_package":"wpgx","rename":{"id":"LocalID"},"overrides":[{"column":"books.id","go_type":"int32"}]}`), + GlobalOptions: []byte(`{"rename":{"id":"GlobalID","metadata":"Meta"},"overrides":[{"column":"books.id","go_type":"int64"}]}`), + } + options, err := parseOptions(req) + if err != nil { + t.Fatal(err) + } + if options.Rename["id"] != "LocalID" || options.Rename["metadata"] != "Meta" { + t.Fatalf("rename precedence: %+v", options.Rename) + } + col := &plugin.Column{Name: "id", Table: &plugin.Identifier{Schema: "custom", Name: "books"}, Type: &plugin.Identifier{Name: "int8"}, NotNull: true} + if got := goType(req, options, col); got != "int64" { + t.Fatalf("legacy global override precedence: %s", got) + } +} + +func TestQualifiedBuiltinOverride(t *testing.T) { + req := &plugin.GenerateRequest{ + Catalog: &plugin.Catalog{DefaultSchema: "public"}, + PluginOptions: []byte(`{"package":"books","sql_package":"wpgx","overrides":[{"db_type":"jsonb","nullable":true,"go_type":{"type":"byte","slice":true}}]}`), + } + options, err := parseOptions(req) + if err != nil { + t.Fatal(err) + } + col := &plugin.Column{Type: &plugin.Identifier{Schema: "pg_catalog", Name: "jsonb"}} + if got := goType(req, options, col); got != "[]byte" { + t.Fatalf("qualified builtin override: %s", got) + } +} + +func TestLegacyStructTagScope(t *testing.T) { + req := &plugin.GenerateRequest{ + Catalog: &plugin.Catalog{DefaultSchema: "public"}, + PluginOptions: []byte(`{"package":"books","sql_package":"wpgx","overrides":[ + {"db_type":"pg_catalog.int8","go_struct_tag":"json:\"changed_id\""}, + {"column":"books.title","go_struct_tag":"json:\"display_title\""} + ]}`), + } + options, err := parseOptions(req) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ column, typ, want string }{ + {"id", "int8", "id"}, + {"title", "text", "display_title"}, + } { + tags := map[string]string{"json": tc.column} + col := &plugin.Column{Name: tc.column, Table: &plugin.Identifier{Schema: "public", Name: "books"}, Type: &plugin.Identifier{Schema: "pg_catalog", Name: tc.typ}, NotNull: true} + addExtraGoStructTags(tags, req, options, col) + if tags["json"] != tc.want { + t.Errorf("%s JSON tag = %q, want %q", tc.column, tags["json"], tc.want) + } + } +} + +func TestMissingGenerateRequest(t *testing.T) { + if _, err := Generate(context.Background(), nil); err == nil { + t.Fatal("expected missing request error") + } +} + +func TestQueryOptions(t *testing.T) { + names := map[string]bool{"GetBook": true} + opts, err := parseQueryOptions([]string{"documentation", " -- timeout: 2s", " -- cache: 1m", " -- cache: 3m"}, true, names) + if err != nil { + t.Fatal(err) + } + if opts.Timeout != 2*time.Second || opts.Cache != 3*time.Minute || !opts.AllowReplica || !opts.CountIntent { + t.Fatalf("options = %+v", opts) + } + opts, err = parseQueryOptions([]string{" -- timeout: 1s", " -- invalidate: [GetBook]"}, false, names) + if err != nil { + t.Fatal(err) + } + if opts.AllowReplica || opts.CountIntent || len(opts.Invalidates) != 1 || opts.Invalidates[0] != "GetBook" { + t.Fatalf("mutation options = %+v", opts) + } +} + +func TestInvalidQueryOptions(t *testing.T) { + for _, tc := range []struct { + comment string + selectStmt bool + }{ + {" -- typo: 1s", true}, + {" -- timeout: 0", true}, + {" -- timeout: 1us", true}, + {" -- cache: invalid", true}, + {" -- timeout", true}, + {" -- invalidate: [Missing]", false}, + {" -- invalidate: [GetBook]", true}, + {" -- allow_replica: true", false}, + {" -- count_intent: yes", true}, + } { + t.Run(tc.comment, func(t *testing.T) { + if _, err := parseQueryOptions([]string{tc.comment}, tc.selectStmt, map[string]bool{"GetBook": true}); err == nil { + t.Fatal("expected an option error") + } + }) + } +} diff --git a/internal/codegen/wicked/postgresql_type.go b/internal/codegen/wicked/postgresql_type.go new file mode 100644 index 0000000000..a5de7df98d --- /dev/null +++ b/internal/codegen/wicked/postgresql_type.go @@ -0,0 +1,271 @@ +package wicked + +import ( + "fmt" + "log" + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/codegen/sdk" + "github.com/sqlc-dev/sqlc/internal/debug" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +func parseIdentifierString(name string) (*plugin.Identifier, error) { + parts := strings.Split(name, ".") + switch len(parts) { + case 1: + return &plugin.Identifier{ + Name: parts[0], + }, nil + case 2: + return &plugin.Identifier{ + Schema: parts[0], + Name: parts[1], + }, nil + case 3: + return &plugin.Identifier{ + Catalog: parts[0], + Schema: parts[1], + Name: parts[2], + }, nil + default: + return nil, fmt.Errorf("invalid name: %s", name) + } +} + +func postgresType(req *plugin.GenerateRequest, options *opts.Options, col *plugin.Column) string { + columnType := strings.TrimPrefix(sdk.DataType(col.Type), "pg_catalog.") + notNull := col.NotNull || col.IsArray + switch columnType { + case "serial", "serial4", "pg_catalog.serial4": + if notNull { + return "int32" + } else { + return "*int32" + } + case "bigserial", "serial8", "pg_catalog.serial8": + if notNull { + return "int64" + } else { + return "*int64" + } + case "smallserial", "serial2", "pg_catalog.serial2": + if notNull { + return "int16" + } else { + return "*int16" + } + case "integer", "int", "int4", "pg_catalog.int4": + if notNull { + return "int32" + } else { + return "*int32" + } + case "bigint", "int8", "pg_catalog.int8": + if notNull { + return "int64" + } else { + return "*int64" + } + case "smallint", "int2", "pg_catalog.int2": + if notNull { + return "int16" + } else { + return "*int16" + } + case "float", "double precision", "float8", "pg_catalog.float8": + if notNull { + return "float64" + } else { + return "*float64" + } + case "real", "float4", "pg_catalog.float4": + if notNull { + return "float32" + } else { + return "*float32" + } + case "numeric", "pg_catalog.numeric", "money": + return "pgtype.Numeric" + case "boolean", "bool", "pg_catalog.bool": + if notNull { + return "bool" + } else { + return "*bool" + } + case "json": + return "json.RawMessage" + case "jsonb": + return "json.RawMessage" + case "bytea", "blob", "pg_catalog.bytea": + return "[]byte" + case "date": + return "pgtype.Date" + case "time", "pg_catalog.time": + return "pgtype.Time" + case "timetz", "pg_catalog.timetz": + // TODO(yumin): strange what timetz are not scanned to pgtype.Time + if notNull { + return "time.Time" + } else { + return "*time.Time" + } + case "timestamp", "pg_catalog.timestamp": + if notNull { + return "time.Time" + } else { + return "*time.Time" + } + case "pg_catalog.timestamptz", "timestamptz": + if notNull { + return "time.Time" + } else { + return "*time.Time" + } + case "text", "varchar", "bpchar", "pg_catalog.varchar", "pg_catalog.bpchar", "string": + if notNull { + return "string" + } else { + return "*string" + } + case "uuid": + if notNull { + return "uuid.UUID" + } else { + return "*uuid.UUID" + } + case "inet": + if notNull { + return "netip.Addr" + } else { + return "*netip.Addr" + + } + case "cidr": + if notNull { + return "netip.Prefix" + } else { + return "*netip.Prefix" + } + case "macaddr", "macaddr8": + if notNull { + return "net.HardwareAddr" + } else { + return "*net.HardwareAddr" + } + case "ltree", "lquery", "ltxtquery": + // This module implements a data type ltree for representing labels + // of data stored in a hierarchical tree-like structure. Extensive + // facilities for searching through label trees are provided. + // + // https://www.postgresql.org/docs/current/ltree.html + if notNull { + return "string" + } else { + return "*string" + } + case "interval", "pg_catalog.interval": + if notNull { + return "int64" + } else { + return "*int64" + } + case "daterange": + return "pgtype.Range[pgtype.Date]" + case "datemultirange": + return "pgtype.Multirange[pgtype.Range[pgtype.Date]]" + case "tsrange": + return "pgtype.Range[pgtype.Timestamp]" + case "tsmultirange": + return "pgtype.Multirange[pgtype.Range[pgtype.Timestamp]]" + case "tstzrange": + return "pgtype.Range[pgtype.Timestamptz]" + case "tstzmultirange": + return "pgtype.Multirange[pgtype.Range[pgtype.Timestamptz]]" + case "numrange": + return "pgtype.Range[pgtype.Numeric]" + case "nummultirange": + return "pgtype.Multirange[pgtype.Range[pgtype.Numeric]]" + case "int4range": + return "pgtype.Range[pgtype.Int4]" + case "int4multirange": + return "pgtype.Multirange[pgtype.Range[pgtype.Int4]]" + case "int8range": + return "pgtype.Range[pgtype.Int8]" + case "int8multirange": + return "pgtype.Multirange[pgtype.Range[pgtype.Int8]]" + case "hstore": + return "pgtype.Hstore" + case "bit", "varbit", "pg_catalog.bit", "pg_catalog.varbit": + return "pgtype.Bits" + case "box": + return "pgtype.Box" + case "cid", "oid": + return "pgtype.Uint32" + case "tid": + return "pgtype.TID" + case "circle": + return "pgtype.Circle" + case "line": + return "pgtype.Line" + case "lseg": + return "pgtype.Lseg" + case "path": + return "pgtype.Path" + case "point": + return "pgtype.Point" + case "polygon": + return "pgtype.Polygon" + case "void": + return "interface{}" + case "any": + return "interface{}" + + default: + rel, err := parseIdentifierString(columnType) + if err != nil { + panic(fmt.Errorf("cannot parse identifier string: %s", columnType)) + } + if rel.Schema == "" { + rel.Schema = req.Catalog.DefaultSchema + } + + for _, schema := range req.Catalog.Schemas { + if schema.Name == "pg_catalog" || schema.Name == "information_schema" { + continue + } + + for _, enum := range schema.Enums { + if rel.Name == enum.Name && rel.Schema == schema.Name { + if notNull { + if schema.Name == req.Catalog.DefaultSchema { + return StructName(enum.Name, options) + } + return StructName(schema.Name+"_"+enum.Name, options) + } else { + if schema.Name == req.Catalog.DefaultSchema { + return "Null" + StructName(enum.Name, options) + } + return "Null" + StructName(schema.Name+"_"+enum.Name, options) + } + } + } + + for _, ct := range schema.CompositeTypes { + if rel.Name == ct.Name && rel.Schema == schema.Name { + if notNull { + return "string" + } else { + return "*string" + } + } + } + } + } + + if debug.Active { + log.Printf("unknown PostgreSQL type: %s\n", columnType) + } + return "interface{}" +} diff --git a/internal/codegen/wicked/query.go b/internal/codegen/wicked/query.go new file mode 100644 index 0000000000..3f3c023755 --- /dev/null +++ b/internal/codegen/wicked/query.go @@ -0,0 +1,432 @@ +package wicked + +import ( + "fmt" + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/metadata" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +type QueryValue struct { + Emit bool + EmitPointer bool + Name string + DBName string // The name of the field in the database. Only set if Struct==nil. + Struct *Struct + Typ string + SQLDriver opts.SQLDriver + + // Column is kept so late in the generation process around to differentiate + // between mysql slices and pg arrays + Column *plugin.Column +} + +func (v QueryValue) EmitStruct() bool { + return v.Emit +} + +func (v QueryValue) IsStruct() bool { + return v.Struct != nil +} + +func (v QueryValue) IsPointer() bool { + return v.EmitPointer && v.Struct != nil +} + +func (v QueryValue) isEmpty() bool { + return v.Typ == "" && v.Name == "" && v.Struct == nil +} + +type Argument struct { + Name string + Type string +} + +func (v QueryValue) Pair() string { + var out []string + for _, arg := range v.Pairs() { + out = append(out, arg.Name+" "+arg.Type) + } + return strings.Join(out, ",") +} + +// Return the argument name and type for query methods. Should only be used in +// the context of method arguments. +func (v QueryValue) Pairs() []Argument { + if v.isEmpty() { + return nil + } + if !v.EmitStruct() && v.IsStruct() { + var out []Argument + for _, f := range v.Struct.Fields { + out = append(out, Argument{ + Name: escape(toLowerCase(f.Name)), + Type: f.Type, + }) + } + return out + } + return []Argument{ + { + Name: escape(v.Name), + Type: v.DefineType(), + }, + } +} + +func (v QueryValue) SlicePair() string { + if v.isEmpty() { + return "" + } + return v.Name + " []" + v.DefineType() +} + +func (v QueryValue) Type() string { + if v.Typ != "" { + return v.Typ + } + if v.Struct != nil { + return v.Struct.Name + } + panic("no type for QueryValue: " + v.Name) +} + +func (v *QueryValue) DefineType() string { + t := v.Type() + if v.IsPointer() { + return "*" + t + } + return t +} + +func (v *QueryValue) ReturnName() string { + if v.IsPointer() { + return "&" + escape(v.Name) + } + return escape(v.Name) +} + +func (v QueryValue) UniqueFields() []Field { + seen := map[string]struct{}{} + fields := make([]Field, 0, len(v.Struct.Fields)) + + for _, field := range v.Struct.Fields { + if _, found := seen[field.Name]; found { + continue + } + seen[field.Name] = struct{}{} + fields = append(fields, field) + } + + return fields +} + +func (v QueryValue) Params() string { + if v.isEmpty() { + return "" + } + var out []string + if v.Struct == nil { + if !v.Column.IsSqlcSlice && strings.HasPrefix(v.Typ, "[]") && v.Typ != "[]byte" && !v.SQLDriver.IsPGX() { + out = append(out, "pq.Array("+escape(v.Name)+")") + } else { + out = append(out, escape(v.Name)) + } + } else { + for _, f := range v.Struct.Fields { + if !f.HasSqlcSlice() && strings.HasPrefix(f.Type, "[]") && f.Type != "[]byte" && !v.SQLDriver.IsPGX() { + out = append(out, "pq.Array("+escape(v.VariableForField(f))+")") + } else { + out = append(out, escape(v.VariableForField(f))) + } + } + } + if len(out) <= 3 { + return strings.Join(out, ",") + } + out = append(out, "") + return strings.TrimRight("\n"+strings.Join(out, ",\n"), ",\n") +} + +func (v QueryValue) ColumnNames() []string { + if v.Struct == nil { + return []string{v.DBName} + } + names := make([]string, len(v.Struct.Fields)) + for i, f := range v.Struct.Fields { + names[i] = f.DBName + } + return names +} + +func (v QueryValue) ColumnNamesAsGoSlice() string { + if v.Struct == nil { + return fmt.Sprintf("[]string{%q}", v.DBName) + } + escapedNames := make([]string, len(v.Struct.Fields)) + for i, f := range v.Struct.Fields { + if f.Column != nil && f.Column.OriginalName != "" { + escapedNames[i] = fmt.Sprintf("%q", f.Column.OriginalName) + } else { + escapedNames[i] = fmt.Sprintf("%q", f.DBName) + } + } + return "[]string{" + strings.Join(escapedNames, ", ") + "}" +} + +// When true, we have to build the arguments to q.db.QueryContext in addition to +// munging the SQL +func (v QueryValue) HasSqlcSlices() bool { + if v.Struct == nil { + return v.Column != nil && v.Column.IsSqlcSlice + } + for _, v := range v.Struct.Fields { + if v.Column.IsSqlcSlice { + return true + } + } + return false +} + +func (v QueryValue) Scan() string { + var out []string + if v.Struct == nil { + if strings.HasPrefix(v.Typ, "[]") && v.Typ != "[]byte" && !v.SQLDriver.IsPGX() { + out = append(out, "pq.Array(&"+v.Name+")") + } else { + // Wicked allocates the scalar return pointer before Scan. + out = append(out, v.Name) + } + } else { + for _, f := range v.Struct.Fields { + + // append any embedded fields + if len(f.EmbedFields) > 0 { + for _, embed := range f.EmbedFields { + if strings.HasPrefix(embed.Type, "[]") && embed.Type != "[]byte" && !v.SQLDriver.IsPGX() { + out = append(out, "pq.Array(&"+v.Name+"."+f.Name+"."+embed.Name+")") + } else { + out = append(out, "&"+v.Name+"."+f.Name+"."+embed.Name) + } + } + continue + } + + if strings.HasPrefix(f.Type, "[]") && f.Type != "[]byte" && !v.SQLDriver.IsPGX() { + out = append(out, "pq.Array(&"+v.Name+"."+f.Name+")") + } else { + out = append(out, "&"+v.Name+"."+f.Name) + } + } + } + if len(out) <= 3 { + return strings.Join(out, ",") + } + out = append(out, "") + return "\n" + strings.Join(out, ",\n") +} + +// Deprecated: This method does not respect the Emit field set on the +// QueryValue. It's used by the go-sql-driver-mysql/copyfromCopy.tmpl and should +// not be used other places. +func (v QueryValue) CopyFromMySQLFields() []Field { + // fmt.Printf("%#v\n", v) + if v.Struct != nil { + return v.Struct.Fields + } + return []Field{ + { + Name: v.Name, + DBName: v.DBName, + Type: v.Typ, + }, + } +} + +func (v QueryValue) VariableForField(f Field) string { + if !v.IsStruct() { + return v.Name + } + if !v.EmitStruct() { + return toLowerCase(f.Name) + } + return v.Name + "." + f.Name +} + +// A struct used to generate methods and fields on the Queries struct +type Query struct { + Pkg string + Option WPgxOption + Invalidates []InvalidateParam + Cmd string + Comments []string + MethodName string + FieldName string + ConstantName string + SQL string + SourceName string + Ret QueryValue + Arg QueryValue + // Used for :copyfrom + Table *plugin.Identifier +} + +func (q Query) hasRetType() bool { + scanned := q.Cmd == metadata.CmdOne || q.Cmd == metadata.CmdMany || + q.Cmd == metadata.CmdBatchMany || q.Cmd == metadata.CmdBatchOne + return scanned && !q.Ret.isEmpty() +} + +func (q Query) TableIdentifierAsGoSlice() string { + escapedNames := make([]string, 0, 3) + for _, p := range []string{q.Table.Catalog, q.Table.Schema, q.Table.Name} { + if p != "" { + escapedNames = append(escapedNames, fmt.Sprintf("%q", p)) + } + } + return "[]string{" + strings.Join(escapedNames, ", ") + "}" +} + +func (q Query) TableIdentifierForMySQL() string { + escapedNames := make([]string, 0, 3) + for _, p := range []string{q.Table.Catalog, q.Table.Schema, q.Table.Name} { + if p != "" { + escapedNames = append(escapedNames, fmt.Sprintf("`%s`", p)) + } + } + return strings.Join(escapedNames, ".") +} + +// CacheKeySprintf is used by WPgx only. +func (v QueryValue) CacheKeySprintf() string { + if v.Struct == nil { + panic(fmt.Errorf("trying to construct sprintf format for non-struct query arg: %+v", v)) + } + format := make([]string, 0) + args := make([]string, 0) + for _, f := range v.Struct.Fields { + format = append(format, "%+v") + if strings.HasPrefix(f.Type, "*") { + args = append(args, wrapPtrStr(v.Name+"."+f.Name)) + } else { + args = append(args, v.Name+"."+f.Name) + } + } + formatStr := `"` + strings.Join(format, ",") + `"` + if len(args) <= 3 { + return formatStr + ", " + strings.Join(args, ",") + } + args = append(args, "") + return formatStr + ",\n" + strings.Join(args, ",\n") +} + +// CountIntent is used by WPgx only. +func (q Query) CountIntent() bool { + return q.Option.CountIntent +} + +// AllowReplica is used by WPgx only. +func (q Query) AllowReplica() bool { + return q.Option.AllowReplica +} + +// CacheKey is used by WPgx only. +func (q Query) CacheKey() string { + return genCacheKeyWithArgName(q, q.Arg.Name) +} + +// InvalidateArgs is used by WPgx only. +func (q Query) InvalidateArgs() string { + rv := "" + // pretty hacky, but works... + if !q.Arg.isEmpty() { + rv = "," + } + for _, inv := range q.Invalidates { + if inv.NoArg { + continue + } + t := "*" + inv.Q.Arg.Type() + rv += fmt.Sprintf("%s %s,", inv.ArgName, t) + } + return strings.TrimRight(rv, ",") +} + +// InvalidateArgsNames is used by WPgx only. +func (q Query) InvalidateArgsNames() string { + rv := "" + // pretty hacky, but works... + if !q.Arg.isEmpty() { + rv = ", " + } + for _, inv := range q.Invalidates { + if inv.NoArg { + continue + } + rv += inv.ArgName + "," + } + return strings.TrimRight(rv, ",") +} + +// UniqueLabel is used by WPgx only. +func (q Query) UniqueLabel() string { + return fmt.Sprintf("%s.%s", q.Pkg, q.MethodName) +} + +// CacheUniqueLabel is used by WPgx only. +func (q Query) CacheUniqueLabel() string { + return fmt.Sprintf("%s:%s:", q.Pkg, q.MethodName) +} + +// ConnType is used by WPgx only. +// Returns the interface type that the query should be called on, either CacheWGConn or CacheQuerierConn. +// PostExec is needed only for invalidation hooks. Both reads and mutations with +// RETURNING can use WQuerier's row APIs; this is not a read-only classification. +func (q Query) ConnType() string { + if len(q.Invalidates) > 0 { + return "CacheWGConn" + } else { + return "CacheQuerierConn" + } +} + +// IsConnTypeQuerier is used by WPgx only. +// Returns true if the query should be called on CacheQuerierConn. +func (q Query) IsConnTypeQuerier() bool { + return len(q.Invalidates) == 0 +} + +func genCacheKeyWithArgName(q Query, argName string) string { + if len(q.Pkg) == 0 { + panic("empty pkg name is invalid") + } + prefix := q.CacheUniqueLabel() + if q.Arg.isEmpty() { + return `"` + prefix + `"` + } + // when it's non-struct parameter, generate inline fmt.Sprintf. + if q.Arg.Struct == nil { + if q.Arg.IsTypePointer() { + argName = wrapPtrStr(argName) + } + fmtStr := `hashIfLong(fmt.Sprintf("%+v",` + argName + `))` + return fmt.Sprintf("\"%s\" + %s", prefix, fmtStr) + } else { + return argName + `.CacheKey()` + } +} + +func wrapPtrStr(v string) string { + return fmt.Sprintf("ptrStr(%s)", v) +} + +type InvalidateParam struct { + Q *Query + NoArg bool + ArgName string + CacheKey string +} + +func (v QueryValue) IsTypePointer() bool { return strings.HasPrefix(v.Type(), "*") } diff --git a/internal/codegen/wicked/reserved.go b/internal/codegen/wicked/reserved.go new file mode 100644 index 0000000000..94494f81b6 --- /dev/null +++ b/internal/codegen/wicked/reserved.go @@ -0,0 +1,67 @@ +package wicked + +func escape(s string) string { + if IsReserved(s) { + return s + "_" + } + return s +} + +func IsReserved(s string) bool { + switch s { + case "break": + return true + case "default": + return true + case "func": + return true + case "interface": + return true + case "select": + return true + case "case": + return true + case "defer": + return true + case "go": + return true + case "map": + return true + case "struct": + return true + case "chan": + return true + case "else": + return true + case "goto": + return true + case "package": + return true + case "switch": + return true + case "const": + return true + case "fallthrough": + return true + case "if": + return true + case "range": + return true + case "type": + return true + case "continue": + return true + case "for": + return true + case "import": + return true + case "return": + return true + case "var": + return true + case "q": + return true + default: + return false + } +} diff --git a/internal/codegen/wicked/result.go b/internal/codegen/wicked/result.go new file mode 100644 index 0000000000..6f0888d5a4 --- /dev/null +++ b/internal/codegen/wicked/result.go @@ -0,0 +1,545 @@ +package wicked + +import ( + "bufio" + "fmt" + "sort" + "strings" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/codegen/sdk" + "github.com/sqlc-dev/sqlc/internal/inflection" + "github.com/sqlc-dev/sqlc/internal/metadata" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +func buildEnums(req *plugin.GenerateRequest, options *opts.Options) []Enum { + var enums []Enum + for _, schema := range req.Catalog.Schemas { + if schema.Name == "pg_catalog" || schema.Name == "information_schema" { + continue + } + for _, enum := range schema.Enums { + var enumName string + if schema.Name == req.Catalog.DefaultSchema { + enumName = enum.Name + } else { + enumName = schema.Name + "_" + enum.Name + } + + e := Enum{ + Name: StructName(enumName, options), + Comment: enum.Comment, + NameTags: map[string]string{}, + ValidTags: map[string]string{}, + } + if options.EmitJsonTags { + e.NameTags["json"] = JSONTagName(enumName, options) + e.ValidTags["json"] = JSONTagName("valid", options) + } + + seen := make(map[string]struct{}, len(enum.Vals)) + for i, v := range enum.Vals { + value := EnumReplace(v) + if _, found := seen[value]; found || value == "" { + value = fmt.Sprintf("value_%d", i) + } + e.Constants = append(e.Constants, Constant{ + Name: StructName(enumName+"_"+value, options), + Value: v, + Type: e.Name, + }) + seen[value] = struct{}{} + } + enums = append(enums, e) + } + } + if len(enums) > 0 { + sort.Slice(enums, func(i, j int) bool { return enums[i].Name < enums[j].Name }) + } + return enums +} + +func buildStructs(req *plugin.GenerateRequest, options *opts.Options) []Struct { + var structs []Struct + for _, schema := range req.Catalog.Schemas { + if schema.Name == "pg_catalog" || schema.Name == "information_schema" { + continue + } + for _, table := range schema.Tables { + primary := req.GetWicked().PrimaryRelation + identity := &plugin.Identifier{Catalog: table.Rel.Catalog, Schema: schema.Name, Name: table.Rel.Name} + if !sdk.SameTableName(primary, identity, req.Catalog.DefaultSchema) { + continue + } + var tableName string + if schema.Name == req.Catalog.DefaultSchema { + tableName = table.Rel.Name + } else { + tableName = schema.Name + "_" + table.Rel.Name + } + structName := tableName + if !options.EmitExactTableNames { + structName = inflection.Singular(inflection.SingularParams{ + Name: structName, + Exclusions: options.InflectionExcludeTableNames, + }) + } + s := Struct{ + Table: &plugin.Identifier{Schema: schema.Name, Name: table.Rel.Name}, + Name: StructName(structName, options), + Comment: table.Comment, + } + for _, column := range table.Columns { + tags := map[string]string{} + if options.EmitDbTags { + tags["db"] = column.Name + } + tags["json"] = JSONTagName(column.Name, options) + addExtraGoStructTags(tags, req, options, column) + s.Fields = append(s.Fields, Field{ + Name: StructName(column.Name, options), + DBName: column.Name, + Column: column, + Type: goType(req, options, column), + Tags: tags, + Comment: column.Comment, + }) + } + structs = append(structs, s) + } + } + if len(structs) > 0 { + sort.Slice(structs, func(i, j int) bool { return structs[i].Name < structs[j].Name }) + } + return structs +} + +type goColumn struct { + id int + *plugin.Column + embed *goEmbed +} + +type goEmbed struct { + modelType string + modelName string + fields []Field +} + +// look through all the structs and attempt to find a matching one to embed +// We need the name of the struct and its field names. +func newGoEmbed(embed *plugin.Identifier, structs []Struct, defaultSchema string) *goEmbed { + if embed == nil { + return nil + } + + for _, s := range structs { + embedSchema := defaultSchema + if embed.Schema != "" { + embedSchema = embed.Schema + } + + // compare the other attributes + if embed.Catalog != s.Table.Catalog || embed.Name != s.Table.Name || embedSchema != s.Table.Schema { + continue + } + + fields := make([]Field, len(s.Fields)) + copy(fields, s.Fields) + + return &goEmbed{ + modelType: s.Name, + modelName: s.Name, + fields: fields, + } + } + + return nil +} + +func columnName(c *plugin.Column, pos int) string { + if c.Name != "" { + return c.Name + } + return fmt.Sprintf("column_%d", pos+1) +} + +func paramName(p *plugin.Parameter) string { + if p.Column.Name != "" { + return argName(p.Column.Name) + } + return fmt.Sprintf("dollar_%d", p.Number) +} + +func argName(name string) string { + out := "" + for i, p := range strings.Split(name, "_") { + if i == 0 { + out += strings.ToLower(p) + } else if p == "id" { + out += "ID" + } else { + out += strings.Title(p) + } + } + return out +} + +func buildQueries(req *plugin.GenerateRequest, options *opts.Options, structs []Struct) ([]Query, error) { + qs := make([]Query, 0, len(req.Queries)) + queryNames := make(map[string]bool, len(req.Queries)) + for _, query := range req.Queries { + queryNames[query.Name] = true + } + for _, query := range req.Queries { + if query.Name == "" { + continue + } + if query.Cmd == "" { + continue + } + + var constantName string + if options.EmitExportedQueries { + constantName = sdk.Title(query.Name) + } else { + constantName = sdk.LowerTitle(query.Name) + } + + comments := query.Comments + if options.EmitSqlAsComment { + if len(comments) == 0 { + comments = append(comments, query.Name) + } + comments = append(comments, " ") + scanner := bufio.NewScanner(strings.NewReader(query.Text)) + for scanner.Scan() { + line := scanner.Text() + comments = append(comments, " "+line) + } + if err := scanner.Err(); err != nil { + return nil, err + } + } + + if wickedReservedNames[strings.ToLower(query.Name)] { + return nil, fmt.Errorf("query name %s is reserved", query.Name) + } + gq := Query{ + Pkg: options.Package, + Cmd: query.Cmd, + ConstantName: constantName, + FieldName: sdk.LowerTitle(query.Name) + "Stmt", + MethodName: query.Name, + SourceName: query.Filename, + SQL: query.Text, + Comments: comments, + Table: query.InsertIntoTable, + } + sqlpkg := parseDriver(options.SqlPackage) + + qpl := int(*options.QueryParameterLimit) + + if len(query.Params) == 1 && qpl != 0 { + p := query.Params[0] + gq.Arg = QueryValue{ + Name: escape(paramName(p)), + DBName: p.Column.GetName(), + Typ: goType(req, options, p.Column), + SQLDriver: sqlpkg, + Column: p.Column, + } + } else if len(query.Params) >= 1 { + var cols []goColumn + for _, p := range query.Params { + cols = append(cols, goColumn{ + id: int(p.Number), + Column: p.Column, + }) + } + s, err := columnsToStruct(req, options, gq.MethodName+"Params", cols, false) + if err != nil { + return nil, err + } + gq.Arg = QueryValue{ + Emit: true, + Name: "arg", + Struct: s, + SQLDriver: sqlpkg, + EmitPointer: options.EmitParamsStructPointers, + } + + // if query params is 2, and query params limit is 4 AND this is a copyfrom, we still want to emit the query's model + // otherwise we end up with a copyfrom using a struct without the struct definition + if len(query.Params) <= qpl && query.Cmd != ":copyfrom" { + gq.Arg.Emit = false + } + } + + if len(query.Columns) == 1 && query.Columns[0].EmbedTable == nil { + c := query.Columns[0] + name := columnName(c, 0) + name = strings.Replace(name, "$", "_", -1) + retName := escape(name) + // For :one queries the scan destination lives in the same scope as + // the query parameters, so reusing a parameter's name would cause + // Scan to overwrite the input and leak it back to the caller on + // sql.ErrNoRows (see sqlc-dev/sqlc#4354). Rename the return + // variable when it would collide. + if query.Cmd == metadata.CmdOne { + argNames := map[string]struct{}{} + for _, p := range gq.Arg.Pairs() { + argNames[p.Name] = struct{}{} + } + for { + if _, conflict := argNames[retName]; !conflict { + break + } + retName += "_2" + } + } + gq.Ret = QueryValue{ + Name: retName, + DBName: name, + Typ: goType(req, options, c), + SQLDriver: sqlpkg, + } + } else if putOutColumns(query) { + var gs *Struct + var emit bool + + for _, s := range structs { + if len(s.Fields) != len(query.Columns) { + continue + } + same := true + for i, f := range s.Fields { + c := query.Columns[i] + sameName := f.Name == StructName(columnName(c, i), options) + sameType := f.Type == goType(req, options, c) + sameTable := sdk.SameTableName(c.Table, s.Table, req.Catalog.DefaultSchema) + if !sameName || !sameType || !sameTable { + same = false + } + } + if same { + gs = &s + break + } + } + + if gs == nil { + var columns []goColumn + for i, c := range query.Columns { + columns = append(columns, goColumn{ + id: i, + Column: c, + embed: newGoEmbed(c.EmbedTable, structs, req.Catalog.DefaultSchema), + }) + } + var err error + gs, err = columnsToStruct(req, options, gq.MethodName+"Row", columns, true) + if err != nil { + return nil, err + } + emit = true + } + gq.Ret = QueryValue{ + Emit: emit, + Name: "i", + Struct: gs, + SQLDriver: sqlpkg, + EmitPointer: options.EmitResultStructPointers, + } + } + + isSelect, present := req.GetWicked().QueryIsSelect[query.Name] + if !present { + return nil, fmt.Errorf("wicked: missing statement metadata for %s", query.Name) + } + var err error + gq.Option, err = parseQueryOptions(query.Comments, isSelect, queryNames) + if err != nil { + return nil, fmt.Errorf("%s/%s: %w", options.Package, query.Name, err) + } + qs = append(qs, gq) + } + sort.Slice(qs, func(i, j int) bool { return qs[i].MethodName < qs[j].MethodName }) + return qs, nil +} + +var cmdReturnsData = map[string]struct{}{ + metadata.CmdBatchMany: {}, + metadata.CmdBatchOne: {}, + metadata.CmdMany: {}, + metadata.CmdOne: {}, +} + +func putOutColumns(query *plugin.Query) bool { + // Legacy wicked exposes Row structs even for exec queries with RETURNING. + // They are part of the generated Go API despite not being returned by exec. + if len(query.Columns) > 0 { + return true + } + _, found := cmdReturnsData[query.Cmd] + return found +} + +// It's possible that this method will generate duplicate JSON tag values +// +// Columns: count, count, count_2 +// Fields: Count, Count_2, Count2 +// +// JSON tags: count, count_2, count_2 +// +// This is unlikely to happen, so don't fix it yet +func columnsToStruct(req *plugin.GenerateRequest, options *opts.Options, name string, columns []goColumn, useID bool) (*Struct, error) { + gs := Struct{ + Name: name, + } + seen := map[string][]int{} + suffixes := map[int]int{} + for i, c := range columns { + colName := columnName(c.Column, i) + tagName := colName + + // override col/tag with expected model name + if c.embed != nil { + colName = c.embed.modelName + tagName = SetCaseStyle(colName, "snake") + } + + fieldName := StructName(colName, options) + baseFieldName := fieldName + // Track suffixes by the ID of the column, so that columns referring to the same numbered parameter can be + // reused. + suffix := 0 + if o, ok := suffixes[c.id]; ok && useID { + suffix = o + } else if v := len(seen[fieldName]); v > 0 && !c.IsNamedParam { + suffix = v + 1 + } + suffixes[c.id] = suffix + if suffix > 0 { + tagName = fmt.Sprintf("%s_%d", tagName, suffix) + fieldName = fmt.Sprintf("%s_%d", fieldName, suffix) + } + tags := map[string]string{} + if options.EmitDbTags { + tags["db"] = tagName + } + if options.EmitJsonTags { + tags["json"] = JSONTagName(tagName, options) + } + addExtraGoStructTags(tags, req, options, c.Column) + f := Field{ + Name: fieldName, + DBName: colName, + Tags: tags, + Column: c.Column, + } + if c.embed == nil { + f.Type = goType(req, options, c.Column) + } else { + f.Type = c.embed.modelType + f.EmbedFields = c.embed.fields + } + + gs.Fields = append(gs.Fields, f) + if _, found := seen[baseFieldName]; !found { + seen[baseFieldName] = []int{i} + } else { + seen[baseFieldName] = append(seen[baseFieldName], i) + } + } + + // If a field does not have a known type, but another + // field with the same name has a known type, assign + // the known type to the field without a known type + for i, field := range gs.Fields { + if len(seen[field.Name]) > 1 && field.Type == "interface{}" { + for _, j := range seen[field.Name] { + if i == j { + continue + } + otherField := gs.Fields[j] + if otherField.Type != field.Type { + field.Type = otherField.Type + } + gs.Fields[i] = field + } + } + } + + err := checkIncompatibleFieldTypes(gs.Fields) + if err != nil { + return nil, err + } + + return &gs, nil +} + +func checkIncompatibleFieldTypes(fields []Field) error { + fieldTypes := map[string]string{} + for _, field := range fields { + if fieldType, found := fieldTypes[field.Name]; !found { + fieldTypes[field.Name] = field.Type + } else if field.Type != fieldType { + return fmt.Errorf("named param %s has incompatible types: %s, %s", field.Name, field.Type, fieldType) + } + } + return nil +} + +func buildQueryInvalidates(queries []Query) error { + qmap := make(map[string]*Query) + for i := range queries { + qmap[queries[i].MethodName] = &queries[i] + } + + for i := range queries { + mutation := &queries[i] + unamer := NewUniqueNamer() + for _, toInvalidateName := range mutation.Option.Invalidates { + query := qmap[toInvalidateName] + if query.Option.Cache <= 0 { + return fmt.Errorf("%s tries to invalidate %s, which is not cached", + mutation.MethodName, toInvalidateName) + } + methodName := sdk.LowerTitle(query.MethodName) + if query.Arg.isEmpty() { + mutation.Invalidates = append(mutation.Invalidates, InvalidateParam{ + Q: query, + NoArg: true, + CacheKey: genCacheKeyWithArgName(*query, ""), // string key + }) + } else { + if query.Arg.IsTypePointer() { + err := fmt.Errorf( + "Although invalidate pointer-typed argument is supported (%s tries to invalidate %s) , the generated type will be **T", + mutation.MethodName, query.MethodName) + fmt.Printf("WARNING: %s\n", err) + } + argName := unamer.UniqueName(methodName) + // additional pointer will be added to invalidate query key, + // so when we generate cache key, add 1 additional deref. + derefArgName := fmt.Sprintf("(*%s)", argName) + cacheKey := genCacheKeyWithArgName(*query, derefArgName) + mutation.Invalidates = append(mutation.Invalidates, InvalidateParam{ + Q: query, + ArgName: argName, + CacheKey: cacheKey, + }) + } + } + } + return nil +} + +func buildDumpLoader(structs []Struct) (*DumpLoader, error) { + if len(structs) == 0 { + return nil, fmt.Errorf("Cannot find main struct") + } + return &DumpLoader{MainStruct: &structs[0]}, nil +} diff --git a/internal/codegen/wicked/struct.go b/internal/codegen/wicked/struct.go new file mode 100644 index 0000000000..5078db3b6c --- /dev/null +++ b/internal/codegen/wicked/struct.go @@ -0,0 +1,49 @@ +package wicked + +import ( + "strings" + "unicode" + "unicode/utf8" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +type Struct struct { + Table *plugin.Identifier + Name string + Fields []Field + Comment string +} + +func StructName(name string, options *opts.Options) string { + if rename := options.Rename[name]; rename != "" { + return rename + } + out := "" + name = strings.Map(func(r rune) rune { + if unicode.IsLetter(r) { + return r + } + if unicode.IsDigit(r) { + return r + } + return rune('_') + }, name) + + for _, p := range strings.Split(name, "_") { + if _, found := options.InitialismsMap[p]; found { + out += strings.ToUpper(p) + } else { + out += strings.Title(p) + } + } + + // If a name has a digit as its first char, prepand an underscore to make it a valid Go name. + r, _ := utf8.DecodeRuneInString(out) + if unicode.IsDigit(r) { + return "_" + out + } else { + return out + } +} diff --git a/internal/codegen/wicked/template.go b/internal/codegen/wicked/template.go new file mode 100644 index 0000000000..0c21b518d7 --- /dev/null +++ b/internal/codegen/wicked/template.go @@ -0,0 +1,7 @@ +package wicked + +import "embed" + +//go:embed templates/* +//go:embed templates/*/* +var templates embed.FS diff --git a/internal/codegen/wicked/templates/template.tmpl b/internal/codegen/wicked/templates/template.tmpl new file mode 100644 index 0000000000..c90e9f7fbd --- /dev/null +++ b/internal/codegen/wicked/templates/template.tmpl @@ -0,0 +1,174 @@ +{{define "dbFile"}}// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc {{.SqlcVersion}} + +package {{.Package}} + +import ( + {{range imports .SourceName}} + {{range .}}{{.}} + {{end}} + {{end}} +) + +{{template "dbCode" . }} +{{end}} + +{{define "dbCode"}} +{{template "dbCodeTemplateWPgx" .}} +{{end}} + +{{define "interfaceFile"}}// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc {{.SqlcVersion}} + +package {{.Package}} + +import ( + {{range imports .SourceName}} + {{range .}}{{.}} + {{end}} + {{end}} +) + +{{template "interfaceCode" . }} +{{end}} + +{{define "interfaceCode"}} +{{template "interfaceCodeWPgx" .}} +{{end}} + +{{define "modelsFile"}}// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc {{.SqlcVersion}} + +package {{.Package}} + +import ( + {{range imports .SourceName}} + {{range .}}{{.}} + {{end}} + {{end}} +) + +{{template "modelsCode" . }} +{{end}} + +{{define "modelsCode"}} +{{range .Enums}} +{{if .Comment}}{{comment .Comment}}{{end}} +type {{.Name}} string + +const ( + {{- range .Constants}} + {{.Name}} {{.Type}} = "{{.Value}}" + {{- end}} +) + +func (e *{{.Name}}) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = {{.Name}}(s) + case string: + *e = {{.Name}}(s) + default: + return fmt.Errorf("unsupported scan type for {{.Name}}: %T", src) + } + return nil +} + +type Null{{.Name}} struct { + {{.Name}} {{.Name}} {{if .NameTag}}{{$.Q}}{{.NameTag}}{{$.Q}}{{end}} + Valid bool {{if .ValidTag}}{{$.Q}}{{.ValidTag}}{{$.Q}}{{end}} // Valid is true if {{.Name}} is not NULL +} + +// Scan implements the Scanner interface. +func (ns *Null{{.Name}}) Scan(value interface{}) error { + if value == nil { + ns.{{.Name}}, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.{{.Name}}.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns Null{{.Name}}) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.{{.Name}}), nil +} + + +{{ if $.EmitEnumValidMethod }} +func (e {{.Name}}) Valid() bool { + switch e { + case {{ range $idx, $name := .Constants }}{{ if ne $idx 0 }},{{ "\n" }}{{ end }}{{ .Name }}{{ end }}: + return true + } + return false +} +{{ end }} + +{{ if $.EmitAllEnumValues }} +func All{{ .Name }}Values() []{{ .Name }} { + return []{{ .Name }}{ {{ range .Constants}}{{ "\n" }}{{ .Name }},{{ end }} + } +} +{{ end }} +{{end}} + +{{range .Structs}} +{{if .Comment}}{{comment .Comment}}{{end}} +type {{.Name}} struct { {{- range .Fields}} + {{- if .Comment}} + {{comment .Comment}}{{else}} + {{- end}} + {{.Name}} {{.Type}} {{if .Tag}}{{$.Q}}{{.Tag}}{{$.Q}}{{end}} + {{- end}} +} +{{end}} +{{end}} + +{{define "queryFile"}}// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc {{.SqlcVersion}} +// source: {{.SourceName}} + +package {{.Package}} + +import ( + {{range imports .SourceName}} + {{range .}}{{.}} + {{end}} + {{end}} +) + +{{template "queryCode" . }} +{{end}} + +{{define "queryCode"}} +{{template "queryCodeWPgx" .}} +{{end}} + +{{define "copyfromFile"}}// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc {{.SqlcVersion}} +// source: {{.SourceName}} + +package {{.Package}} + +import ( + {{range imports .SourceName}} + {{range .}}{{.}} + {{end}} + {{end}} +) + +{{template "copyfromCode" . }} +{{end}} + +{{define "copyfromCode"}} +{{template "copyfromCodeWPgx" .}} +{{end}} diff --git a/internal/codegen/wicked/templates/wpgx/copyfromCopy.tmpl b/internal/codegen/wicked/templates/wpgx/copyfromCopy.tmpl new file mode 100644 index 0000000000..026a53f88f --- /dev/null +++ b/internal/codegen/wicked/templates/wpgx/copyfromCopy.tmpl @@ -0,0 +1,54 @@ +{{define "copyfromCodeWPgx"}} +{{range .GoQueries}} +{{if eq .Cmd ":copyfrom" }} +// iteratorFor{{.MethodName}} implements pgx.CopyFromSource. +type iteratorFor{{.MethodName}} struct { + rows []{{.Arg.DefineType}} + skippedFirstNextCall bool +} + +func (r *iteratorFor{{.MethodName}}) Next() bool { + if len(r.rows) == 0 { + return false + } + if !r.skippedFirstNextCall { + r.skippedFirstNextCall = true + return true + } + r.rows = r.rows[1:] + return len(r.rows) > 0 +} + +func (r iteratorFor{{.MethodName}}) Values() ([]interface{}, error) { + return []interface{}{ +{{- if .Arg.Struct }} +{{- range .Arg.Struct.Fields }} + r.rows[0].{{.Name}}, +{{- end }} +{{- else }} + r.rows[0], +{{- end }} + }, nil +} + +func (r iteratorFor{{.MethodName}}) Err() error { + return nil +} + +{{range .Comments}}//{{.}} +{{end -}} +func (q *Queries) {{.MethodName}}(ctx context.Context, {{.Arg.SlicePair}}) (int64, error) { +{{- if gt .Option.Timeout.Milliseconds 0 }} + ctx, cancel := context.WithTimeout(ctx, time.Millisecond * {{.Option.Timeout.Milliseconds}}) + defer cancel() +{{- end}} + q.db.CountIntent("{{.UniqueLabel}}") + return q.db.WCopyFrom(ctx, "{{.UniqueLabel}}", {{.TableIdentifierAsGoSlice}}, {{.Arg.ColumnNamesAsGoSlice}}, &iteratorFor{{.MethodName}}{rows: {{.Arg.Name}}}) +} + +// eliminate unused error +var _ = time.Now() + +{{end}} +{{end}} +{{end}} diff --git a/internal/codegen/wicked/templates/wpgx/dbCode.tmpl b/internal/codegen/wicked/templates/wpgx/dbCode.tmpl new file mode 100644 index 0000000000..3e96d92c50 --- /dev/null +++ b/internal/codegen/wicked/templates/wpgx/dbCode.tmpl @@ -0,0 +1,88 @@ +{{define "dbCodeTemplateWPgx"}} + +// BeforeDump allows you to edit result before dump. +type BeforeDump func(m *{{.DumpLoader.MainStructName}}) + +type CacheQuerierConn interface { + GetCache() *dcache.DCache + GetConn() wpgx.WQuerier +} + +type CacheWGConn interface { + GetCache() *dcache.DCache + GetConn() wpgx.WGConn +} + +func New(db wpgx.WGConn, cache *dcache.DCache) *Queries { + return &Queries{db: db, cache: cache} +} + +type Queries struct { + db wpgx.WGConn + cache *dcache.DCache +} + +var _ CacheWGConn = (*Queries)(nil) + +func (q *Queries) GetCache() *dcache.DCache { + return q.cache +} + +func (q *Queries) GetConn() wpgx.WGConn { + return q.db +} + +func (q *Queries) AsReadOnly() *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: q.db, + cache: q.cache, + } +} + +func (q *Queries) WithTx(tx *wpgx.WTx) *Queries { + return &Queries{ + db: tx, + cache: q.cache, + } +} + +func (q *Queries) WithCache(cache *dcache.DCache) *Queries { + return &Queries{ + db: q.db, + cache: cache, + } +} + +func (q *Queries) UseReplica(replicaQuerier wpgx.WQuerier) *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: replicaQuerier, + cache: q.cache, + } +} + +type ReadOnlyQueries struct { + db wpgx.WQuerier + cache *dcache.DCache +} + +var _ CacheQuerierConn = (*ReadOnlyQueries)(nil) + +func (q *ReadOnlyQueries) WithCache(cache *dcache.DCache) *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: q.db, + cache: cache, + } +} + +func (q *ReadOnlyQueries) GetCache() *dcache.DCache { + return q.cache +} + +func (q *ReadOnlyQueries) GetConn() wpgx.WQuerier { + return q.db +} + +var Schema = {{$.Q}} +{{escape .RawSchemaSQL}} +{{$.Q}} +{{end}} diff --git a/internal/codegen/wicked/templates/wpgx/interfaceCode.tmpl b/internal/codegen/wicked/templates/wpgx/interfaceCode.tmpl new file mode 100644 index 0000000000..3517b9cff3 --- /dev/null +++ b/internal/codegen/wicked/templates/wpgx/interfaceCode.tmpl @@ -0,0 +1,12 @@ +{{define "interfaceCodeWPgx"}} +type Querier interface { +{{range .GoQueries}} +{{if eq .Cmd ":copyfrom"}} + {{.MethodName}}(ctx context.Context, {{.Arg.SlicePair}}) (int64, error) +{{else}} + {{.MethodName}}(ctx context.Context, {{.Arg.Pair}} {{.InvalidateArgs}}) {{if eq .Cmd ":one"}}(*{{.Ret.Type}}, error){{else if eq .Cmd ":many"}}([]{{.Ret.Type}}, error){{else if eq .Cmd ":exec"}}error{{else if eq .Cmd ":execrows"}}(int64, error){{else if eq .Cmd ":execresult"}}(pgconn.CommandTag, error){{end}} +{{end}} +{{end}} +} +var _ Querier = (*Queries)(nil) +{{end}} diff --git a/internal/codegen/wicked/templates/wpgx/queryCode.tmpl b/internal/codegen/wicked/templates/wpgx/queryCode.tmpl new file mode 100644 index 0000000000..a2305372d0 --- /dev/null +++ b/internal/codegen/wicked/templates/wpgx/queryCode.tmpl @@ -0,0 +1,450 @@ +{{define "queryCodeWPgx"}} +{{range .GoQueries}} +{{if $.OutputQuery .SourceName}} +{{if and (ne .Cmd ":copyfrom") (ne (hasPrefix .Cmd ":batch") true)}} +const {{.ConstantName}} = {{$.Q}}-- name: {{.MethodName}} {{.Cmd}} +{{escape .SQL}} +{{$.Q}} +{{end}} + +{{if ne (hasPrefix .Cmd ":batch") true}} +{{if .Arg.EmitStruct}} +type {{.Arg.Type}} struct { {{- range .Arg.Struct.Fields}} + {{.Name}} {{.Type}} {{if .Tag}}{{$.Q}}{{.Tag}}{{$.Q}}{{end}} + {{- end}} +} + +{{if and (ne .Cmd ":copyfrom") (ne .Option.Cache.Milliseconds 0)}} +// CacheKey - cache key +func ({{.Arg.Name}} {{.Arg.Type}}) CacheKey() string { + prefix := "{{.CacheUniqueLabel}}" + return prefix + hashIfLong(fmt.Sprintf({{.Arg.CacheKeySprintf}})) +} +{{end}} + +{{end}} + +{{if .Ret.EmitStruct}} +type {{.Ret.Type}} struct { {{- range .Ret.Struct.Fields}} + {{.Name}} {{.Type}} {{if .Tag}}{{$.Q}}{{.Tag}}{{$.Q}}{{end}} + {{- end}} +} +{{end}} +{{end}} + +{{if eq .Cmd ":one"}} +{{range .Comments}}//{{.}} +{{end -}} + +func (q *Queries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}} {{.InvalidateArgs}}) (*{{.Ret.Type}}, error) { + return _{{.MethodName}}(ctx, {{- if .IsConnTypeQuerier }}q.AsReadOnly(){{else}}q{{- end}}, {{.Arg.Name}} {{.InvalidateArgsNames}}) +} + +{{ if .AllowReplica }} +func (q *ReadOnlyQueries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}}) (*{{.Ret.Type}}, error) { + return _{{.MethodName}}(ctx, q, {{.Arg.Name}}) +} +{{- end}} + +func _{{.MethodName}}(ctx context.Context, q {{.ConnType}}, {{.Arg.Pair}} {{.InvalidateArgs}}) (*{{.Ret.Type}}, error) { +{{- if gt .Option.Timeout.Milliseconds 0 }} + qctx, cancel := context.WithTimeout(ctx, time.Millisecond * {{.Option.Timeout.Milliseconds}}) + defer cancel() +{{- end}} +{{- if .CountIntent }} + q.GetConn().CountIntent("{{.UniqueLabel}}") +{{- end}} +{{- if eq .Option.Cache.Milliseconds 0}} + row := q.GetConn().WQueryRow(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + var {{.Ret.Name}} *{{.Ret.Type}} = new({{.Ret.Type}}) + err := row.Scan({{.Ret.Scan}}) + if err == pgx.ErrNoRows { + return (*{{.Ret.Type}})(nil), nil + } else if err != nil { + return nil, err + } +{{else}} + dbRead := func() (any, time.Duration, error) { + cacheDuration := time.Duration(time.Millisecond * {{.Option.Cache.Milliseconds}}) + row := q.GetConn().WQueryRow(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + var {{.Ret.Name}} *{{.Ret.Type}} = new({{.Ret.Type}}) + err := row.Scan({{.Ret.Scan}}) + if err == pgx.ErrNoRows { + return (*{{.Ret.Type}})(nil), cacheDuration, nil + } + return {{.Ret.Name}}, cacheDuration, err + } + if q.GetCache() == nil { + {{.Ret.Name}}, _, err := dbRead() + return {{.Ret.Name}}.(*{{.Ret.Type}}), err + } + + var {{.Ret.Name}} *{{.Ret.Type}} + err := q.GetCache().GetWithTtl(qctx, {{.CacheKey}}, &{{.Ret.Name}}, dbRead, false, false) + if err != nil { + return nil, err + } +{{- end}} + +{{ if .Option.Invalidates -}} + // invalidate + _ = q.GetConn().PostExec(func() error { + if q.GetCache() == nil { + return nil + } + anyErr := make(chan error, {{len .Invalidates}}) + var wg sync.WaitGroup + wg.Add({{len .Invalidates}}) + {{ range .Invalidates -}} + go func() { + defer wg.Done() + {{ if not .NoArg -}} + if {{.ArgName}} != nil { + {{ end -}} + key := {{.CacheKey}} + invalidateErr := q.GetCache().Invalidate(ctx, key) + if invalidateErr != nil { + log.Ctx(ctx).Error().Err(invalidateErr).Msgf( + "Failed to invalidate: %s", key) + anyErr <- invalidateErr + } + {{ if not .NoArg -}} + } + {{ end -}} + }() + {{ end -}} + wg.Wait() + close(anyErr) + return <-anyErr + }) +{{- end }} + return {{.Ret.Name}}, err +} +{{end}} + +{{if eq .Cmd ":many"}} +{{range .Comments}}//{{.}} +{{end -}} + +func (q *Queries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}} {{.InvalidateArgs}}) ([]{{.Ret.Type}}, error) { + return _{{.MethodName}}(ctx, {{- if .IsConnTypeQuerier }}q.AsReadOnly(){{else}}q{{- end}}, {{.Arg.Name}} {{.InvalidateArgsNames}}) +} + +{{ if .AllowReplica }} +func (q *ReadOnlyQueries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}}) ([]{{.Ret.Type}}, error) { + return _{{.MethodName}}(ctx, q, {{.Arg.Name}}) +} +{{- end}} + +func _{{.MethodName}}(ctx context.Context, q {{.ConnType}}, {{.Arg.Pair}} {{.InvalidateArgs}}) ([]{{.Ret.Type}}, error) { +{{- if gt .Option.Timeout.Milliseconds 0 }} + qctx, cancel := context.WithTimeout(ctx, time.Millisecond * {{.Option.Timeout.Milliseconds}}) + defer cancel() +{{- end}} +{{- if .CountIntent }} + q.GetConn().CountIntent("{{.UniqueLabel}}") +{{- end}} +{{- if eq .Option.Cache.Milliseconds 0}} + rows, err := q.GetConn().WQuery(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + if err != nil { + return nil, err + } + defer rows.Close() + var items []{{.Ret.Type}} + for rows.Next() { + var {{.Ret.Name}} *{{.Ret.Type}} = new({{.Ret.Type}}) + if err := rows.Scan({{.Ret.Scan}}); err != nil { + return nil, err + } + items = append(items, *{{.Ret.Name}}) + } + if err := rows.Err(); err != nil { + return nil, err + } +{{else}} + dbRead := func() (any, time.Duration, error) { + cacheDuration := time.Duration(time.Millisecond * {{.Option.Cache.Milliseconds}}) + rows, err := q.GetConn().WQuery(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + if err != nil { + return []{{.Ret.Type}}(nil), 0, err + } + defer rows.Close() + var items []{{.Ret.Type}} + for rows.Next() { + var {{.Ret.Name}} *{{.Ret.Type}} = new({{.Ret.Type}}) + if err := rows.Scan({{.Ret.Scan}}); err != nil { + return []{{.Ret.Type}}(nil), 0, err + } + items = append(items, *{{.Ret.Name}}) + } + if err := rows.Err(); err != nil { + return []{{.Ret.Type}}(nil), 0, err + } + return items, cacheDuration, nil + } + if q.GetCache() == nil { + items, _, err := dbRead() + return items.([]{{.Ret.Type}}), err + } + var items []{{.Ret.Type}} + err := q.GetCache().GetWithTtl(qctx, {{.CacheKey}}, &items, dbRead, false, false) + if err != nil { + return nil, err + } +{{- end}} + +{{ if .Option.Invalidates -}} + // invalidate + _ = q.GetConn().PostExec(func() error { + if q.GetCache() == nil { + return nil + } + anyErr := make(chan error, {{len .Invalidates}}) + var wg sync.WaitGroup + wg.Add({{len .Invalidates}}) + {{ range .Invalidates -}} + go func() { + defer wg.Done() + {{ if not .NoArg -}} + if {{.ArgName}} != nil { + {{ end -}} + key := {{.CacheKey}} + invalidateErr := q.GetCache().Invalidate(ctx, key) + if invalidateErr != nil { + log.Ctx(ctx).Error().Err(invalidateErr).Msgf( + "Failed to invalidate: %s", key) + anyErr <- invalidateErr + } + {{ if not .NoArg -}} + } + {{ end -}} + }() + {{ end -}} + wg.Wait() + close(anyErr) + return <-anyErr + }) +{{- end }} + return items, err +} +{{end}} + +{{if eq .Cmd ":exec"}} +{{range .Comments}}//{{.}} +{{end -}} +func (q *Queries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}} {{.InvalidateArgs}}) error { +{{- if gt .Option.Timeout.Milliseconds 0 }} + qctx, cancel := context.WithTimeout(ctx, time.Millisecond * {{.Option.Timeout.Milliseconds}}) + defer cancel() +{{- end}} + _, err := q.db.WExec(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + if err != nil { + return err + } +{{ if .Option.Invalidates -}} + // invalidate + _ = q.db.PostExec(func() error { + if q.cache == nil { + return nil + } + anyErr := make(chan error, {{len .Invalidates}}) + var wg sync.WaitGroup + wg.Add({{len .Invalidates}}) + {{ range .Invalidates -}} + go func() { + defer wg.Done() + {{ if not .NoArg -}} + if {{.ArgName}} != nil { + {{ end -}} + key := {{.CacheKey}} + invalidateErr := q.cache.Invalidate(ctx, key) + if invalidateErr != nil { + log.Ctx(ctx).Error().Err(invalidateErr).Msgf( + "Failed to invalidate: %s", key) + anyErr <- invalidateErr + } + {{ if not .NoArg -}} + } + {{ end -}} + }() + {{ end -}} + wg.Wait() + close(anyErr) + return <-anyErr + }) +{{- end }} + return nil +} +{{end}} + +{{if eq .Cmd ":execrows"}} +{{range .Comments}}//{{.}} +{{end -}} +func (q *Queries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}} {{.InvalidateArgs}}) (int64, error) { +{{- if gt .Option.Timeout.Milliseconds 0 }} + qctx, cancel := context.WithTimeout(ctx, time.Millisecond * {{.Option.Timeout.Milliseconds}}) + defer cancel() +{{- end}} + result, err := q.db.WExec(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + if err != nil { + return 0, err + } +{{ if .Option.Invalidates -}} + // invalidate + _ = q.db.PostExec(func() error { + if q.cache == nil { + return nil + } + anyErr := make(chan error, {{len .Invalidates}}) + var wg sync.WaitGroup + wg.Add({{len .Invalidates}}) + {{ range .Invalidates -}} + go func() { + defer wg.Done() + {{ if not .NoArg -}} + if {{.ArgName}} != nil { + {{ end -}} + key := {{.CacheKey}} + invalidateErr := q.cache.Invalidate(ctx, key) + if invalidateErr != nil { + log.Ctx(ctx).Error().Err(invalidateErr).Msgf( + "Failed to invalidate: %s", key) + anyErr <- invalidateErr + } + {{ if not .NoArg -}} + } + {{ end -}} + }() + {{ end -}} + wg.Wait() + close(anyErr) + return <-anyErr + }) +{{- end }} + return result.RowsAffected(), nil +} +{{end}} + +{{if eq .Cmd ":execresult"}} +{{range .Comments}}//{{.}} +{{end -}} +func (q *Queries) {{.MethodName}}(ctx context.Context, {{.Arg.Pair}} {{.InvalidateArgs}}) (pgconn.CommandTag, error) { +{{- if gt .Option.Timeout.Milliseconds 0 }} + qctx, cancel := context.WithTimeout(ctx, time.Millisecond * {{.Option.Timeout.Milliseconds}}) + defer cancel() +{{- end}} + rv, err := q.db.WExec(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}}) + if err != nil { + return rv, err + } +{{ if .Option.Invalidates -}} + // invalidate + _ = q.db.PostExec(func() error { + if q.cache == nil { + return nil + } + anyErr := make(chan error, {{len .Invalidates}}) + var wg sync.WaitGroup + wg.Add({{len .Invalidates}}) + {{ range .Invalidates -}} + go func() { + defer wg.Done() + {{ if not .NoArg -}} + if {{.ArgName}} != nil { + {{ end -}} + key := {{.CacheKey}} + invalidateErr := q.cache.Invalidate(ctx, key) + if invalidateErr != nil { + log.Ctx(ctx).Error().Err(invalidateErr).Msgf( + "Failed to invalidate: %s", key) + anyErr <- invalidateErr + } + {{ if not .NoArg -}} + } + {{ end -}} + }() + {{ end -}} + wg.Wait() + close(anyErr) + return <-anyErr + }) +{{- end }} + return rv, nil +} +{{end}} + + +{{end}} +{{end}} + +//// auto generated functions + +func (q *Queries) Dump(ctx context.Context, beforeDump ...BeforeDump) ([]byte, error) { + sql := "{{.DumpLoader.DumpSQL}}" + rows, err := q.db.WQuery(ctx, "{{.Package}}.Dump", sql) + if err != nil { + return nil, err + } + defer rows.Close() + var items []{{.DumpLoader.MainStructName}} + for rows.Next() { + var v {{.DumpLoader.MainStructName}} + if err := rows.Scan({{.DumpLoader.Fields "&v."}}); err != nil { + return nil, err + } + for _, applyBeforeDump := range beforeDump { + applyBeforeDump(&v) + } + items = append(items, v) + } + if err := rows.Err(); err != nil { + return nil, err + } + bytes, err := json.MarshalIndent(items, "", " ") + if err != nil { + return nil, err + } + return bytes, nil +} + +func (q *Queries) Load(ctx context.Context, data []byte) error { + sql := "{{.DumpLoader.LoadSQL}}" + rows := make([]{{.DumpLoader.MainStructName}}, 0) + err := json.Unmarshal(data, &rows) + if err != nil { + return err + } + for _, row := range rows { + _, err := q.db.WExec(ctx, "{{.Package}}.Load", sql, {{.DumpLoader.Fields "row."}}) + if err != nil { + return err + } + } + return nil +} + +func hashIfLong(v string) string { + if len(v) > 64 { + hash := sha256.Sum256([]byte(v)) + return "h(" + hex.EncodeToString(hash[:]) + ")" + } + return v +} + +func ptrStr[T any](v *T) string { + if v == nil { + return "" + } + return fmt.Sprintf("%+v", *v) +} + +// eliminate unused error +var _ = log.Logger +var _ = fmt.Sprintf("") +var _ = time.Now() +var _ = json.RawMessage{} +var _ = sha256.Sum256(nil) +var _ = hex.EncodeToString(nil) +var _ = sync.WaitGroup{} + +{{end}} diff --git a/internal/codegen/wicked/types_test.go b/internal/codegen/wicked/types_test.go new file mode 100644 index 0000000000..ce8b7f0145 --- /dev/null +++ b/internal/codegen/wicked/types_test.go @@ -0,0 +1,77 @@ +package wicked + +import ( + "testing" + + "github.com/sqlc-dev/sqlc/internal/codegen/golang/opts" + "github.com/sqlc-dev/sqlc/internal/plugin" +) + +func TestPostgreSQLTypeCompatibility(t *testing.T) { + req := &plugin.GenerateRequest{Catalog: &plugin.Catalog{DefaultSchema: "public"}} + for _, tc := range []struct { + name, required, nullable string + }{ + {"int2", "int16", "*int16"}, {"int4", "int32", "*int32"}, {"int8", "int64", "*int64"}, + {"serial2", "int16", "*int16"}, {"serial4", "int32", "*int32"}, {"serial8", "int64", "*int64"}, + {"float4", "float32", "*float32"}, {"float8", "float64", "*float64"}, + {"numeric", "pgtype.Numeric", "pgtype.Numeric"}, {"money", "pgtype.Numeric", "pgtype.Numeric"}, + {"bool", "bool", "*bool"}, {"bytea", "[]byte", "[]byte"}, + {"json", "json.RawMessage", "json.RawMessage"}, {"jsonb", "json.RawMessage", "json.RawMessage"}, + {"text", "string", "*string"}, {"varchar", "string", "*string"}, {"bpchar", "string", "*string"}, + {"uuid", "uuid.UUID", "*uuid.UUID"}, + {"date", "pgtype.Date", "pgtype.Date"}, {"time", "pgtype.Time", "pgtype.Time"}, + {"timetz", "time.Time", "*time.Time"}, {"timestamp", "time.Time", "*time.Time"}, {"timestamptz", "time.Time", "*time.Time"}, + {"interval", "int64", "*int64"}, + {"inet", "netip.Addr", "*netip.Addr"}, {"cidr", "netip.Prefix", "*netip.Prefix"}, + {"macaddr", "net.HardwareAddr", "*net.HardwareAddr"}, {"macaddr8", "net.HardwareAddr", "*net.HardwareAddr"}, + {"ltree", "string", "*string"}, {"lquery", "string", "*string"}, {"ltxtquery", "string", "*string"}, + {"hstore", "pgtype.Hstore", "pgtype.Hstore"}, + {"bit", "pgtype.Bits", "pgtype.Bits"}, {"varbit", "pgtype.Bits", "pgtype.Bits"}, + {"cid", "pgtype.Uint32", "pgtype.Uint32"}, {"oid", "pgtype.Uint32", "pgtype.Uint32"}, {"tid", "pgtype.TID", "pgtype.TID"}, + {"box", "pgtype.Box", "pgtype.Box"}, {"circle", "pgtype.Circle", "pgtype.Circle"}, {"line", "pgtype.Line", "pgtype.Line"}, + {"lseg", "pgtype.Lseg", "pgtype.Lseg"}, {"path", "pgtype.Path", "pgtype.Path"}, {"point", "pgtype.Point", "pgtype.Point"}, {"polygon", "pgtype.Polygon", "pgtype.Polygon"}, + {"daterange", "pgtype.Range[pgtype.Date]", "pgtype.Range[pgtype.Date]"}, + {"tsrange", "pgtype.Range[pgtype.Timestamp]", "pgtype.Range[pgtype.Timestamp]"}, + {"tstzrange", "pgtype.Range[pgtype.Timestamptz]", "pgtype.Range[pgtype.Timestamptz]"}, + {"numrange", "pgtype.Range[pgtype.Numeric]", "pgtype.Range[pgtype.Numeric]"}, + {"int4range", "pgtype.Range[pgtype.Int4]", "pgtype.Range[pgtype.Int4]"}, + {"int8range", "pgtype.Range[pgtype.Int8]", "pgtype.Range[pgtype.Int8]"}, + {"datemultirange", "pgtype.Multirange[pgtype.Range[pgtype.Date]]", "pgtype.Multirange[pgtype.Range[pgtype.Date]]"}, + {"tsmultirange", "pgtype.Multirange[pgtype.Range[pgtype.Timestamp]]", "pgtype.Multirange[pgtype.Range[pgtype.Timestamp]]"}, + {"tstzmultirange", "pgtype.Multirange[pgtype.Range[pgtype.Timestamptz]]", "pgtype.Multirange[pgtype.Range[pgtype.Timestamptz]]"}, + {"nummultirange", "pgtype.Multirange[pgtype.Range[pgtype.Numeric]]", "pgtype.Multirange[pgtype.Range[pgtype.Numeric]]"}, + {"int4multirange", "pgtype.Multirange[pgtype.Range[pgtype.Int4]]", "pgtype.Multirange[pgtype.Range[pgtype.Int4]]"}, + {"int8multirange", "pgtype.Multirange[pgtype.Range[pgtype.Int8]]", "pgtype.Multirange[pgtype.Range[pgtype.Int8]]"}, + {"name", "interface{}", "interface{}"}, {"xid", "interface{}", "interface{}"}, + } { + for _, schema := range []string{"", "pg_catalog"} { + t.Run(schema+"/"+tc.name, func(t *testing.T) { + for _, notNull := range []bool{false, true} { + col := &plugin.Column{Type: &plugin.Identifier{Schema: schema, Name: tc.name}, NotNull: notNull} + want := tc.nullable + if notNull { + want = tc.required + } + if got := goType(req, &opts.Options{}, col); got != want { + t.Fatalf("not-null=%v: type = %s, want %s", notNull, got, want) + } + col.IsArray, col.ArrayDims = true, 2 + if got := goType(req, &opts.Options{}, col); got != "[][]"+tc.required { + t.Fatalf("array type = %s, want [][]%s", got, tc.required) + } + } + }) + } + } +} + +func TestUUIDImportCompatibility(t *testing.T) { + for _, typ := range []string{"uuid.UUID", "*uuid.UUID", "[]uuid.UUID"} { + i := importer{Options: &opts.Options{SqlPackage: "pgx/v5"}, Structs: []Struct{{Fields: []Field{{Type: typ}}}}} + imports := i.modelImports() + if len(imports.Dep) != 1 || imports.Dep[0].Path != "github.com/google/uuid" { + t.Errorf("%s imports = %+v, want google/uuid", typ, imports.Dep) + } + } +} diff --git a/internal/codegen/wicked/unique_namer.go b/internal/codegen/wicked/unique_namer.go new file mode 100644 index 0000000000..204a426ac3 --- /dev/null +++ b/internal/codegen/wicked/unique_namer.go @@ -0,0 +1,23 @@ +package wicked + +import "fmt" + +type UniqueNamer struct { + used map[string]int +} + +func NewUniqueNamer() *UniqueNamer { + return &UniqueNamer{used: make(map[string]int)} +} + +func (u *UniqueNamer) UniqueName(name string) string { + if n, ok := u.used[name]; ok { + newName := fmt.Sprintf("%s%d", name, n) + u.used[name] += 1 + u.used[newName] = 1 + return newName + } else { + u.used[name] = 1 + return name + } +} diff --git a/internal/endtoend/testdata/go.mod b/internal/endtoend/testdata/go.mod index 502eef7017..fe50756b30 100644 --- a/internal/endtoend/testdata/go.mod +++ b/internal/endtoend/testdata/go.mod @@ -21,17 +21,46 @@ require ( ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/coocood/freecache v1.2.3 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/friendsofgo/errors v0.9.2 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgproto3/v2 v2.0.1 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kelseyhightower/envconfig v1.4.0 // indirect + github.com/klauspost/compress v1.15.14 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/pgvector/pgvector-go v0.1.1 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + github.com/rs/zerolog v1.28.0 // indirect + github.com/satori/go.uuid v1.2.0 // indirect + github.com/stumble/dcache v0.1.3 // indirect + github.com/stumble/wpgx v0.3.1 // indirect + github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/volatiletech/inflect v0.0.1 // indirect github.com/volatiletech/randomize v0.0.1 // indirect github.com/volatiletech/strmangle v0.0.1 // indirect + go.opentelemetry.io/otel v1.12.0 // indirect + go.opentelemetry.io/otel/trace v1.12.0 // indirect golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + google.golang.org/protobuf v1.30.0 // indirect ) diff --git a/internal/endtoend/testdata/go.sum b/internal/endtoend/testdata/go.sum index 0711cb8eaf..0698936eee 100644 --- a/internal/endtoend/testdata/go.sum +++ b/internal/endtoend/testdata/go.sum @@ -1,20 +1,44 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coocood/freecache v1.2.3 h1:lcBwpZrwBZRZyLk/8EMyQVXRiFl663cCuMOrjCALeto= +github.com/coocood/freecache v1.2.3/go.mod h1:RBUWa/Cy+OHdfTGFEhEuE1pMCMX51Ncizj7rthiQ3vk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -83,8 +107,15 @@ github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshN github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.1 h1:PJAw7H/9hoWC4Kf3J8iNmL1SwA6E8vfsLqBiL+F6CtI= github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc= +github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -100,21 +131,40 @@ github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/pgvector/pgvector-go v0.1.1 h1:kqJigGctFnlWvskUiYIvJRNwUtQl/aMSUZVs0YWQe+g= github.com/pgvector/pgvector-go v0.1.1/go.mod h1:wLJgD/ODkdtd2LJK4l6evHXTuG+8PxymYAVomKHOWac= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY= @@ -132,8 +182,18 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stumble/dcache v0.1.3 h1:ONN+Y65XgOe/tZ7Xnu5keS58oDX3KFjs5xqTk+mJG7s= +github.com/stumble/dcache v0.1.3/go.mod h1:NRYV3jq62VEFw0SleYWHK5y6nONZ+LFnLXTo927ilVk= +github.com/stumble/wpgx v0.3.1 h1:u56Xcj8gVBQSyk+nEf03HVqm3XUaytjsEjcEdDdkkXM= +github.com/stumble/wpgx v0.3.1/go.mod h1:NzglxY/rDtz4lnR3CdP5HpMgL49Edq9Iftm9H6OKwEc= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/volatiletech/inflect v0.0.1 h1:2a6FcMQyhmPZcLa+uet3VJ8gLn/9svWhJxJYwvE8KsU= github.com/volatiletech/inflect v0.0.1/go.mod h1:IBti31tG6phkHitLlr5j7shC5SOo//x0AjDzaJU1PLA= github.com/volatiletech/null/v8 v8.1.2 h1:kiTiX1PpwvuugKwfvUNX/SU/5A2KGZMXfGD0DUHdKEI= @@ -143,6 +203,10 @@ github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xu github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.opentelemetry.io/otel v1.12.0 h1:IgfC7kqQrRccIKuB7Cl+SRUmsKbEwSGPr0Eu+/ht1SQ= +go.opentelemetry.io/otel v1.12.0/go.mod h1:geaoz0L0r1BEOR81k7/n9W4TCXYCJ7bPO7K374jQHG0= +go.opentelemetry.io/otel/trace v1.12.0 h1:p28in++7Kd0r2d8gSt931O57fdjUyWxkVbESuILAeUc= +go.opentelemetry.io/otel/trace v1.12.0/go.mod h1:pHlgBynn6s25qJ2szD+Bv+iwKJttjHSI3lUAyf0GNuQ= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -170,7 +234,10 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -181,6 +248,10 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= @@ -207,6 +278,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IV golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/internal/endtoend/testdata/wicked_compat/go/compatibility_test.go b/internal/endtoend/testdata/wicked_compat/go/compatibility_test.go new file mode 100644 index 0000000000..ac830ff8e1 --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/go/compatibility_test.go @@ -0,0 +1,37 @@ +package compat + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" +) + +// Check type identity, not only the spelling "uuid.UUID" in generated source. +var ( + _ uuid.UUID = Record{}.ExternalID + _ *uuid.UUID = Record{}.OptionalUuid + _ = CreateRecordRow{ID: 42, Name: "example"} + _ = CreateRecordParams{ExternalID: uuid.UUID{}, OptionalUuid: new(uuid.UUID)} +) + +func TestLegacyJSONPayload(t *testing.T) { + const id = "e9114100-51ea-4e6c-8f88-189f4f365dfe" + u := uuid.MustParse(id) + record := Record{ID: 42, ExternalID: u, OptionalUuid: &u, Name: "example", Data: json.RawMessage(`{"n":1}`)} + const legacy = `{"id":42,"external_id":"` + id + `","optional_uuid":"` + id + `","display_name":"example","data":{"n":1}}` + encoded, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if string(encoded) != legacy { + t.Fatalf("JSON contract changed: %s", encoded) + } + var decoded Record + if err := json.Unmarshal([]byte(legacy), &decoded); err != nil { + t.Fatal(err) + } + if decoded.ID != 42 || decoded.ExternalID != u || decoded.OptionalUuid == nil || *decoded.OptionalUuid != u || decoded.Name != "example" || string(decoded.Data) != `{"n":1}` { + t.Fatalf("old cache payload did not round-trip: %+v", decoded) + } +} diff --git a/internal/endtoend/testdata/wicked_compat/go/db.go b/internal/endtoend/testdata/wicked_compat/go/db.go new file mode 100644 index 0000000000..1a002f11dc --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/go/db.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package compat + +import ( + "github.com/stumble/dcache" + "github.com/stumble/wpgx" +) + +// BeforeDump allows you to edit result before dump. +type BeforeDump func(m *Record) + +type CacheQuerierConn interface { + GetCache() *dcache.DCache + GetConn() wpgx.WQuerier +} + +type CacheWGConn interface { + GetCache() *dcache.DCache + GetConn() wpgx.WGConn +} + +func New(db wpgx.WGConn, cache *dcache.DCache) *Queries { + return &Queries{db: db, cache: cache} +} + +type Queries struct { + db wpgx.WGConn + cache *dcache.DCache +} + +var _ CacheWGConn = (*Queries)(nil) + +func (q *Queries) GetCache() *dcache.DCache { + return q.cache +} + +func (q *Queries) GetConn() wpgx.WGConn { + return q.db +} + +func (q *Queries) AsReadOnly() *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: q.db, + cache: q.cache, + } +} + +func (q *Queries) WithTx(tx *wpgx.WTx) *Queries { + return &Queries{ + db: tx, + cache: q.cache, + } +} + +func (q *Queries) WithCache(cache *dcache.DCache) *Queries { + return &Queries{ + db: q.db, + cache: cache, + } +} + +func (q *Queries) UseReplica(replicaQuerier wpgx.WQuerier) *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: replicaQuerier, + cache: q.cache, + } +} + +type ReadOnlyQueries struct { + db wpgx.WQuerier + cache *dcache.DCache +} + +var _ CacheQuerierConn = (*ReadOnlyQueries)(nil) + +func (q *ReadOnlyQueries) WithCache(cache *dcache.DCache) *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: q.db, + cache: cache, + } +} + +func (q *ReadOnlyQueries) GetCache() *dcache.DCache { + return q.cache +} + +func (q *ReadOnlyQueries) GetConn() wpgx.WQuerier { + return q.db +} + +var Schema = ` +CREATE TABLE records () INHERITS (parents); +` diff --git a/internal/endtoend/testdata/wicked_compat/go/models.go b/internal/endtoend/testdata/wicked_compat/go/models.go new file mode 100644 index 0000000000..49b488dc7f --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/go/models.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package compat + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type Record struct { + ID int64 `json:"id"` + ExternalID uuid.UUID `json:"external_id"` + OptionalUuid *uuid.UUID `json:"optional_uuid"` + Name string `json:"display_name"` + Data json.RawMessage `json:"data"` +} diff --git a/internal/endtoend/testdata/wicked_compat/go/query.sql.go b/internal/endtoend/testdata/wicked_compat/go/query.sql.go new file mode 100644 index 0000000000..eacc4c8031 --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/go/query.sql.go @@ -0,0 +1,198 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package compat + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +const createRecord = `-- name: CreateRecord :exec +INSERT INTO records (id, external_id, optional_uuid, name, data) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, name +` + +type CreateRecordParams struct { + ID int64 + ExternalID uuid.UUID + OptionalUuid *uuid.UUID + Name string `json:"display_name"` + Data json.RawMessage +} + +type CreateRecordRow struct { + ID int64 + Name string `json:"display_name"` +} + +// -- timeout: 1s +// -- invalidate: [GetRecord] +func (q *Queries) CreateRecord(ctx context.Context, arg CreateRecordParams, getRecord *int64) error { + qctx, cancel := context.WithTimeout(ctx, time.Millisecond*1000) + defer cancel() + _, err := q.db.WExec(qctx, "compat.CreateRecord", createRecord, + arg.ID, + arg.ExternalID, + arg.OptionalUuid, + arg.Name, + arg.Data) + if err != nil { + return err + } + // invalidate + _ = q.db.PostExec(func() error { + if q.cache == nil { + return nil + } + anyErr := make(chan error, 1) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if getRecord != nil { + key := "compat:GetRecord:" + hashIfLong(fmt.Sprintf("%+v", (*getRecord))) + invalidateErr := q.cache.Invalidate(ctx, key) + if invalidateErr != nil { + log.Ctx(ctx).Error().Err(invalidateErr).Msgf( + "Failed to invalidate: %s", key) + anyErr <- invalidateErr + } + } + }() + wg.Wait() + close(anyErr) + return <-anyErr + }) + return nil +} + +const getRecord = `-- name: GetRecord :one +SELECT id, external_id, optional_uuid, name, data FROM records WHERE id = $1 +` + +// -- timeout: 1s +// -- cache: 1m +func (q *Queries) GetRecord(ctx context.Context, id int64) (*Record, error) { + return _GetRecord(ctx, q.AsReadOnly(), id) +} + +func (q *ReadOnlyQueries) GetRecord(ctx context.Context, id int64) (*Record, error) { + return _GetRecord(ctx, q, id) +} + +func _GetRecord(ctx context.Context, q CacheQuerierConn, id int64) (*Record, error) { + qctx, cancel := context.WithTimeout(ctx, time.Millisecond*1000) + defer cancel() + q.GetConn().CountIntent("compat.GetRecord") + dbRead := func() (any, time.Duration, error) { + cacheDuration := time.Duration(time.Millisecond * 60000) + row := q.GetConn().WQueryRow(qctx, "compat.GetRecord", getRecord, id) + var i *Record = new(Record) + err := row.Scan( + &i.ID, + &i.ExternalID, + &i.OptionalUuid, + &i.Name, + &i.Data, + ) + if err == pgx.ErrNoRows { + return (*Record)(nil), cacheDuration, nil + } + return i, cacheDuration, err + } + if q.GetCache() == nil { + i, _, err := dbRead() + return i.(*Record), err + } + + var i *Record + err := q.GetCache().GetWithTtl(qctx, "compat:GetRecord:"+hashIfLong(fmt.Sprintf("%+v", id)), &i, dbRead, false, false) + if err != nil { + return nil, err + } + + return i, err +} + +//// auto generated functions + +func (q *Queries) Dump(ctx context.Context, beforeDump ...BeforeDump) ([]byte, error) { + sql := "SELECT id,external_id,optional_uuid,name,data FROM \"records\" ORDER BY id,name ASC;" + rows, err := q.db.WQuery(ctx, "compat.Dump", sql) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Record + for rows.Next() { + var v Record + if err := rows.Scan(&v.ID, &v.ExternalID, &v.OptionalUuid, &v.Name, &v.Data); err != nil { + return nil, err + } + for _, applyBeforeDump := range beforeDump { + applyBeforeDump(&v) + } + items = append(items, v) + } + if err := rows.Err(); err != nil { + return nil, err + } + bytes, err := json.MarshalIndent(items, "", " ") + if err != nil { + return nil, err + } + return bytes, nil +} + +func (q *Queries) Load(ctx context.Context, data []byte) error { + sql := "INSERT INTO \"records\" (id,external_id,optional_uuid,name,data) VALUES ($1,$2,$3,$4,$5);" + rows := make([]Record, 0) + err := json.Unmarshal(data, &rows) + if err != nil { + return err + } + for _, row := range rows { + _, err := q.db.WExec(ctx, "compat.Load", sql, row.ID, row.ExternalID, row.OptionalUuid, row.Name, row.Data) + if err != nil { + return err + } + } + return nil +} + +func hashIfLong(v string) string { + if len(v) > 64 { + hash := sha256.Sum256([]byte(v)) + return "h(" + hex.EncodeToString(hash[:]) + ")" + } + return v +} + +func ptrStr[T any](v *T) string { + if v == nil { + return "" + } + return fmt.Sprintf("%+v", *v) +} + +// eliminate unused error +var _ = log.Logger +var _ = fmt.Sprintf("") +var _ = time.Now() +var _ = json.RawMessage{} +var _ = sha256.Sum256(nil) +var _ = hex.EncodeToString(nil) +var _ = sync.WaitGroup{} diff --git a/internal/endtoend/testdata/wicked_compat/parents.sql b/internal/endtoend/testdata/wicked_compat/parents.sql new file mode 100644 index 0000000000..8ed58b13be --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/parents.sql @@ -0,0 +1,7 @@ +CREATE TABLE parents ( + id bigint NOT NULL, + external_id uuid NOT NULL, + optional_uuid uuid, + name text NOT NULL, + data jsonb +); diff --git a/internal/endtoend/testdata/wicked_compat/query.sql b/internal/endtoend/testdata/wicked_compat/query.sql new file mode 100644 index 0000000000..95dd83ffb7 --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/query.sql @@ -0,0 +1,11 @@ +-- name: GetRecord :one +-- -- timeout: 1s +-- -- cache: 1m +SELECT * FROM records WHERE id = @id; + +-- name: CreateRecord :exec +-- -- timeout: 1s +-- -- invalidate: [GetRecord] +INSERT INTO records (id, external_id, optional_uuid, name, data) +VALUES (@id, @external_id, @optional_uuid, @name, @data) +RETURNING id, name; diff --git a/internal/endtoend/testdata/wicked_compat/schema.sql b/internal/endtoend/testdata/wicked_compat/schema.sql new file mode 100644 index 0000000000..bda0051dca --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/schema.sql @@ -0,0 +1 @@ +CREATE TABLE records () INHERITS (parents); diff --git a/internal/endtoend/testdata/wicked_compat/sqlc.yaml b/internal/endtoend/testdata/wicked_compat/sqlc.yaml new file mode 100644 index 0000000000..f1355b9559 --- /dev/null +++ b/internal/endtoend/testdata/wicked_compat/sqlc.yaml @@ -0,0 +1,15 @@ +version: '2' +sql: + - schema: [schema.sql, parents.sql] + queries: query.sql + engine: postgresql + gen: + go: + package: compat + out: go + sql_package: wpgx + overrides: + - db_type: pg_catalog.int8 + go_struct_tag: 'json:"changed_id"' + - column: records.name + go_struct_tag: 'json:"display_name"' diff --git a/internal/endtoend/testdata/wicked_missing_timeout/query.sql b/internal/endtoend/testdata/wicked_missing_timeout/query.sql new file mode 100644 index 0000000000..05f373a9bd --- /dev/null +++ b/internal/endtoend/testdata/wicked_missing_timeout/query.sql @@ -0,0 +1,2 @@ +-- name: GetBook :one +SELECT * FROM books WHERE id = @id; diff --git a/internal/endtoend/testdata/wicked_missing_timeout/schema.sql b/internal/endtoend/testdata/wicked_missing_timeout/schema.sql new file mode 100644 index 0000000000..754b8479ba --- /dev/null +++ b/internal/endtoend/testdata/wicked_missing_timeout/schema.sql @@ -0,0 +1 @@ +CREATE TABLE books (id bigint NOT NULL); diff --git a/internal/endtoend/testdata/wicked_missing_timeout/sqlc.yaml b/internal/endtoend/testdata/wicked_missing_timeout/sqlc.yaml new file mode 100644 index 0000000000..90cadea5b8 --- /dev/null +++ b/internal/endtoend/testdata/wicked_missing_timeout/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: postgresql + schema: schema.sql + queries: query.sql + gen: + go: + package: books + sql_package: wpgx + out: go diff --git a/internal/endtoend/testdata/wicked_missing_timeout/stderr.txt b/internal/endtoend/testdata/wicked_missing_timeout/stderr.txt new file mode 100644 index 0000000000..9d83107b7f --- /dev/null +++ b/internal/endtoend/testdata/wicked_missing_timeout/stderr.txt @@ -0,0 +1,2 @@ +# package books +error generating code: books/GetBook does not have a timeout option diff --git a/internal/endtoend/testdata/wicked_primary/books.sql b/internal/endtoend/testdata/wicked_primary/books.sql new file mode 100644 index 0000000000..32de14ce20 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/books.sql @@ -0,0 +1,5 @@ +CREATE TABLE books ( + id bigint NOT NULL, + title text NOT NULL, + metadata jsonb +); diff --git a/internal/endtoend/testdata/wicked_primary/go/db.go b/internal/endtoend/testdata/wicked_primary/go/db.go new file mode 100644 index 0000000000..1412297ed1 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/go/db.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package revenues + +import ( + "github.com/stumble/dcache" + "github.com/stumble/wpgx" +) + +// BeforeDump allows you to edit result before dump. +type BeforeDump func(m *BookRevenue) + +type CacheQuerierConn interface { + GetCache() *dcache.DCache + GetConn() wpgx.WQuerier +} + +type CacheWGConn interface { + GetCache() *dcache.DCache + GetConn() wpgx.WGConn +} + +func New(db wpgx.WGConn, cache *dcache.DCache) *Queries { + return &Queries{db: db, cache: cache} +} + +type Queries struct { + db wpgx.WGConn + cache *dcache.DCache +} + +var _ CacheWGConn = (*Queries)(nil) + +func (q *Queries) GetCache() *dcache.DCache { + return q.cache +} + +func (q *Queries) GetConn() wpgx.WGConn { + return q.db +} + +func (q *Queries) AsReadOnly() *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: q.db, + cache: q.cache, + } +} + +func (q *Queries) WithTx(tx *wpgx.WTx) *Queries { + return &Queries{ + db: tx, + cache: q.cache, + } +} + +func (q *Queries) WithCache(cache *dcache.DCache) *Queries { + return &Queries{ + db: q.db, + cache: cache, + } +} + +func (q *Queries) UseReplica(replicaQuerier wpgx.WQuerier) *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: replicaQuerier, + cache: q.cache, + } +} + +type ReadOnlyQueries struct { + db wpgx.WQuerier + cache *dcache.DCache +} + +var _ CacheQuerierConn = (*ReadOnlyQueries)(nil) + +func (q *ReadOnlyQueries) WithCache(cache *dcache.DCache) *ReadOnlyQueries { + return &ReadOnlyQueries{ + db: q.db, + cache: cache, + } +} + +func (q *ReadOnlyQueries) GetCache() *dcache.DCache { + return q.cache +} + +func (q *ReadOnlyQueries) GetConn() wpgx.WQuerier { + return q.db +} + +var Schema = ` +CREATE MATERIALIZED VIEW book_revenues AS +SELECT id, title, metadata FROM books; +` diff --git a/internal/endtoend/testdata/wicked_primary/go/models.go b/internal/endtoend/testdata/wicked_primary/go/models.go new file mode 100644 index 0000000000..83d7f44d41 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/go/models.go @@ -0,0 +1,15 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package revenues + +import ( + "encoding/json" +) + +type BookRevenue struct { + ID int64 `json:"id"` + Title string `json:"title"` + Metadata json.RawMessage `json:"metadata"` +} diff --git a/internal/endtoend/testdata/wicked_primary/go/querier.go b/internal/endtoend/testdata/wicked_primary/go/querier.go new file mode 100644 index 0000000000..ae178400eb --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/go/querier.go @@ -0,0 +1,15 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package revenues + +import ( + "context" +) + +type Querier interface { + GetRevenue(ctx context.Context, id int64) (*BookRevenue, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/endtoend/testdata/wicked_primary/go/query.sql.go b/internal/endtoend/testdata/wicked_primary/go/query.sql.go new file mode 100644 index 0000000000..ac15b5da20 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/go/query.sql.go @@ -0,0 +1,131 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package revenues + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +const getRevenue = `-- name: GetRevenue :one +SELECT id, title, metadata FROM book_revenues WHERE id = $1 +` + +// -- timeout: 250ms +// -- cache: 1m +func (q *Queries) GetRevenue(ctx context.Context, id int64) (*BookRevenue, error) { + return _GetRevenue(ctx, q.AsReadOnly(), id) +} + +func (q *ReadOnlyQueries) GetRevenue(ctx context.Context, id int64) (*BookRevenue, error) { + return _GetRevenue(ctx, q, id) +} + +func _GetRevenue(ctx context.Context, q CacheQuerierConn, id int64) (*BookRevenue, error) { + qctx, cancel := context.WithTimeout(ctx, time.Millisecond*250) + defer cancel() + q.GetConn().CountIntent("revenues.GetRevenue") + dbRead := func() (any, time.Duration, error) { + cacheDuration := time.Duration(time.Millisecond * 60000) + row := q.GetConn().WQueryRow(qctx, "revenues.GetRevenue", getRevenue, id) + var i *BookRevenue = new(BookRevenue) + err := row.Scan(&i.ID, &i.Title, &i.Metadata) + if err == pgx.ErrNoRows { + return (*BookRevenue)(nil), cacheDuration, nil + } + return i, cacheDuration, err + } + if q.GetCache() == nil { + i, _, err := dbRead() + return i.(*BookRevenue), err + } + + var i *BookRevenue + err := q.GetCache().GetWithTtl(qctx, "revenues:GetRevenue:"+hashIfLong(fmt.Sprintf("%+v", id)), &i, dbRead, false, false) + if err != nil { + return nil, err + } + + return i, err +} + +//// auto generated functions + +func (q *Queries) Dump(ctx context.Context, beforeDump ...BeforeDump) ([]byte, error) { + sql := "SELECT id,title,metadata FROM \"book_revenues\" ORDER BY id,title ASC;" + rows, err := q.db.WQuery(ctx, "revenues.Dump", sql) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BookRevenue + for rows.Next() { + var v BookRevenue + if err := rows.Scan(&v.ID, &v.Title, &v.Metadata); err != nil { + return nil, err + } + for _, applyBeforeDump := range beforeDump { + applyBeforeDump(&v) + } + items = append(items, v) + } + if err := rows.Err(); err != nil { + return nil, err + } + bytes, err := json.MarshalIndent(items, "", " ") + if err != nil { + return nil, err + } + return bytes, nil +} + +func (q *Queries) Load(ctx context.Context, data []byte) error { + sql := "INSERT INTO \"book_revenues\" (id,title,metadata) VALUES ($1,$2,$3);" + rows := make([]BookRevenue, 0) + err := json.Unmarshal(data, &rows) + if err != nil { + return err + } + for _, row := range rows { + _, err := q.db.WExec(ctx, "revenues.Load", sql, row.ID, row.Title, row.Metadata) + if err != nil { + return err + } + } + return nil +} + +func hashIfLong(v string) string { + if len(v) > 64 { + hash := sha256.Sum256([]byte(v)) + return "h(" + hex.EncodeToString(hash[:]) + ")" + } + return v +} + +func ptrStr[T any](v *T) string { + if v == nil { + return "" + } + return fmt.Sprintf("%+v", *v) +} + +// eliminate unused error +var _ = log.Logger +var _ = fmt.Sprintf("") +var _ = time.Now() +var _ = json.RawMessage{} +var _ = sha256.Sum256(nil) +var _ = hex.EncodeToString(nil) +var _ = sync.WaitGroup{} diff --git a/internal/endtoend/testdata/wicked_primary/primary.sql b/internal/endtoend/testdata/wicked_primary/primary.sql new file mode 100644 index 0000000000..4c6b382535 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/primary.sql @@ -0,0 +1,2 @@ +CREATE MATERIALIZED VIEW book_revenues AS +SELECT id, title, metadata FROM books; diff --git a/internal/endtoend/testdata/wicked_primary/query.sql b/internal/endtoend/testdata/wicked_primary/query.sql new file mode 100644 index 0000000000..6ad9634f21 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/query.sql @@ -0,0 +1,4 @@ +-- name: GetRevenue :one +-- -- timeout: 250ms +-- -- cache: 1m +SELECT * FROM book_revenues WHERE id = @id; diff --git a/internal/endtoend/testdata/wicked_primary/sqlc.yaml b/internal/endtoend/testdata/wicked_primary/sqlc.yaml new file mode 100644 index 0000000000..1cb94e8611 --- /dev/null +++ b/internal/endtoend/testdata/wicked_primary/sqlc.yaml @@ -0,0 +1,11 @@ +version: "2" +sql: + - engine: postgresql + schema: [primary.sql, books.sql] + queries: query.sql + gen: + go: + package: revenues + sql_package: wpgx + emit_interface: true + out: go diff --git a/scripts/release_test.go b/scripts/release_test.go new file mode 100644 index 0000000000..f269a49130 --- /dev/null +++ b/scripts/release_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestReleaseVersionMatchesGeneratedCode(t *testing.T) { + if testing.Short() { + t.Skip("builds and runs the release binary") + } + dir := t.TempDir() + binary := filepath.Join(dir, "sqlc") + if runtime.GOOS == "windows" { + binary += ".exe" + } + const version = "v2.4.0-version-test-wicked-fork" + build := exec.Command("go", "build", "-ldflags", releaseLDFlags(version), "-o", binary, "./cmd/sqlc") + build.Dir = ".." + build.Env = append(os.Environ(), "CGO_ENABLED=0") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build release: %v\n%s", err, output) + } + output, err := exec.Command(binary, "version").CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != version { + t.Fatalf("version: %v: %s", err, output) + } + for name, contents := range map[string]string{ + "sqlc.yaml": "version: '2'\nsql:\n- schema: schema.sql\n queries: query.sql\n engine: postgresql\n gen:\n go:\n sql_package: wpgx\n package: records\n out: generated\n", + "schema.sql": "CREATE TABLE records (id bigint NOT NULL);", + "query.sql": "-- name: GetRecord :one\n-- -- timeout: 1s\nSELECT * FROM records WHERE id = @id;", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + generate := exec.Command(binary, "generate") + generate.Dir = dir + if output, err := generate.CombinedOutput(); err != nil { + t.Fatalf("generate: %v\n%s", err, output) + } + for _, name := range []string{"db.go", "models.go", "query.sql.go"} { + code, err := os.ReadFile(filepath.Join(dir, "generated", name)) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(code, []byte("// sqlc "+version+"\n")) { + t.Errorf("%s does not report the release version", name) + } + } +} diff --git a/wicked_change_logs.md b/wicked_change_logs.md new file mode 100644 index 0000000000..fdb6660ec4 --- /dev/null +++ b/wicked_change_logs.md @@ -0,0 +1,33 @@ +# Wicked changes +## Oppinionated fixes (changes) +1. a set of rules mapping pg types to go types. +2. always emit JSON tag. +3. TODO: Unified place for all types defined in the query. + Usecase: eliminate duplicated ENUM values, in every generated model file. +4. Since NULL is not “equal to” NULL, (The null value represents an unknown + value, and it is not known whether two unknown values are equal), You should never pass + a nil pointer to the function argument in a select query where condition. + Also, unlike needle, cache for query with parameters having pointer fields is not supported. +5. cache key uniqueness: cache key for a query is consisted by + `packageName + methodName + "joining arguments in string format with ","`. + The uniqueness of package names are checked for one configuration file. +6. Please define only *ONE* table per schema.sql file. + Only one model, which is defined by the only table creation statement of the first + schema, will be generated into the model file. Internally, to allow user to put + `create materialized view` into schema.sql, we reversed the order of parsing schema files. +7. If you need to preserve camel-styled names, use rename option in configuration file. + There is no way for us to do it automatically, because tokens were lower-cased in pg parser. + It is recommended to snake case in SQL. +8. Not really doing type-checking on everything: + Although using type cast can help to generate correctly typed code, but we found that not + all SQL code are type-checked correctly. We might need to implement a new type check pass. +9. Schema.sql will be copied into db.go file as `var Schema`. User need to be careful with using + those schema. type/function declaration: does not support `IF NOT EXISTS`, so they should only + be executed once. `Create [materialized] view` can only be executed after dependency tables + have been created. + +## TODOs +1. Batch support for wpgx. + +## Cherry-picked fixes ++ TBD: https://github.com/kyleconroy/sqlc/pull/2001