Skip to content

Commit f6a7ba3

Browse files
authored
Add output formats, interactive table browser, and multi-chunk results (#4646)
## Why The `aitools tools query` command outputs results as JSON to stderr via `cmdio.LogString`. This means output can't be piped, there's no tabular display for interactive use, and large multi-chunk results are truncated to the first chunk. ## Changes Before: always JSON to stderr, single chunk only. Now: format-aware output routing with interactive table browser. **Output routing:** - Interactive terminal (stdout is TTY): table display - Small results (<=30 rows): static formatted table, prints and exits - Large results (>30 rows): interactive browser with scrolling and search - Non-interactive (piped stdout) or `--output json`: JSON array of objects (backwards compatible) - All output goes to stdout, not stderr **Interactive table browser (`libs/tableview/`):** Reusable shared component using `bubbles/viewport`. Features: - Horizontal + vertical scrolling (arrow keys, pgup/pgdn) - Cursor row highlighting (purple background) - `/` search with yellow match highlighting, `n`/`N` to navigate matches - `g`/`G` for top/bottom, `q` to quit - Footer with row count, scroll position, keybinding hints **Multi-chunk fetching:** Queries returning more data than fits in a single response chunk now fetch all chunks via `GetStatementResultChunkN` and combine them. https://github.com/user-attachments/assets/60b299aa-01ef-4b75-89e0-1f15dac05e42 https://github.com/user-attachments/assets/86747be1-6c94-4399-b0f3-dd2fc25f3df8 ## Test plan - [x] Unit tests for `libs/tableview` (table rendering, search, highlighting) - [x] Unit tests for renderers (JSON format, static table, extractColumns) - [x] Unit tests for `fetchAllRows` (single chunk, multi-chunk, nil result) - [x] All existing query tests pass (polling, cancellation, input resolution) - [x] `make lintfull` clean - [x] `make checks` clean - [x] Manual: interactive table with scrolling and search - [x] Manual: `| cat` produces JSON - [x] Manual: `--output json` forces JSON
1 parent 1d44462 commit f6a7ba3

6 files changed

Lines changed: 776 additions & 63 deletions

File tree

experimental/aitools/cmd/query.go

Lines changed: 78 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package mcp
22

33
import (
44
"context"
5-
"encoding/json"
65
"errors"
76
"fmt"
87
"io"
@@ -17,6 +16,7 @@ import (
1716
"github.com/databricks/cli/experimental/aitools/lib/session"
1817
"github.com/databricks/cli/libs/cmdctx"
1918
"github.com/databricks/cli/libs/cmdio"
19+
"github.com/databricks/cli/libs/flags"
2020
"github.com/databricks/cli/libs/log"
2121
"github.com/databricks/databricks-sdk-go/service/sql"
2222
"github.com/spf13/cobra"
@@ -34,8 +34,38 @@ const (
3434

3535
// cancelTimeout is how long to wait for server-side cancellation.
3636
cancelTimeout = 10 * time.Second
37+
38+
// staticTableThreshold is the maximum number of rows rendered as a static table.
39+
// Beyond this, an interactive scrollable table is used.
40+
staticTableThreshold = 30
41+
)
42+
43+
type queryOutputMode int
44+
45+
const (
46+
queryOutputModeJSON queryOutputMode = iota
47+
queryOutputModeStaticTable
48+
queryOutputModeInteractiveTable
3749
)
3850

51+
func selectQueryOutputMode(outputType flags.Output, stdoutInteractive, promptSupported bool, rowCount int) queryOutputMode {
52+
if outputType == flags.OutputJSON {
53+
return queryOutputModeJSON
54+
}
55+
if !stdoutInteractive {
56+
return queryOutputModeJSON
57+
}
58+
// Interactive table browsing requires keyboard input from stdin.
59+
// If prompts are not supported, prefer static table output instead.
60+
if !promptSupported {
61+
return queryOutputModeStaticTable
62+
}
63+
if rowCount <= staticTableThreshold {
64+
return queryOutputModeStaticTable
65+
}
66+
return queryOutputModeInteractiveTable
67+
}
68+
3969
func newQueryCmd() *cobra.Command {
4070
var warehouseID string
4171
var filePath string
@@ -52,7 +82,8 @@ exists, it is read as a SQL file automatically.
5282
The command auto-detects an available warehouse unless --warehouse is set
5383
or the DATABRICKS_WAREHOUSE_ID environment variable is configured.
5484
55-
Output includes the query results as JSON and row count.`,
85+
Output is JSON in non-interactive contexts. In interactive terminals it renders
86+
tables, and large results open an interactive table browser.`,
5687
Example: ` databricks experimental aitools tools query "SELECT * FROM samples.nyctaxi.trips LIMIT 5"
5788
databricks experimental aitools tools query --warehouse abc123 "SELECT 1"
5889
databricks experimental aitools tools query --file report.sql
@@ -79,13 +110,30 @@ Output includes the query results as JSON and row count.`,
79110
return err
80111
}
81112

82-
output, err := formatQueryResult(resp)
113+
columns := extractColumns(resp.Manifest)
114+
rows, err := fetchAllRows(ctx, w.StatementExecution, resp)
83115
if err != nil {
84116
return err
85117
}
86118

87-
cmdio.LogString(ctx, output)
88-
return nil
119+
if len(columns) == 0 && len(rows) == 0 {
120+
fmt.Fprintln(cmd.OutOrStdout(), "Query executed successfully (no results)")
121+
return nil
122+
}
123+
124+
// Output format depends on stdout capabilities.
125+
// Interactive table browsing also requires prompt-capable stdin.
126+
stdoutInteractive := cmdio.SupportsColor(ctx, cmd.OutOrStdout())
127+
promptSupported := cmdio.IsPromptSupported(ctx)
128+
129+
switch selectQueryOutputMode(root.OutputType(cmd), stdoutInteractive, promptSupported, len(rows)) {
130+
case queryOutputModeJSON:
131+
return renderJSON(cmd.OutOrStdout(), columns, rows)
132+
case queryOutputModeStaticTable:
133+
return renderStaticTable(cmd.OutOrStdout(), columns, rows)
134+
default:
135+
return renderInteractiveTable(cmd.OutOrStdout(), columns, rows)
136+
}
89137
},
90138
}
91139

@@ -274,6 +322,31 @@ func executeAndPoll(ctx context.Context, api sql.StatementExecutionInterface, wa
274322
}
275323
}
276324

