Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 .`
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ build:
go build -v ./...

lint:
golangci-lint run ./...
GOTOOLCHAIN=go1.25.13 golangci-lint run ./...

test:
go test -v ./...
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.

```
Expand Down Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
59 changes: 58 additions & 1 deletion command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down
16 changes: 12 additions & 4 deletions execution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
1 change: 1 addition & 0 deletions flags_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Expand Down
24 changes: 12 additions & 12 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading