diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 21c75f2..162d4ad 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,6 +15,10 @@ before: builds: - env: - CGO_ENABLED=0 + # --version reads main.version. Setting ldflags replaces GoReleaser's + # default -s -w plus version/commit/date/builtBy, so keep -s -w here. + ldflags: + - -s -w -X main.version={{.Version}} goos: - linux - windows diff --git a/AGENTS.md b/AGENTS.md index 444f884..d349f32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Primary package layout: - Build: `go build ./...` - Unit/package tests: `go test ./params/... ./jqresult/... ./resultset/...` - Full test suite: `go test ./...` (requires emulator/container environment for integration tests) -- Lint: `golangci-lint run` +- Lint: `GOTOOLCHAIN=go1.25.13 golangci-lint run` (the CI-pinned linter v2.12.2 is built with go1.26 and panics loading a go1.27 stdlib; pin the toolchain to the `go.mod` 1.25 line) - Golden files: - Update CSV goldens: `go test -update-golden -run TestExperimentalCsvGolden .` - Update YAML/profile goldens: `go test -update-golden -run TestYamlOutputGolden .` diff --git a/Makefile b/Makefile index 49be109..2ee88d4 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ build: go build -v ./... lint: - golangci-lint run ./... + GOTOOLCHAIN=go1.25.13 golangci-lint run ./... test: go test -v ./... diff --git a/README.md b/README.md index 4c38783..d0826d9 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Arguments: Flags: -h, --help Show context-sensitive help. + --version Show version and exit. --sql=STRING SQL query text; exclusive with --sql-file. --sql-file=STRING File name contains SQL query; exclusive with --sql @@ -44,9 +45,9 @@ Flags: WITH_PLAN_AND_STATS, or WITH_STATS. --priority="unspecified" Priority for the execute SQL request. --format="json" Output format of the primary document. - -o, --output="-" Destination of the primary document. Use - - for stdout; /dev/stdout and /dev/stderr are - mapped in-process. + -o, --output="-" Destination of the primary document. + Use - for stdout; /dev/stdout, /dev/stderr, + and /dev/null are mapped in-process. --plan-output=STRING Write the query-plan artifact here and strip stats.queryPlan from the primary document. Enables split mode. @@ -114,6 +115,13 @@ Timestamp Bound (micro-seconds precision) ``` +## Exit status + +- `0` success (`--help` and `--version` included) +- `1` runtime failure (query, auth, output, and other execution errors) +- `2` usage or flag parse error +- `3` output failed after a statement was committed (not a rollback; SQL is not replayed) + Local build requires Go 1.25. ``` @@ -175,7 +183,8 @@ Path conventions for both `--output` and `--plan-output`: - `-` means stdout. - `/dev/stdout` and `/dev/stderr` are recognized literally and mapped to stdout/stderr in-process (so they work on Windows and take part in collision checks). `--plan-output=/dev/stderr` is the supported spelling for "plan on the terminal while rows go down the pipe". -- Any other value is a regular file. Files are written to a sibling temp file (mode `0600`) and renamed into place after the query (and transaction) succeeds, so a failing query leaves an existing target intact. Overwriting an existing target is allowed. Two files plus stdout are not a transaction: if publishing the second file fails, the command reports which outputs completed and exits non-zero. SQL is never replayed because an output failed. +- `/dev/null` is recognized literally and discards bytes in-process. Two `/dev/null` destinations are allowed. +- Any other value must be a regular file (directories and devices are rejected before the query). Files are written to a sibling temp file in the resolved directory (mode `0600`) and renamed into place after the query (and transaction) succeeds, so a failing query leaves an existing target intact. The published file keeps mode `0600` even when replacing a more permissive target, because result rows may be sensitive. Overwriting an existing target is allowed. Two files plus stdout are not a transaction: if publishing the second file fails, the command reports which outputs completed and exits non-zero. SQL is never replayed because an output failed. Symlinked parent directories are resolved before collision checks, so `--output=real/new.json --plan-output=alias/new.json` is rejected when `alias` points at `real`. In split mode the two destinations must differ. Both on stdout (any spelling) is rejected. `--plan-output` requires `--query-mode=PLAN`, `PROFILE`, or `WITH_PLAN_AND_STATS` (never upgraded from `NORMAL` or `WITH_STATS`). It is incompatible with `--try-partition-query` and `--enable-partitioned-dml`. diff --git a/command.go b/command.go index fbfb117..d118642 100644 --- a/command.go +++ b/command.go @@ -43,7 +43,7 @@ func prepareCommand(o opts) (*preparedCommand, error) { jqFilter = jqresult.DefaultFilter(jqMode) } - jqCode, err = jqresult.Compile(jqFilter, jqMode) + jqCode, err = jqresult.Compile(jqFilter) if err != nil { return nil, err } diff --git a/command_test.go b/command_test.go index 24a22ba..4889004 100644 --- a/command_test.go +++ b/command_test.go @@ -28,6 +28,63 @@ func TestRunCLIHelpReturnsWithoutExecution(t *testing.T) { } } +func TestRunCLIVersionReturnsWithoutExecution(t *testing.T) { + out, err := captureStdout(t, func() error { return runCLI(t.Context(), []string{"--version"}) }) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out) != "dev" { + t.Fatalf("version = %q, want dev", out) + } +} + +func TestProcessFlagsValidationErrorOmitsUsage(t *testing.T) { + stderr, err := captureStderr(t, func() error { + _, err := processFlags([]string{"db", "--instance", "i", "--sql", "SELECT 1"}) + return err + }) + if err == nil || !strings.Contains(err.Error(), "--project is required") { + t.Fatalf("error = %v, want --project is required", err) + } + if strings.Contains(stderr, "Usage:") { + t.Fatalf("validation error dumped usage: %q", stderr) + } +} + +func TestProcessFlagsUnknownFlagOmitsUsage(t *testing.T) { + stderr, err := captureStderr(t, func() error { + return runCLI(t.Context(), []string{"--unknown-option"}) + }) + if err == nil { + t.Fatal("expected argument error") + } + if strings.Contains(stderr, "Usage:") { + t.Fatalf("parse error dumped usage: %q", stderr) + } +} + +func TestExitStatus(t *testing.T) { + t.Parallel() + + if got := exitStatus(nil); got != 0 { + t.Fatalf("nil = %d, want 0", got) + } + if got := exitStatus(errors.New("query failed")); got != exitFailure { + t.Fatalf("generic = %d, want %d", got, exitFailure) + } + if got := exitStatus(wrapCommittedOutputError(errors.New("rename failed"))); got != exitOutputAfterCommit { + t.Fatalf("after commit = %d, want %d", got, exitOutputAfterCommit) + } + + _, err := processFlags([]string{"--unknown-option"}) + if err == nil { + t.Fatal("expected parse error") + } + if got := exitStatus(err); got != exitUsage { + t.Fatalf("parse = %d, want %d (%v)", got, exitUsage, err) + } +} + func TestPrepareCommandFreezesInputs(t *testing.T) { dir := t.TempDir() sql, params, filter := filepath.Join(dir, "query.sql"), filepath.Join(dir, "params.json"), filepath.Join(dir, "filter.jq") @@ -61,7 +118,7 @@ func TestPrepareCommandFreezesInputs(t *testing.T) { func TestPrintJQHonorsCancellation(t *testing.T) { for _, mode := range []jqresult.InputMode{jqresult.InputEager, jqresult.InputLazy} { t.Run(string(mode), func(t *testing.T) { - code, err := jqresult.Compile("def spin: spin; spin", mode) + code, err := jqresult.Compile("def spin: spin; spin") if err != nil { t.Fatal(err) } diff --git a/execution_test.go b/execution_test.go index 2919361..558637a 100644 --- a/execution_test.go +++ b/execution_test.go @@ -66,12 +66,20 @@ func TestDMLResultPublication(t *testing.T) { server := &executionServer{retry: scenario == "retry", failCommit: scenario == "commit_failure"} startQueryStatsModeServer(t, server) path := filepath.Join(t.TempDir(), "result") + if err := os.WriteFile(path, []byte("original"), 0600); err != nil { + t.Fatal(err) + } if scenario == "output_failure" { - if err := os.Mkdir(path, 0700); err != nil { - t.Fatal(err) + orig := afterOutputSinksOpen + t.Cleanup(func() { afterOutputSinksOpen = orig }) + afterOutputSinksOpen = func(*outputSinks) { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } } - } else if err := os.WriteFile(path, []byte("original"), 0600); err != nil { - t.Fatal(err) } err := runCLI(t.Context(), []string{"db", "--project", "p", "--instance", "i", "--sql", "UPDATE T SET V=1 THEN RETURN V", "--format", format, "--output", path, "--timeout", "5s"}) diff --git a/flags_validation_test.go b/flags_validation_test.go index 0b85b58..b1d6066 100644 --- a/flags_validation_test.go +++ b/flags_validation_test.go @@ -97,6 +97,7 @@ func TestIsReadWriteStatement(t *testing.T) { {name: "commented_update", query: "/* comment */ UPDATE T SET X=1", wantDML: true}, {name: "line_comment_update", query: "-- comment\nUPDATE T SET X=1", wantDML: true}, {name: "hash_comment_update", query: "# comment\nINSERT T(a) VALUES(1)", wantDML: true}, + {name: "hint_then_insert", query: "@{priority=HIGH} INSERT T(a) VALUES(1)", wantDML: true}, {name: "plain_select", query: "SELECT 1", wantDML: false}, {name: "commented_select", query: "-- comment\nSELECT 1", wantDML: false}, } diff --git a/go.mod b/go.mod index 189d432..55f6b3a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/alecthomas/kong v1.15.0 github.com/apstndb/gsqlutils v0.0.0-20260502161854-d7d6011a36e0 github.com/apstndb/memebridge v0.6.1 - github.com/apstndb/spanemuboost v0.4.6 + github.com/apstndb/spanemuboost v0.4.7 github.com/apstndb/spaniter v0.3.1 github.com/apstndb/spannerotel v0.2.0 github.com/apstndb/spannerplan v0.3.0 @@ -23,14 +23,14 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.uber.org/zap v1.27.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.43.0 + golang.org/x/term v0.45.0 google.golang.org/api v0.280.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - cel.dev/expr v0.25.1 // indirect + cel.dev/expr v0.25.2 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect @@ -41,7 +41,7 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -111,7 +111,7 @@ require ( github.com/samber/lo v1.53.0 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/gcloud v0.42.0 // indirect @@ -121,7 +121,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect @@ -132,12 +132,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/image v0.43.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index f32d821..2807503 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= @@ -30,8 +30,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0 h1:BzsL0qE7LvtTEtXG7Dt5NS1EP0CQwI21HZfj9aGghhw= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.6.0/go.mod h1:I7kE2kM3qCr9QPT4cU4cCFYkEpVyVr16YOGUHzy+nR0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0 h1:5eCqTd9rTwMlE62z0xFdzPJ+3pji75hJrwq1jrCjo5w= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0/go.mod h1:4BcvJy7WxY8X2eX49z2VO1ByhO+CcQK8lKPCH/QlZvo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= @@ -56,8 +56,8 @@ github.com/apstndb/memebridge v0.6.1 h1:M1FMF5kb5vP/v5rTfP/N1HlLCfBPX7ctDQ3K/gFk github.com/apstndb/memebridge v0.6.1/go.mod h1:E/HVP4iaSgiPXMIQTPT1u1uKkjmRtRECkVKE2TrF3bc= github.com/apstndb/protoyaml v0.1.1 h1:qCxi4l6twinpF+tM3qXG2qeRq6OmIklWK+LWtuM1eBk= github.com/apstndb/protoyaml v0.1.1/go.mod h1:bsZCSj3nYZKfLiKRogOxuUjlURUOJpBb+G5f1b3g5Fc= -github.com/apstndb/spanemuboost v0.4.6 h1:9f1qQDLNrPQJiswNLtiTaR0U//zToqxjcEGWGkyThm4= -github.com/apstndb/spanemuboost v0.4.6/go.mod h1:urUe85EvqomWV4vCj2pALluNhIksbu9RAQZufgq1b8E= +github.com/apstndb/spanemuboost v0.4.7 h1:+IVu7sJG1D8uTLVb2YZDZpBVazGIleaEriWomu1662M= +github.com/apstndb/spanemuboost v0.4.7/go.mod h1:urUe85EvqomWV4vCj2pALluNhIksbu9RAQZufgq1b8E= github.com/apstndb/spaniter v0.3.1 h1:hhi4+JCF80x696bg7zIco7Vo8kmPbkbTdvSOEhItp50= github.com/apstndb/spaniter v0.3.1/go.mod h1:aBSHcHIqgAZXCxFdi734R/wAQUIuCQ6WZ+CjOCxARIM= github.com/apstndb/spannerotel v0.2.0 h1:EpGzxB9CfnRedlOlO/x4+c8+vOEbptGcd0PegEMsKYk= @@ -278,8 +278,8 @@ github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUN github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -315,8 +315,8 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= @@ -354,8 +354,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= @@ -375,8 +375,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -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/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -385,8 +385,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -398,14 +398,14 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -443,8 +443,8 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/integration_test.go b/integration_test.go index 8ab3039..ce44ecd 100644 --- a/integration_test.go +++ b/integration_test.go @@ -77,7 +77,7 @@ func runEagerReadWriteDML(t *testing.T, client *spanner.Client, ctx context.Cont if err != nil { return err } - code, err := jqresult.Compile(filter, jqresult.InputEager) + code, err := jqresult.Compile(filter) if err != nil { return err } @@ -376,7 +376,7 @@ func TestWithCloudSpannerEmulator(t *testing.T) { spanner.Statement{SQL: "SELECT SingerId FROM Singers ORDER BY SingerId LIMIT 3"}, opts, ) - code, err := jqresult.Compile(filter, jqresult.InputLazy) + code, err := jqresult.Compile(filter) if err != nil { t.Fatal(err) } @@ -503,7 +503,7 @@ func TestWithCloudSpannerEmulator(t *testing.T) { if err != nil { return err } - code, err := jqresult.Compile("true", jqresult.InputEager) + code, err := jqresult.Compile("true") if err != nil { return err } diff --git a/internal/planrender/render.go b/internal/planrender/render.go index ae5512a..3e2c367 100644 --- a/internal/planrender/render.go +++ b/internal/planrender/render.go @@ -180,25 +180,25 @@ func renderGraph(ctx context.Context, w io.Writer, format Format, rowType *sppb. return fmt.Errorf("build plan graph: %w", err) } - switch format { - case FormatDOT: + switch { + case format == FormatDOT: return dot.NewRenderer(dot.Options{ ShowQuery: opts.ShowQuery, ShowQueryStats: opts.ShowQueryStats, }).Render(ctx, w, plan) - case FormatMermaid: + case format == FormatMermaid: return mermaid.NewRenderer(mermaid.Options{ BuildOptions: buildOpts, ShowQuery: opts.ShowQuery, ShowQueryStats: opts.ShowQueryStats, }).Render(ctx, w, plan) - case FormatD2: + case format == FormatD2: return d2.NewRenderer(d2.Options{ BuildOptions: buildOpts, ShowQuery: opts.ShowQuery, ShowQueryStats: opts.ShowQueryStats, }).Render(ctx, w, plan) - case FormatSVG, FormatPNG: + case format.NeedsGraphviz(): gvFormat := graphviz.SVG if format == FormatPNG { gvFormat = graphviz.PNG diff --git a/jqresult/compile.go b/jqresult/compile.go index 728e482..4ae0130 100644 --- a/jqresult/compile.go +++ b/jqresult/compile.go @@ -5,8 +5,7 @@ import ( ) // Compile parses filter and returns executable jq code. -// The mode parameter is retained for source compatibility; compilation is mode-independent. -func Compile(filter string, _ InputMode) (*gojq.Code, error) { +func Compile(filter string) (*gojq.Code, error) { q, err := gojq.Parse(filter) if err != nil { return nil, err diff --git a/jqresult/lazy_replay_test.go b/jqresult/lazy_replay_test.go index 5d9f15d..0b48330 100644 --- a/jqresult/lazy_replay_test.go +++ b/jqresult/lazy_replay_test.go @@ -114,7 +114,7 @@ func TestLazyRedactRowsViews(t *testing.T) { l.rows.redact = true defer l.Stop() - code, err := Compile("{n: (.rows|length), rows: [.rows[]]}", InputLazy) + code, err := Compile("{n: (.rows|length), rows: [.rows[]]}") if err != nil { t.Fatal(err) } @@ -192,7 +192,7 @@ func runLazyFilter(t *testing.T, filter string) any { t.Helper() l := newSyntheticLazy(t, 3) defer l.Stop() - code, err := Compile(filter, InputLazy) + code, err := Compile(filter) if err != nil { t.Fatal(err) } diff --git a/jqresult/lazy_stats_test.go b/jqresult/lazy_stats_test.go index b56e578..be2e9e2 100644 --- a/jqresult/lazy_stats_test.go +++ b/jqresult/lazy_stats_test.go @@ -65,7 +65,7 @@ func TestLazyOmitQueryPlanLeavesQueryStats(t *testing.T) { } defer l.Stop() - code, err := Compile(".stats", InputLazy) + code, err := Compile(".stats") if err != nil { t.Fatal(err) } @@ -140,7 +140,7 @@ func TestLazyStatsInterleavedFilterIDs(t *testing.T) { l := newSyntheticLazy(t, 3) defer l.Stop() - code, err := Compile(`. as $root | [.rows[] | {row: ., stats: $root.stats}]`, InputLazy) + code, err := Compile(`. as $root | [.rows[] | {row: ., stats: $root.stats}]`) if err != nil { t.Fatal(err) } diff --git a/jqresult/pipeline_test.go b/jqresult/pipeline_test.go index becb613..08c19bf 100644 --- a/jqresult/pipeline_test.go +++ b/jqresult/pipeline_test.go @@ -7,7 +7,7 @@ import ( func TestExecuteLazyNilRowIter(t *testing.T) { t.Parallel() - code, err := Compile(".", InputLazy) + code, err := Compile(".") if err != nil { t.Fatal(err) } @@ -20,7 +20,7 @@ func TestExecuteLazyNilRowIter(t *testing.T) { func TestExecuteEagerNilResultSet(t *testing.T) { t.Parallel() - code, err := Compile(".", InputEager) + code, err := Compile(".") if err != nil { t.Fatal(err) } diff --git a/jqresult/protojson.go b/jqresult/protojson.go index 7aded0e..4c2926d 100644 --- a/jqresult/protojson.go +++ b/jqresult/protojson.go @@ -4,9 +4,7 @@ import ( "bytes" "encoding/json" - "cloud.google.com/go/spanner" sppb "cloud.google.com/go/spanner/apiv1/spannerpb" - "github.com/apstndb/execspansql/resultset" "github.com/apstndb/spaniter" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -51,12 +49,3 @@ func StatsMapFromResult(result spaniter.RowIteratorResult) (map[string]any, erro func ResultSetMap(rs *sppb.ResultSet) (map[string]any, error) { return ProtoToMap(rs) } - -// ResultSetMapFromRowIterator materializes rowIter and returns the jq input map. -func ResultSetMapFromRowIterator(rowIter *spanner.RowIterator, redact bool, opts ...spaniter.Option) (map[string]any, error) { - rs, err := resultset.Materialize(rowIter, redact, opts...) - if err != nil { - return nil, err - } - return ResultSetMap(rs) -} diff --git a/jqresult/protojson_test.go b/jqresult/protojson_test.go index e9deae0..e65350d 100644 --- a/jqresult/protojson_test.go +++ b/jqresult/protojson_test.go @@ -47,15 +47,6 @@ func TestMetadataMapFromMetadataNil(t *testing.T) { } } -func TestResultSetMapFromRowIteratorNil(t *testing.T) { - t.Parallel() - - _, err := ResultSetMapFromRowIterator(nil, false) - if err == nil { - t.Fatal("error = nil, want nil row iterator error") - } -} - // Both eager result materialization and lazy stats conversion must preserve // arbitrary plan/stat contents, independently of RPC query mode support. func TestPlanAndStatsAcrossResultPaths(t *testing.T) { diff --git a/jqresult/rowiter.go b/jqresult/rowiter.go index d2741e6..db84661 100644 --- a/jqresult/rowiter.go +++ b/jqresult/rowiter.go @@ -52,14 +52,6 @@ func (r *RowIter) ensureSeq() { r.pull, r.stopSeq = spaniter.PullRowIteratorSeq(r.rowIter, spaniter.WithResult(&r.result)) } -// Prime reads the first row (or iterator.Done) so metadata is populated. -// The first row is buffered for the next Next call when present. -func (r *RowIter) Prime() error { - unlock := r.lockIO() - defer unlock() - return r.primeUnlocked() -} - func (r *RowIter) primeUnlocked() error { if r.primed || r.stopped { return nil @@ -94,12 +86,6 @@ func (r *RowIter) nextRow() (*spanner.Row, error) { return row, nil } -func (r *RowIter) Next() (any, bool) { - unlock := r.lockIO() - defer unlock() - return r.nextUnlocked() -} - func (r *RowIter) nextUnlocked() (any, bool) { if r.redact || r.stopped { return nil, false @@ -118,13 +104,6 @@ func (r *RowIter) nextUnlocked() (any, bool) { return v, true } -// Drain exhausts the Spanner row iterator. When redact is false, drained rows are returned. -func (r *RowIter) Drain() ([]any, error) { - unlock := r.lockIO() - defer unlock() - return r.drainUnlocked() -} - func (r *RowIter) drainUnlocked() ([]any, error) { if r.stopped { return nil, nil diff --git a/main.go b/main.go index eed76f1..f2445e7 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "log" "os" "os/signal" + "syscall" "encoding/json" @@ -37,22 +38,46 @@ const ( logGrpcModeOff = "off" logGrpcModeMetadata = "metadata" logGrpcModePayload = "payload" + + exitFailure = 1 + exitUsage = 2 + exitOutputAfterCommit = 3 ) +// version is set by GoReleaser via -X main.version={{.Version}}. +var version = "dev" + func main() { // Keep process-wide signals and exit handling outside the testable runner. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + log.SetFlags(0) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { <-ctx.Done() - stop() // A second interrupt uses the default handler. + stop() // A second interrupt or SIGTERM uses the default handler. }() if err := runCLI(ctx, os.Args[1:]); err != nil { - log.Fatalln(err) + fmt.Fprintln(os.Stderr, err) + os.Exit(exitStatus(err)) + } +} + +func exitStatus(err error) int { + if err == nil { + return 0 + } + if errors.Is(err, errOutputAfterCommit) { + return exitOutputAfterCommit + } + var parseErr *kong.ParseError + if errors.As(err, &parseErr) { + return exitUsage } + return exitFailure } type opts struct { + Version versionFlag `name:"version" help:"Show version and exit."` Database string `arg:"" required:"" help:"ID or fully qualified resource name of the database."` Sql string `name:"sql" xor:"sql" required:"" help:"SQL query text; exclusive with --sql-file."` SqlFile string `name:"sql-file" xor:"sql" required:"" help:"File name contains SQL query; exclusive with --sql"` @@ -62,7 +87,7 @@ type opts struct { QueryMode string `name:"query-mode" enum:"NORMAL,PLAN,PROFILE,WITH_PLAN_AND_STATS,WITH_STATS" default:"NORMAL" help:"Query mode: NORMAL, PLAN, PROFILE, WITH_PLAN_AND_STATS, or WITH_STATS."` Priority string `name:"priority" enum:"high,low,medium,unspecified" default:"unspecified" help:"Priority for the execute SQL request."` Format string `name:"format" enum:"json,yaml,experimental_csv" default:"json" help:"Output format of the primary document."` - Output string `name:"output" short:"o" default:"-" help:"Destination of the primary document. Use - for stdout; /dev/stdout and /dev/stderr are mapped in-process."` + Output string `name:"output" short:"o" default:"-" help:"Destination of the primary document. Use - for stdout; /dev/stdout, /dev/stderr, and /dev/null are mapped in-process."` PlanOutput string `name:"plan-output" help:"Write the query-plan artifact here and strip stats.queryPlan from the primary document. Enables split mode."` PlanFormat string `name:"plan-format" help:"Format of the plan artifact: json, yaml, text, dot, mermaid, d2, svg, or png. Defaults to --format when that is json or yaml, otherwise json. Requires --plan-output."` PlanTextStyle string `name:"plan-text-style" help:"Text plan style: current, traditional, or compact. Defaults to current. Requires --plan-format=text." group:"Plan rendering"` @@ -141,12 +166,32 @@ func (o opts) mergedParams() (map[string]string, error) { return params.MergeParams(fileParams, cliParams), nil } -var errHelpRequested = errors.New("help requested") +var ( + errHelpRequested = errors.New("help requested") + errVersionRequested = errors.New("version requested") +) + +// versionFlag prints the build version and stops parsing, like --help. +type versionFlag bool + +func (v versionFlag) Decode(ctx *kong.DecodeContext) error { return nil } +func (v versionFlag) IsBool() bool { return true } +func (v versionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { + ver := vars["version"] + if ver == "" { + ver = "dev" + } + if _, err := fmt.Fprintln(app.Stdout, ver); err != nil { + return err + } + return errVersionRequested +} func processFlags(args []string) (o opts, err error) { parser, err := kong.New(&o, kong.Name("execspansql"), kong.Description("Yet another gcloud spanner databases execute-sql replacement"), + kong.Vars{"version": version}, kong.Help(func(options kong.HelpOptions, ctx *kong.Context) error { if err := kong.DefaultHelpPrinter(options, ctx); err != nil { return err @@ -160,21 +205,11 @@ func processFlags(args []string) (o opts, err error) { if err != nil { return o, err } - ctx, err := parser.Parse(args) - if errors.Is(err, errHelpRequested) { + _, err = parser.Parse(args) + if errors.Is(err, errHelpRequested) || errors.Is(err, errVersionRequested) { return o, err } if err != nil { - var parseErr *kong.ParseError - if errors.As(err, &parseErr) { - ctx = parseErr.Context - } - if ctx != nil { - prev := parser.Stdout - parser.Stdout = os.Stderr - _ = ctx.PrintUsage(false) - parser.Stdout = prev - } return o, err } return o, nil @@ -203,40 +238,10 @@ func parseTimestampBound(rawReadTimestamp string) (spanner.TimestampBound, error return spanner.ReadTimestamp(parsed), nil } -func stripLeadingComments(query string) string { - for { - query = strings.TrimLeft(query, " \t\r\n") - if query == "" { - return "" - } - - switch { - case strings.HasPrefix(query, "--"): - if i := strings.IndexAny(query[2:], "\r\n"); i >= 0 { - query = query[2+i+1:] - continue - } - return "" - case strings.HasPrefix(query, "#"): - if i := strings.IndexAny(query[1:], "\r\n"); i >= 0 { - query = query[1+i+1:] - continue - } - return "" - case strings.HasPrefix(query, "/*"): - if i := strings.Index(query[2:], "*/"); i >= 0 { - query = query[i+4:] - continue - } - return "" - default: - return query - } - } -} - func isReadWriteStatement(query string) bool { - return stmtkind.IsDMLLexical(stripLeadingComments(query)) + // stmtkind.IsDMLLexical uses the memefish lexer, which already skips + // leading line/block comments and hints before classifying DML. + return stmtkind.IsDMLLexical(query) } func queryModeForQuery(query string, enablePartitionedDML bool, tb spanner.TimestampBound) queryMode { @@ -404,7 +409,7 @@ func spaniterStatsOpts(mode queryMode, opts spanner.QueryOptions) []spaniter.Opt // dial options that bypass application-default credentials). func runCLI(ctx context.Context, args []string, clientOptions ...option.ClientOption) (err error) { o, err := processFlags(args) - if errors.Is(err, errHelpRequested) { + if errors.Is(err, errHelpRequested) || errors.Is(err, errVersionRequested) { return nil } if err != nil { @@ -421,6 +426,7 @@ func runCLI(ctx context.Context, args []string, clientOptions ...option.ClientOp return err } defer sinks.Abort() + afterOutputSinksOpen(sinks) authOpts, err := maybeAuthPreflight(ctx, o, clientOptions, newReauthHooks()) if err != nil { diff --git a/output.go b/output.go index ec94bea..a5536a3 100644 --- a/output.go +++ b/output.go @@ -22,6 +22,7 @@ const ( destStdoutDash = "-" destDevStdout = "/dev/stdout" destDevStderr = "/dev/stderr" + destDevNull = "/dev/null" planModesHelp = "PLAN, PROFILE, or WITH_PLAN_AND_STATS" planFormatHelp = "json, yaml, text, dot, mermaid, d2, svg, or png" @@ -48,6 +49,7 @@ const ( destKindStdout destKind = iota destKindStderr destKindFile + destKindDiscard ) type resolvedDest struct { @@ -69,6 +71,10 @@ func resolveDestination(raw string) resolvedDest { return resolvedDest{kind: destKindStdout, raw: raw} case destDevStderr: return resolvedDest{kind: destKindStderr, raw: raw} + case destDevNull: + // Literal like /dev/stdout: mapped in-process so it works on Windows + // and is not rejected as a character device. + return resolvedDest{kind: destKindDiscard, raw: raw} default: abs, err := filepath.Abs(filepath.Clean(raw)) if err != nil { @@ -84,6 +90,8 @@ func destLabel(d resolvedDest) string { return "stdout" case destKindStderr: return "stderr" + case destKindDiscard: + return destDevNull default: if d.abs != "" { return d.abs @@ -315,51 +323,119 @@ func validateDestinations(o opts) error { if err := checkAlias("--output", primaryRaw, primary); err != nil { return err } + if err := validateFileDestination("--output", primaryRaw, primary); err != nil { + return err + } } if plan != nil { if err := checkAlias("--plan-output", o.PlanOutput, *plan); err != nil { return err } + if err := validateFileDestination("--plan-output", o.PlanOutput, *plan); err != nil { + return err + } } return nil } -func fileIdentity(path string) (abs string, info os.FileInfo, exists bool, err error) { - abs, err = filepath.Abs(filepath.Clean(path)) - if err != nil { - return "", nil, false, err +func validateFileDestination(flagName, destRaw string, d resolvedDest) error { + if d.kind != destKindFile { + return nil + } + path := destRaw + if d.abs != "" { + path = d.abs } - info, statErr := os.Stat(path) - if statErr != nil { - if errors.Is(statErr, os.ErrNotExist) { - return abs, nil, false, nil + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil } - return abs, nil, false, statErr + return fmt.Errorf("%s: %w", flagName, err) } - if eval, evalErr := filepath.EvalSymlinks(path); evalErr == nil { - if evalAbs, absErr := filepath.Abs(eval); absErr == nil { - abs = evalAbs + if info.IsDir() { + return fmt.Errorf("%s: %s is a directory", flagName, destRaw) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s: %s is not a regular file", flagName, destRaw) + } + return nil +} + +// canonicalOutputPath resolves path for collision checks and temp-file +// placement. Existing targets are EvalSymlinks'd. Missing targets walk up to +// the deepest existing ancestor, resolve that, then rejoin the missing leaf so +// --output=real/new.json and --plan-output=alias/new.json collide when alias +// points at real even if new.json does not exist yet. +func canonicalOutputPath(path string) (string, error) { + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + existing, leaf, err := deepestExistingAncestor(abs) + if err != nil { + return "", err + } + resolved := existing + if eval, evalErr := filepath.EvalSymlinks(existing); evalErr == nil { + resolved = eval + } + if resolvedAbs, absErr := filepath.Abs(resolved); absErr == nil { + resolved = resolvedAbs + } + if leaf == "" { + return resolved, nil + } + return filepath.Join(resolved, leaf), nil +} + +func deepestExistingAncestor(abs string) (existing, leaf string, err error) { + var missing []string + cur := abs + for { + _, statErr := os.Lstat(cur) + if statErr == nil { + return cur, filepath.Join(missing...), nil + } + if !errors.Is(statErr, os.ErrNotExist) { + return "", "", statErr + } + parent := filepath.Dir(cur) + if parent == cur { + return abs, "", nil } + missing = append([]string{filepath.Base(cur)}, missing...) + cur = parent } - return abs, info, true, nil } func sameOutputFile(a, b string) (bool, error) { - absA, infoA, existsA, err := fileIdentity(a) + canonA, err := canonicalOutputPath(a) if err != nil { return false, err } - absB, infoB, existsB, err := fileIdentity(b) + canonB, err := canonicalOutputPath(b) if err != nil { return false, err } - if absA == absB { + if canonA == canonB { return true, nil } - if existsA && existsB { - return os.SameFile(infoA, infoB), nil + infoA, errA := os.Stat(a) + if errA != nil { + if !errors.Is(errA, os.ErrNotExist) { + return false, errA + } + return false, nil + } + infoB, errB := os.Stat(b) + if errB != nil { + if !errors.Is(errB, os.ErrNotExist) { + return false, errB + } + return false, nil } - return false, nil + return os.SameFile(infoA, infoB), nil } // fileSink is a regular-file destination written via a sibling temp file. @@ -384,6 +460,11 @@ type outputSinks struct { done bool } +// afterOutputSinksOpen is invoked after destinations are reserved and before +// authentication. Tests replace it to simulate a publication race; production +// keeps the no-op. +var afterOutputSinksOpen = func(*outputSinks) {} + func newOutputSinks(o opts) (*outputSinks, error) { if err := validateDestinations(o); err != nil { return nil, err @@ -422,19 +503,23 @@ func openDestination(d resolvedDest) (io.Writer, *fileSink, error) { return os.Stdout, nil, nil case destKindStderr: return os.Stderr, nil, nil + case destKindDiscard: + return io.Discard, nil, nil case destKindFile: - dir := filepath.Dir(d.abs) - tmp, err := os.CreateTemp(dir, ".execspansql-*.tmp") + canonical, err := canonicalOutputPath(d.raw) if err != nil { return nil, nil, err } - if err := tmp.Chmod(0o600); err != nil { - name := tmp.Name() - _ = tmp.Close() - _ = os.Remove(name) + // CreateTemp already uses 0600. The published file keeps that mode + // even when replacing a more permissive target: result rows may be + // sensitive. Place the temp beside the resolved path so a symlinked + // parent does not write into a different directory than Finish. + dir := filepath.Dir(canonical) + tmp, err := os.CreateTemp(dir, ".execspansql-*.tmp") + if err != nil { return nil, nil, err } - return tmp, &fileSink{file: tmp, final: d.abs}, nil + return tmp, &fileSink{file: tmp, final: canonical}, nil default: return nil, nil, fmt.Errorf("unknown destination kind") } @@ -527,11 +612,13 @@ func (s *outputSinks) Finish(workErr error) error { return nil } +var errOutputAfterCommit = errors.New("output failed after the statement was committed; this is not a rollback and the SQL is not replayed") + func wrapCommittedOutputError(err error) error { if err == nil { return nil } - return fmt.Errorf("output failed after the statement was committed; this is not a rollback and the SQL is not replayed: %w", err) + return fmt.Errorf("%w: %w", errOutputAfterCommit, err) } func hasUsableQueryPlan(stats *sppb.ResultSetStats) bool { diff --git a/output_test.go b/output_test.go index 92e8692..e02bea6 100644 --- a/output_test.go +++ b/output_test.go @@ -24,6 +24,7 @@ func TestResolveDestinationStdoutSpellings(t *testing.T) { {raw: "-", kind: destKindStdout}, {raw: "/dev/stdout", kind: destKindStdout}, {raw: "/dev/stderr", kind: destKindStderr}, + {raw: "/dev/null", kind: destKindDiscard}, {raw: "plan.json", kind: destKindFile}, } for _, tt := range tests { @@ -95,6 +96,14 @@ func TestValidateDestinationsStdoutCollision(t *testing.T) { name: "discard_allows_plan_on_stdout", o: opts{Output: "-", PlanOutput: "-", DiscardResults: true}, }, + { + name: "both_dev_null_ok", + o: opts{Output: "/dev/null", PlanOutput: "/dev/null"}, + }, + { + name: "stdout_and_dev_null_ok", + o: opts{Output: "-", PlanOutput: "/dev/null"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -161,6 +170,74 @@ func TestValidateDestinationsSymlinkAndHardLink(t *testing.T) { } } +func TestValidateDestinationsSymlinkedParentNewFile(t *testing.T) { + t.Parallel() + + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(realDir, alias); err != nil { + t.Fatal(err) + } + + err := validateDestinations(opts{ + Output: filepath.Join(realDir, "new.json"), + PlanOutput: filepath.Join(alias, "new.json"), + }) + if err == nil || !strings.Contains(err.Error(), "same file") { + t.Fatalf("aliased parent new file: error = %v, want same file", err) + } + + err = validateDestinations(opts{ + Output: filepath.Join(realDir, "a.json"), + PlanOutput: filepath.Join(alias, "b.json"), + }) + if err != nil { + t.Fatalf("distinct files under aliased parent: %v", err) + } + + err = validateDestinations(opts{ + Output: filepath.Join(realDir, "missing", "out.json"), + PlanOutput: filepath.Join(alias, "missing", "out.json"), + }) + if err == nil || !strings.Contains(err.Error(), "same file") { + t.Fatalf("missing nested parent: error = %v, want same file", err) + } +} + +func TestValidateDestinationsRejectsNonRegular(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + outdir := filepath.Join(dir, "outdir") + if err := os.Mkdir(outdir, 0o755); err != nil { + t.Fatal(err) + } + err := validateDestinations(opts{Output: outdir}) + if err == nil || !strings.Contains(err.Error(), "--output") || !strings.Contains(err.Error(), "directory") { + t.Fatalf("directory: error = %v, want --output directory", err) + } + + _, err = newOutputSinks(opts{Output: outdir}) + if err == nil || !strings.Contains(err.Error(), "directory") { + t.Fatalf("newOutputSinks directory: error = %v", err) + } + if leftover := listExecspansqlTemps(t, dir); len(leftover) != 0 { + t.Fatalf("directory target created temps: %v", leftover) + } + + if _, err := os.Stat("/dev/zero"); err != nil { + t.Skip("/dev/zero not available") + } + err = validateDestinations(opts{Output: "/dev/zero"}) + if err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("/dev/zero: error = %v, want not a regular file", err) + } +} + func TestValidateDestinationsInputAlias(t *testing.T) { t.Parallel() @@ -367,6 +444,13 @@ func TestOutputSinksPublishAndAbort(t *testing.T) { if string(got) != "new-primary" { t.Fatalf("after publish: %q, want new-primary", got) } + info, err := os.Stat(primary) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("published mode = %o, want 0600", perm) + } }) t.Run("plan_error_publishes_primary", func(t *testing.T) { @@ -403,6 +487,115 @@ func TestOutputSinksPublishAndAbort(t *testing.T) { }) } +func TestRunCLICancelledContextRemovesTemp(t *testing.T) { + startQueryStatsModeServer(t, &queryStatsModeServer{}) + dir := t.TempDir() + out := filepath.Join(dir, "out.json") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := runCLI(ctx, []string{"db", "--project", "p", "--instance", "i", "--sql", "SELECT 1", "-o", out, "--timeout", "5s"}) + if err == nil { + t.Fatal("expected cancellation error") + } + if leftover := listExecspansqlTemps(t, dir); len(leftover) != 0 { + t.Fatalf("temps left after cancel: %v", leftover) + } + if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target exists after cancel: %v", statErr) + } +} + +func TestOutputSinksAbortRemovesTemp(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, "out.json") + s, err := newOutputSinks(opts{Output: target}) + if err != nil { + t.Fatal(err) + } + if temps := listExecspansqlTemps(t, dir); len(temps) != 1 { + t.Fatalf("temps after open = %v, want 1", temps) + } + s.Abort() + if leftover := listExecspansqlTemps(t, dir); len(leftover) != 0 { + t.Fatalf("after abort: %v", leftover) + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target exists after abort: %v", err) + } +} + +func TestOutputSinksDevNullDoesNotCreateFile(t *testing.T) { + t.Parallel() + + s, err := newOutputSinks(opts{Output: "/dev/null"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.primary.Write([]byte("discarded")); err != nil { + t.Fatal(err) + } + s.MarkPrimaryComplete() + if err := s.Finish(nil); err != nil { + t.Fatal(err) + } +} + +func TestOutputSinksPublishThroughSymlinkedParent(t *testing.T) { + t.Parallel() + + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(realDir, alias); err != nil { + t.Fatal(err) + } + + s, err := newOutputSinks(opts{Output: filepath.Join(alias, "out.json")}) + if err != nil { + t.Fatal(err) + } + if temps := listExecspansqlTemps(t, realDir); len(temps) != 1 { + t.Fatalf("temps in resolved dir = %v, want 1", temps) + } + if temps := listExecspansqlTemps(t, alias); len(temps) != 1 { + t.Fatalf("temps visible through alias = %v, want 1", temps) + } + if _, err := s.primary.Write([]byte("DATA")); err != nil { + t.Fatal(err) + } + s.MarkPrimaryComplete() + if err := s.Finish(nil); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(realDir, "out.json")) + if err != nil { + t.Fatal(err) + } + if string(got) != "DATA" { + t.Fatalf("published = %q, want DATA", got) + } +} + +func listExecspansqlTemps(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".execspansql-") && strings.HasSuffix(e.Name(), ".tmp") { + names = append(names, e.Name()) + } + } + return names +} + func TestDiscardResultsProducesNoPrimaryBytes(t *testing.T) { t.Parallel() diff --git a/pdml_query_mode_test.go b/pdml_query_mode_test.go index 0c12ac7..8fd26db 100644 --- a/pdml_query_mode_test.go +++ b/pdml_query_mode_test.go @@ -106,6 +106,30 @@ func setPdmlQueryModeV(t *testing.T, ctx context.Context, client *spanner.Client } } +func captureStderr(t *testing.T, fn func() error) (string, error) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stderr + os.Stderr = w + defer func() { os.Stderr = old }() + + fnErr := fn() + if err := w.Close(); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + return buf.String(), fnErr +} + func captureStdout(t *testing.T, fn func() error) (string, error) { t.Helper() r, w, err := os.Pipe() diff --git a/yaml_golden_test.go b/yaml_golden_test.go index de5d886..98417e7 100644 --- a/yaml_golden_test.go +++ b/yaml_golden_test.go @@ -12,7 +12,7 @@ import ( ) func encodeResultSetYAML(filter string, rs *sppb.ResultSet) ([]byte, error) { - code, err := jqresult.Compile(filter, jqresult.InputEager) + code, err := jqresult.Compile(filter) if err != nil { return nil, err }