325+
// fetchAllRows collects all result rows, fetching additional chunks if needed.
326+
func fetchAllRows(ctx context.Context, api sql.StatementExecutionInterface, resp *sql.StatementResponse) ([][]string, error) {
327+
if resp.Result == nil {
328+
return nil, nil
329+
}
330+
331+
rows := append([][]string{}, resp.Result.DataArray...)
332+
333+
totalChunks := 0
334+
if resp.Manifest != nil {
335+
totalChunks = resp.Manifest.TotalChunkCount
336+
}
337+
338+
for chunk := 1; chunk < totalChunks; chunk++ {
339+
log.Debugf(ctx, "Fetching result chunk %d/%d for statement %s", chunk+1, totalChunks, resp.StatementId)
340+
chunkResp, err := api.GetStatementResultChunkNByStatementIdAndChunkIndex(ctx, resp.StatementId, chunk)
341+
if err != nil {
342+
return nil, fmt.Errorf("fetch result chunk %d: %w", chunk, err)
343+
}
344+
rows = append(rows, chunkResp.DataArray...)
345+
}
346+
347+
return rows, nil
348+
}
349+
277350
// isTerminalState returns true if the statement has reached a final state.
278351
func isTerminalState(status *sql.StatementStatus) bool {
279352
if status == nil {
@@ -334,43 +407,3 @@ func cleanSQL(s string) string {
334407

335408
return strings.Join(lines, "\n")
336409
}
337-
338-
func formatQueryResult(resp *sql.StatementResponse) (string, error) {
339-
var sb strings.Builder
340-
341-
if resp.Manifest == nil || resp.Result == nil {
342-
sb.WriteString("Query executed successfully (no results)\n")
343-
return sb.String(), nil
344-
}
345-
346-
var columns []string
347-
if resp.Manifest.Schema != nil {
348-
for _, col := range resp.Manifest.Schema.Columns {
349-
columns = append(columns, col.Name)
350-
}
351-
}
352-
353-
var rows []map[string]any
354-
if resp.Result.DataArray != nil {
355-
for _, row := range resp.Result.DataArray {
356-
rowMap := make(map[string]any)
357-
for i, val := range row {
358-
if i < len(columns) {
359-
rowMap[columns[i]] = val
360-
}
361-
}
362-
rows = append(rows, rowMap)
363-
}
364-
}
365-
366-
output, err := json.MarshalIndent(rows, "", " ")
367-
if err != nil {
368-
return "", fmt.Errorf("marshal results: %w", err)
369-
}
370-
371-
sb.Write(output)
372-
sb.WriteString("\n\n")
373-
sb.WriteString(fmt.Sprintf("Row count: %d\n", len(rows)))
374-
375-
return sb.String(), nil
376-
}

experimental/aitools/cmd/query_test.go

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/databricks/cli/cmd/root"
1212
"github.com/databricks/cli/libs/cmdio"
13+
"github.com/databricks/cli/libs/flags"
1314
mocksql "github.com/databricks/databricks-sdk-go/experimental/mocks/service/sql"
1415
"github.com/databricks/databricks-sdk-go/service/sql"
1516
"github.com/spf13/cobra"
@@ -159,32 +160,109 @@ func TestResolveWarehouseIDWithFlag(t *testing.T) {
159160
assert.Equal(t, "explicit-id", id)
160161
}
161162

162-
func TestFormatQueryResultNoResults(t *testing.T) {
163+
func TestSelectQueryOutputMode(t *testing.T) {
164+
tests := []struct {
165+
name string
166+
outputType flags.Output
167+
stdoutInteractive bool
168+
promptSupported bool
169+
rowCount int
170+
want queryOutputMode
171+
}{
172+
{
173+
name: "json flag always returns json",
174+
outputType: flags.OutputJSON,
175+
stdoutInteractive: true,
176+
promptSupported: true,
177+
rowCount: 999,
178+
want: queryOutputModeJSON,
179+
},
180+
{
181+
name: "non interactive stdout returns json",
182+
outputType: flags.OutputText,
183+
stdoutInteractive: false,
184+
promptSupported: true,
185+
rowCount: 5,
186+
want: queryOutputModeJSON,
187+
},
188+
{
189+
name: "missing stdin interactivity falls back to static table",
190+
outputType: flags.OutputText,
191+
stdoutInteractive: true,
192+
promptSupported: false,
193+
rowCount: staticTableThreshold + 10,
194+
want: queryOutputModeStaticTable,
195+
},
196+
{
197+
name: "small results use static table",
198+
outputType: flags.OutputText,
199+
stdoutInteractive: true,
200+
promptSupported: true,
201+
rowCount: staticTableThreshold,
202+
want: queryOutputModeStaticTable,
203+
},
204+
{
205+
name: "large results use interactive table",
206+
outputType: flags.OutputText,
207+
stdoutInteractive: true,
208+
promptSupported: true,
209+
rowCount: staticTableThreshold + 1,
210+
want: queryOutputModeInteractiveTable,
211+
},
212+
}
213+
214+
for _, tc := range tests {
215+
t.Run(tc.name, func(t *testing.T) {
216+
got := selectQueryOutputMode(tc.outputType, tc.stdoutInteractive, tc.promptSupported, tc.rowCount)
217+
assert.Equal(t, tc.want, got)
218+
})
219+
}
220+
}
221+
222+
func TestFetchAllRowsSingleChunk(t *testing.T) {
223+
ctx := cmdio.MockDiscard(t.Context())
224+
mockAPI := mocksql.NewMockStatementExecutionInterface(t)
225+
163226
resp := &sql.StatementResponse{
164-
Status: &sql.StatementStatus{State: sql.StatementStateSucceeded},
227+
StatementId: "stmt-1",
228+
Manifest: &sql.ResultManifest{TotalChunkCount: 1},
229+
Result: &sql.ResultData{DataArray: [][]string{{"1", "alice"}, {"2", "bob"}}},
165230
}
166-
output, err := formatQueryResult(resp)
231+
232+
rows, err := fetchAllRows(ctx, mockAPI, resp)
167233
require.NoError(t, err)
168-
assert.Contains(t, output, "no results")
234+
assert.Equal(t, [][]string{{"1", "alice"}, {"2", "bob"}}, rows)
169235
}
170236

171-
func TestFormatQueryResultWithData(t *testing.T) {
237+
func TestFetchAllRowsMultiChunk(t *testing.T) {
238+
ctx := cmdio.MockDiscard(t.Context())
239+
mockAPI := mocksql.NewMockStatementExecutionInterface(t)
240+
172241
resp := &sql.StatementResponse{
173-
Status: &sql.StatementStatus{State: sql.StatementStateSucceeded},
174-
Manifest: &sql.ResultManifest{
175-
Schema: &sql.ResultSchema{
176-
Columns: []sql.ColumnInfo{{Name: "id"}, {Name: "name"}},
177-
},
178-
},
179-
Result: &sql.ResultData{
180-
DataArray: [][]string{{"1", "alice"}, {"2", "bob"}},
181-
},
242+
StatementId: "stmt-1",
243+
Manifest: &sql.ResultManifest{TotalChunkCount: 3},
244+
Result: &sql.ResultData{DataArray: [][]string{{"1", "a"}}},
182245
}
183-
output, err := formatQueryResult(resp)
246+
247+
mockAPI.EXPECT().GetStatementResultChunkNByStatementIdAndChunkIndex(mock.Anything, "stmt-1", 1).
248+
Return(&sql.ResultData{DataArray: [][]string{{"2", "b"}}}, nil).Once()
249+
mockAPI.EXPECT().GetStatementResultChunkNByStatementIdAndChunkIndex(mock.Anything, "stmt-1", 2).
250+
Return(&sql.ResultData{DataArray: [][]string{{"3", "c"}}}, nil).Once()
251+
252+
rows, err := fetchAllRows(ctx, mockAPI, resp)
253+
require.NoError(t, err)
254+
assert.Equal(t, [][]string{{"1", "a"}, {"2", "b"}, {"3", "c"}}, rows)
255+
}
256+
257+
func TestFetchAllRowsNilResult(t *testing.T) {
258+
ctx := cmdio.MockDiscard(t.Context())
259+
mockAPI := mocksql.NewMockStatementExecutionInterface(t)
260+
261+
resp := &sql.StatementResponse{StatementId: "stmt-1"}
262+
263+
rows, err := fetchAllRows(ctx, mockAPI, resp)
184264
require.NoError(t, err)
185-
assert.Contains(t, output, "alice")
186-
assert.Contains(t, output, "bob")
187-
assert.Contains(t, output, "Row count: 2")
265+
assert.Nil(t, rows)
188266
}
189267

190268
func TestIsTerminalState(t *testing.T) {

0 commit comments

Comments
 (0)