Skip to content

Commit 42e344b

Browse files
authored
Experimental postgres query (PR 2/4): provisioned + JSON/CSV streaming + types (#5136)
## PR Stack 1. [#5135](#5135) — PR 1: scaffold + autoscaling targeting + text output 2. **PR 2 (this PR)** — [#5136](#5136) — provisioned + JSON/CSV streaming + typed values + `experimental/libs/sqlcli` for output handling 3. [#5138](#5138) — PR 3: multi-input + multi-statement rejection + error formatting 4. [#5143](#5143) — PR 4: cancellation + timeout + TUI Stacked on PR 1. ## Why Two things in this PR. The user-facing one: postgres query learns JSON/CSV streaming and provisioned-instance support. The architectural one: aitools query and postgres query had near-identical output-mode handling (same env var, same flag/env precedence, same threshold). Promote the duplication to a shared `experimental/libs/sqlcli` package before the second consumer ossifies the divergence. ## Changes **Architectural:** `experimental/libs/sqlcli/` is a new package under `experimental/libs/` (not `libs/`) so it inherits the experimental-stability guarantee of its consumers. Exposes: - `sqlcli.EnvOutputFormat`, `sqlcli.StaticTableThreshold` constants. - `sqlcli.Format` typedef + `sqlcli.OutputText/JSON/CSV` consts + `sqlcli.AllFormats`. - `sqlcli.ResolveFormat` — flag > env > default precedence with the explicit-text-on-pipe-is-honoured rule. aitools query migrates to use sqlcli (pure refactor, no behavior change). postgres query was about to add its own copy of all of this; instead it uses sqlcli from day one. **User-facing changes for postgres query:** - `--target my-instance` now resolves a provisioned instance. - `--output json` streams typed values: numbers stay numeric, jsonb stays nested, NaN/Inf/bigints-outside-2^53 become strings. - `--output csv` streams (no buffering). - `DATABRICKS_OUTPUT_FORMAT` honoured. - Auto-fallback to JSON when stdout is piped. - Duplicate column names get deterministic `__N` suffixes with a stderr warning. Also adds `cmdio.IsOutputTTY` (a small public wrapper around the existing private `isTTY`) so commands can ask "is stdout a terminal?" without folding in `NO_COLOR` / `TERM=dumb` (both of which `cmdio.SupportsColor` ANDs in for the colour-rendering decision). ## Test plan - [x] `go test ./experimental/aitools/... ./experimental/postgres/... ./experimental/libs/...` (unit, sinks, value mapping, format selection, aitools tests still pass after migration) - [x] `go tool ... golangci-lint run ./experimental/...` (0 issues)
1 parent fadaebc commit 42e344b

17 files changed

Lines changed: 1340 additions & 163 deletions

File tree

experimental/aitools/cmd/query.go

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ import (
1414
"github.com/databricks/cli/cmd/root"
1515
"github.com/databricks/cli/experimental/aitools/lib/middlewares"
1616
"github.com/databricks/cli/experimental/aitools/lib/session"
17+
"github.com/databricks/cli/experimental/libs/sqlcli"
1718
"github.com/databricks/cli/libs/cmdctx"
1819
"github.com/databricks/cli/libs/cmdio"
19-
"github.com/databricks/cli/libs/env"
20-
"github.com/databricks/cli/libs/flags"
2120
"github.com/databricks/cli/libs/log"
2221
"github.com/databricks/databricks-sdk-go/service/sql"
2322
"github.com/spf13/cobra"
@@ -35,16 +34,6 @@ const (
3534

3635
// cancelTimeout is how long to wait for server-side cancellation.
3736
cancelTimeout = 10 * time.Second
38-
39-
// staticTableThreshold is the maximum number of rows rendered as a static table.
40-
// Beyond this, an interactive scrollable table is used.
41-
staticTableThreshold = 30
42-
43-
// outputCSV is the csv output format, supported only by the query command.
44-
outputCSV = "csv"
45-
46-
// envOutputFormat matches the env var name in cmd/root/io.go.
47-
envOutputFormat = "DATABRICKS_OUTPUT_FORMAT"
4837
)
4938

5039
type queryOutputMode int
@@ -55,8 +44,13 @@ const (
5544
queryOutputModeInteractiveTable
5645
)
5746

58-
func selectQueryOutputMode(outputType flags.Output, stdoutInteractive, promptSupported bool, rowCount int) queryOutputMode {
59-
if outputType == flags.OutputJSON {
47+
// selectQueryOutputMode picks the rendering mode for a single-query result.
48+
// JSON is the only machine-readable option; static and interactive are
49+
// table variants chosen by row count and TTY capabilities. Sharing only
50+
// the threshold with sqlcli; the three-way decision is aitools-specific
51+
// because the postgres command's renderers have a different shape.
52+
func selectQueryOutputMode(format sqlcli.Format, stdoutInteractive, promptSupported bool, rowCount int) queryOutputMode {
53+
if format == sqlcli.OutputJSON {
6054
return queryOutputModeJSON
6155
}
6256
if !stdoutInteractive {
@@ -67,7 +61,7 @@ func selectQueryOutputMode(outputType flags.Output, stdoutInteractive, promptSup
6761
if !promptSupported {
6862
return queryOutputModeStaticTable
6963
}
70-
if rowCount <= staticTableThreshold {
64+
if rowCount <= sqlcli.StaticTableThreshold {
7165
return queryOutputModeStaticTable
7266
}
7367
return queryOutputModeInteractiveTable
@@ -119,24 +113,15 @@ interactive table browser. Use --output csv to export results as CSV.`,
119113
RunE: func(cmd *cobra.Command, args []string) error {
120114
ctx := cmd.Context()
121115

122-
// Normalize case to match root --output behavior (flags.Output.Set lowercases).
123-
outputFormat = strings.ToLower(outputFormat)
124-
125-
// If --output wasn't explicitly passed, check the env var.
126-
// Invalid env values are silently ignored, matching cmd/root/io.go.
127-
if !cmd.Flag("output").Changed {
128-
if v, ok := env.Lookup(ctx, envOutputFormat); ok {
129-
switch flags.Output(strings.ToLower(v)) {
130-
case flags.OutputText, flags.OutputJSON, outputCSV:
131-
outputFormat = strings.ToLower(v)
132-
}
133-
}
134-
}
135-
136-
switch flags.Output(outputFormat) {
137-
case flags.OutputText, flags.OutputJSON, outputCSV:
138-
default:
139-
return fmt.Errorf("unsupported output format %q, accepted values: text, json, csv", outputFormat)
116+
// Resolve the effective format via sqlcli so the env-var
117+
// precedence and explicit-text-on-pipe handling stays in sync
118+
// across commands. We pass stdoutTTY=true to keep the original
119+
// aitools behavior of not auto-falling-back to JSON here; the
120+
// per-result render mode further down already handles the pipe
121+
// case via selectQueryOutputMode.
122+
format, err := sqlcli.ResolveFormat(ctx, outputFormat, cmd.Flag("output").Changed, true)
123+
if err != nil {
124+
return err
140125
}
141126

142127
sqls, err := resolveSQLs(ctx, cmd, args, filePaths)
@@ -146,7 +131,7 @@ interactive table browser. Use --output csv to export results as CSV.`,
146131

147132
// Reject incompatible flag combinations before any API call so the
148133
// user sees the real error instead of an auth/warehouse failure.
149-
if len(sqls) > 1 && flags.Output(outputFormat) != flags.OutputJSON {
134+
if len(sqls) > 1 && format != sqlcli.OutputJSON {
150135
return fmt.Errorf("multiple queries require --output json (got %q); pass --output json to receive a JSON array of per-statement results", outputFormat)
151136
}
152137

@@ -173,7 +158,7 @@ interactive table browser. Use --output csv to export results as CSV.`,
173158
}
174159

175160
// CSV bypasses the normal output mode selection.
176-
if flags.Output(outputFormat) == outputCSV {
161+
if format == sqlcli.OutputCSV {
177162
if len(columns) == 0 && len(rows) == 0 {
178163
return nil
179164
}
@@ -190,7 +175,7 @@ interactive table browser. Use --output csv to export results as CSV.`,
190175
stdoutInteractive := cmdio.SupportsColor(ctx, cmd.OutOrStdout())
191176
promptSupported := cmdio.IsPromptSupported(ctx)
192177

193-
switch selectQueryOutputMode(flags.Output(outputFormat), stdoutInteractive, promptSupported, len(rows)) {
178+
switch selectQueryOutputMode(format, stdoutInteractive, promptSupported, len(rows)) {
194179
case queryOutputModeJSON:
195180
return renderJSON(cmd.OutOrStdout(), columns, rows)
196181
case queryOutputModeStaticTable:
@@ -206,9 +191,13 @@ interactive table browser. Use --output csv to export results as CSV.`,
206191
cmd.Flags().IntVar(&concurrency, "concurrency", defaultBatchConcurrency, "Maximum in-flight statements when running a batch of queries")
207192
// Local --output flag shadows the root command's persistent --output flag,
208193
// adding csv support for this command only.
209-
cmd.Flags().StringVarP(&outputFormat, "output", "o", string(flags.OutputText), "Output format: text, json, or csv")
194+
cmd.Flags().StringVarP(&outputFormat, "output", "o", string(sqlcli.OutputText), "Output format: text, json, or csv")
210195
cmd.RegisterFlagCompletionFunc("output", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
211-
return []string{string(flags.OutputText), string(flags.OutputJSON), string(outputCSV)}, cobra.ShellCompDirectiveNoFileComp
196+
out := make([]string, len(sqlcli.AllFormats))
197+
for i, f := range sqlcli.AllFormats {
198+
out[i] = string(f)
199+
}
200+
return out, cobra.ShellCompDirectiveNoFileComp
212201
})
213202

214203
return cmd

experimental/aitools/cmd/query_test.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import (
1010
"time"
1111

1212
"github.com/databricks/cli/cmd/root"
13+
"github.com/databricks/cli/experimental/libs/sqlcli"
1314
"github.com/databricks/cli/libs/cmdio"
1415
"github.com/databricks/cli/libs/env"
15-
"github.com/databricks/cli/libs/flags"
1616
mocksql "github.com/databricks/databricks-sdk-go/experimental/mocks/service/sql"
1717
"github.com/databricks/databricks-sdk-go/service/sql"
1818
"github.com/spf13/cobra"
@@ -271,57 +271,57 @@ func TestResolveWarehouseIDWithFlag(t *testing.T) {
271271
func TestSelectQueryOutputMode(t *testing.T) {
272272
tests := []struct {
273273
name string
274-
outputType flags.Output
274+
format sqlcli.Format
275275
stdoutInteractive bool
276276
promptSupported bool
277277
rowCount int
278278
want queryOutputMode
279279
}{
280280
{
281281
name: "json flag always returns json",
282-
outputType: flags.OutputJSON,
282+
format: sqlcli.OutputJSON,
283283
stdoutInteractive: true,
284284
promptSupported: true,
285285
rowCount: 999,
286286
want: queryOutputModeJSON,
287287
},
288288
{
289289
name: "non interactive stdout returns json",
290-
outputType: flags.OutputText,
290+
format: sqlcli.OutputText,
291291
stdoutInteractive: false,
292292
promptSupported: true,
293293
rowCount: 5,
294294
want: queryOutputModeJSON,
295295
},
296296
{
297297
name: "missing stdin interactivity falls back to static table",
298-
outputType: flags.OutputText,
298+
format: sqlcli.OutputText,
299299
stdoutInteractive: true,
300300
promptSupported: false,
301-
rowCount: staticTableThreshold + 10,
301+
rowCount: sqlcli.StaticTableThreshold + 10,
302302
want: queryOutputModeStaticTable,
303303
},
304304
{
305305
name: "small results use static table",
306-
outputType: flags.OutputText,
306+
format: sqlcli.OutputText,
307307
stdoutInteractive: true,
308308
promptSupported: true,
309-
rowCount: staticTableThreshold,
309+
rowCount: sqlcli.StaticTableThreshold,
310310
want: queryOutputModeStaticTable,
311311
},
312312
{
313313
name: "large results use interactive table",
314-
outputType: flags.OutputText,
314+
format: sqlcli.OutputText,
315315
stdoutInteractive: true,
316316
promptSupported: true,
317-
rowCount: staticTableThreshold + 1,
317+
rowCount: sqlcli.StaticTableThreshold + 1,
318318
want: queryOutputModeInteractiveTable,
319319
},
320320
}
321321

322322
for _, tc := range tests {
323323
t.Run(tc.name, func(t *testing.T) {
324-
got := selectQueryOutputMode(tc.outputType, tc.stdoutInteractive, tc.promptSupported, tc.rowCount)
324+
got := selectQueryOutputMode(tc.format, tc.stdoutInteractive, tc.promptSupported, tc.rowCount)
325325
assert.Equal(t, tc.want, got)
326326
})
327327
}

experimental/libs/sqlcli/output.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Package sqlcli holds patterns shared by experimental SQL-running commands
2+
// (currently `experimental aitools tools query` and `experimental postgres
3+
// query`). The package lives under experimental/libs/ rather than libs/ so
4+
// the commands depending on it inherit experimental-stability guarantees:
5+
// when both consumers graduate, this package can be promoted alongside
6+
// (or its API stabilised first).
7+
package sqlcli
8+
9+
import (
10+
"context"
11+
"fmt"
12+
"slices"
13+
"strings"
14+
15+
"github.com/databricks/cli/libs/env"
16+
)
17+
18+
// EnvOutputFormat matches the env var name in cmd/root/io.go.
19+
// Reading it lets pipelines set DATABRICKS_OUTPUT_FORMAT once for all
20+
// commands.
21+
const EnvOutputFormat = "DATABRICKS_OUTPUT_FORMAT"
22+
23+
// StaticTableThreshold is the row count above which interactive callers may
24+
// hand off to libs/tableview's scrollable viewer. Smaller results stay in a
25+
// static tabwriter table so they pipe to scripts unchanged.
26+
const StaticTableThreshold = 30
27+
28+
// Format is the user-selectable output shape. Using a string typedef instead
29+
// of an int enum keeps the help text and DATABRICKS_OUTPUT_FORMAT env var
30+
// values self-describing.
31+
type Format string
32+
33+
const (
34+
OutputText Format = "text"
35+
OutputJSON Format = "json"
36+
OutputCSV Format = "csv"
37+
)
38+
39+
// AllFormats is the canonical order shown in completions / help. Sharing
40+
// the slice avoids drift between consumers when a new format is added.
41+
var AllFormats = []Format{OutputText, OutputJSON, OutputCSV}
42+
43+
// ResolveFormat picks the effective output format. Precedence:
44+
//
45+
// 1. The local --output flag if it was explicitly set.
46+
// 2. DATABRICKS_OUTPUT_FORMAT env var if set to a known value (invalid
47+
// values are silently ignored, matching cmd/root/io.go and aitools).
48+
// 3. The flag default (whatever the caller passes as flagValue).
49+
//
50+
// Then the auto-selection rule applies: a *defaulted* text mode on a non-TTY
51+
// stdout falls back to JSON, so scripts piping the output get machine-
52+
// readable output by default. An *explicit* --output text (flag or env) is
53+
// honoured even on a pipe; per AGENTS.md we don't silently override flags
54+
// the user set.
55+
//
56+
// flagSet is true if the user explicitly passed --output on the CLI.
57+
// stdoutTTY is true if stdout is a terminal.
58+
func ResolveFormat(ctx context.Context, flagValue string, flagSet, stdoutTTY bool) (Format, error) {
59+
chosen := Format(strings.ToLower(flagValue))
60+
chosenExplicit := flagSet
61+
62+
if !flagSet {
63+
if v, ok := env.Lookup(ctx, EnvOutputFormat); ok {
64+
candidate := Format(strings.ToLower(v))
65+
if IsKnown(candidate) {
66+
chosen = candidate
67+
chosenExplicit = true
68+
}
69+
}
70+
}
71+
72+
if !IsKnown(chosen) {
73+
return "", fmt.Errorf("unsupported output format %q; expected one of: %s", flagValue, joinFormats(AllFormats))
74+
}
75+
76+
if chosen == OutputText && !stdoutTTY && !chosenExplicit {
77+
return OutputJSON, nil
78+
}
79+
return chosen, nil
80+
}
81+
82+
// IsKnown reports whether f is one of the formats in AllFormats.
83+
func IsKnown(f Format) bool {
84+
return slices.Contains(AllFormats, f)
85+
}
86+
87+
func joinFormats(formats []Format) string {
88+
parts := make([]string, len(formats))
89+
for i, f := range formats {
90+
parts[i] = string(f)
91+
}
92+
return strings.Join(parts, ", ")
93+
}

0 commit comments

Comments
 (0)