diff --git a/NOTICE b/NOTICE index dd1e4c0c1b6..8ef4a7a1bd0 100644 --- a/NOTICE +++ b/NOTICE @@ -111,6 +111,10 @@ golang.org/x/sys - https://github.com/golang/sys Copyright 2009 The Go Authors. License - https://github.com/golang/sys/blob/master/LICENSE +golang.org/x/term - https://github.com/golang/term +Copyright 2009 The Go Authors. +License - https://github.com/golang/term/blob/master/LICENSE + golang.org/x/text - https://github.com/golang/text Copyright 2009 The Go Authors. License - https://github.com/golang/text/blob/master/LICENSE diff --git a/acceptance/pipelines/show/basic/out.test.toml b/acceptance/pipelines/show/basic/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/pipelines/show/basic/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/pipelines/show/basic/output.txt b/acceptance/pipelines/show/basic/output.txt new file mode 100644 index 00000000000..f3c173df645 --- /dev/null +++ b/acceptance/pipelines/show/basic/output.txt @@ -0,0 +1,57 @@ + +=== table output + +>>> [CLI] pipelines show main.default.people +id name +-- ----- +1 alice +2 bob + +2 rows + +=== json output + +>>> [CLI] pipelines show main.default.people --output json +[ + { + "id": "1", + "name": "alice" + }, + { + "id": "2", + "name": "bob" + } +] + +=== invalid table name + +>>> [CLI] pipelines show people +Error: invalid table name "people": expected catalog.schema.table or schema.table + +=== wire SQL pins quoting and default limit + +>>> print_requests.py //api/2.0/sql/statements +{ + "method": "POST", + "path": "/api/2.0/sql/statements", + "body": { + "disposition": "INLINE", + "format": "JSON_ARRAY", + "on_wait_timeout": "CONTINUE", + "statement": "SELECT * FROM `main`.`default`.`people` LIMIT 100", + "wait_timeout": "10s", + "warehouse_id": "wh-test" + } +} +{ + "method": "POST", + "path": "/api/2.0/sql/statements", + "body": { + "disposition": "INLINE", + "format": "JSON_ARRAY", + "on_wait_timeout": "CONTINUE", + "statement": "SELECT * FROM `main`.`default`.`people` LIMIT 100", + "wait_timeout": "10s", + "warehouse_id": "wh-test" + } +} diff --git a/acceptance/pipelines/show/basic/script b/acceptance/pipelines/show/basic/script new file mode 100644 index 00000000000..04f40545d6c --- /dev/null +++ b/acceptance/pipelines/show/basic/script @@ -0,0 +1,11 @@ +title "table output\n" +trace $CLI pipelines show main.default.people + +title "json output\n" +trace $CLI pipelines show main.default.people --output json + +title "invalid table name\n" +musterr trace $CLI pipelines show people + +title "wire SQL pins quoting and default limit\n" +trace print_requests.py //api/2.0/sql/statements diff --git a/acceptance/pipelines/show/basic/test.toml b/acceptance/pipelines/show/basic/test.toml new file mode 100644 index 00000000000..ea5249e4d81 --- /dev/null +++ b/acceptance/pipelines/show/basic/test.toml @@ -0,0 +1,21 @@ +RecordRequests = true + +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = [] + +[Env] +DATABRICKS_WAREHOUSE_ID = "wh-test" + +# The testserver's SQL Statement Execution API is matcher-driven and fails +# unmatched statements by default; stub a canned result so the golden shows real +# columns and rows. The SDK POSTs to the path without a trailing slash. +[[Server]] +Pattern = "POST /api/2.0/sql/statements" +Response.Body = ''' +{ + "statement_id": "s1", + "status": {"state": "SUCCEEDED"}, + "manifest": {"schema": {"columns": [{"name": "id"}, {"name": "name"}]}, "total_chunk_count": 1}, + "result": {"data_array": [["1", "alice"], ["2", "bob"]]} +} +''' diff --git a/acceptance/pipelines/show/help/out.test.toml b/acceptance/pipelines/show/help/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/pipelines/show/help/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/pipelines/show/help/output.txt b/acceptance/pipelines/show/help/output.txt new file mode 100644 index 00000000000..05d8a315bb5 --- /dev/null +++ b/acceptance/pipelines/show/help/output.txt @@ -0,0 +1,22 @@ + +=== help +>>> [CLI] pipelines show --help +Preview a table's columns and sample rows on a SQL warehouse. + +TABLE is a fully-qualified name: catalog.schema.table (Unity Catalog) or +schema.table (legacy Hive metastore). + +Usage: + databricks pipelines show TABLE [flags] + +Flags: + -h, --help help for show + -n, --limit int Maximum number of rows to fetch. (default 100) + --warehouse-id string SQL warehouse to run the query on. Defaults to DATABRICKS_WAREHOUSE_ID or a workspace default. + +Global Flags: + --debug enable debug logging + -o, --output type output type: text or json (default text) + -p, --profile string ~/.databrickscfg profile + -t, --target string bundle target to use (if applicable) + --var strings set values for variables defined in project config. Example: --var="foo=bar" diff --git a/acceptance/pipelines/show/help/script b/acceptance/pipelines/show/help/script new file mode 100644 index 00000000000..4bf308a2d04 --- /dev/null +++ b/acceptance/pipelines/show/help/script @@ -0,0 +1,4 @@ +# Pin the command tree so any change to the command or its help shows up as a diff here. + +title "help" +trace $CLI pipelines show --help diff --git a/acceptance/pipelines/show/help/test.toml b/acceptance/pipelines/show/help/test.toml new file mode 100644 index 00000000000..49709b578ef --- /dev/null +++ b/acceptance/pipelines/show/help/test.toml @@ -0,0 +1,3 @@ +# --help prints without authenticating, so no server stubs are needed. +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = [] diff --git a/cmd/pipelines/commands.go b/cmd/pipelines/commands.go index da8bc06ace1..6a6b6931313 100644 --- a/cmd/pipelines/commands.go +++ b/cmd/pipelines/commands.go @@ -20,5 +20,6 @@ func Commands() []*cobra.Command { historyCommand(), logsCommand(), openCommand(), + showCommand(), } } diff --git a/cmd/pipelines/show.go b/cmd/pipelines/show.go new file mode 100644 index 00000000000..abeed7aceef --- /dev/null +++ b/cmd/pipelines/show.go @@ -0,0 +1,101 @@ +package pipelines + +import ( + "context" + "errors" + "fmt" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/databrickscfg/cfgpickers" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/sqlexec" + "github.com/databricks/cli/libs/tableprint" + "github.com/databricks/databricks-sdk-go" + "github.com/spf13/cobra" +) + +const defaultLimit = 100 + +func showCommand() *cobra.Command { + var warehouseID string + var limit int + + cmd := &cobra.Command{ + Use: "show TABLE", + Short: "Preview a table's columns and sample rows", + Long: `Preview a table's columns and sample rows on a SQL warehouse. + +TABLE is a fully-qualified name: catalog.schema.table (Unity Catalog) or +schema.table (legacy Hive metastore).`, + Args: root.ExactArgs(1), + PreRunE: root.MustWorkspaceClient, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + w := cmdctx.WorkspaceClient(ctx) + + if limit <= 0 { + return errors.New("--limit must be a positive integer") + } + + quoted, err := quoteTableName(args[0]) + if err != nil { + return err + } + + warehouseID, err = resolveWarehouseID(ctx, w, warehouseID) + if err != nil { + return err + } + + client := sqlexec.New(w.StatementExecution, warehouseID) + statement := fmt.Sprintf("SELECT * FROM %s LIMIT %d", quoted, limit) + result, err := client.Execute(ctx, statement) + if err != nil { + return err + } + + return render(ctx, cmd, result.Columns, result.Rows) + }, + } + + cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse to run the query on. Defaults to DATABRICKS_WAREHOUSE_ID or a workspace default.") + cmd.Flags().IntVarP(&limit, "limit", "n", defaultLimit, "Maximum number of rows to fetch.") + + return cmd +} + +// warehouse to query either --warehouse-id flag, DATABRICKS_WAREHOUSE_ID config, +// or the workspace default warehouse. +func resolveWarehouseID(ctx context.Context, w *databricks.WorkspaceClient, flag string) (string, error) { + if flag != "" { + return flag, nil + } + if w.Config.WarehouseID != "" { + return w.Config.WarehouseID, nil + } + warehouse, err := cfgpickers.GetDefaultWarehouse(ctx, w) + if errors.Is(err, cfgpickers.ErrNoCompatibleWarehouses) { + return "", errors.New("no SQL warehouse available; pass --warehouse-id or set DATABRICKS_WAREHOUSE_ID") + } + if err != nil { + return "", err + } + return warehouse.Id, nil +} + +// writes the result as JSON (--output json) or a terminal-width-aware table. +func render(ctx context.Context, cmd *cobra.Command, columns []string, rows [][]string) error { + out := cmd.OutOrStdout() + switch root.OutputType(cmd) { + case flags.OutputJSON: + return renderJSON(out, columns, rows) + case flags.OutputText: + if len(columns) == 0 { + return nil + } + return tableprint.Render(ctx, out, columns, rows, tableprint.DetectWidth(out)) + default: + return fmt.Errorf("unknown output type %s", root.OutputType(cmd)) + } +} diff --git a/cmd/pipelines/show_identifier.go b/cmd/pipelines/show_identifier.go new file mode 100644 index 00000000000..29b435516bc --- /dev/null +++ b/cmd/pipelines/show_identifier.go @@ -0,0 +1,25 @@ +package pipelines + +import ( + "fmt" + "strings" +) + +// validates a fully-qualified table name and returns it with each +// segment backtick-quoted. Accepts two-part (schema.table, Hive) +// and three-part (catalog.schema.table, Unity Catalog) names. +func quoteTableName(name string) (string, error) { + parts := strings.Split(name, ".") + if len(parts) < 2 || len(parts) > 3 { + return "", fmt.Errorf("invalid table name %q: expected catalog.schema.table or schema.table", name) + } + + quoted := make([]string, len(parts)) + for i, part := range parts { + if part == "" { + return "", fmt.Errorf("invalid table name %q: empty identifier segment", name) + } + quoted[i] = "`" + strings.ReplaceAll(part, "`", "``") + "`" + } + return strings.Join(quoted, "."), nil +} diff --git a/cmd/pipelines/show_identifier_test.go b/cmd/pipelines/show_identifier_test.go new file mode 100644 index 00000000000..9e02420068e --- /dev/null +++ b/cmd/pipelines/show_identifier_test.go @@ -0,0 +1,54 @@ +package pipelines + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuoteTableName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"three part", "main.sales.orders", "`main`.`sales`.`orders`"}, + {"two part", "sales.orders", "`sales`.`orders`"}, + {"hive metastore", "hive_metastore.sales.orders", "`hive_metastore`.`sales`.`orders`"}, + {"leading digit segment", "cat.sch.2020_sales", "`cat`.`sch`.`2020_sales`"}, + {"hyphen segment", "cat.sch.order-items", "`cat`.`sch`.`order-items`"}, + {"space segment", "cat.sch.my table", "`cat`.`sch`.`my table`"}, + {"reserved word segment", "cat.sch.order", "`cat`.`sch`.`order`"}, + {"internal backtick doubled", "cat.sch.wei`rd", "`cat`.`sch`.`wei``rd`"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := quoteTableName(tc.in) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestQuoteTableNameErrors(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"one part", "orders"}, + {"four part", "a.b.c.d"}, + {"empty", ""}, + {"empty middle segment", "cat..table"}, + {"trailing dot", "cat.sch."}, + {"leading dot", ".sch.table"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := quoteTableName(tc.in) + assert.Error(t, err) + }) + } +} diff --git a/cmd/pipelines/show_render.go b/cmd/pipelines/show_render.go new file mode 100644 index 00000000000..f483d59d695 --- /dev/null +++ b/cmd/pipelines/show_render.go @@ -0,0 +1,56 @@ +package pipelines + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +// marshals a result row as a JSON object with keys in column order. +type orderedRow struct { + columns []string + values []string +} + +func (r orderedRow) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + for i, col := range r.columns { + if i > 0 { + buf.WriteByte(',') + } + key, err := json.Marshal(col) + if err != nil { + return nil, err + } + buf.Write(key) + buf.WriteByte(':') + var val []byte + if i < len(r.values) { + val, err = json.Marshal(r.values[i]) + } else { + val = []byte("null") + } + if err != nil { + return nil, err + } + buf.Write(val) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// writes rows as an indented JSON array of column-ordered objects. +func renderJSON(w io.Writer, columns []string, rows [][]string) error { + objects := make([]orderedRow, len(rows)) + for i, row := range rows { + objects[i] = orderedRow{columns: columns, values: row} + } + output, err := json.MarshalIndent(objects, "", " ") + if err != nil { + return fmt.Errorf("marshal results: %w", err) + } + fmt.Fprintf(w, "%s\n", output) + return nil +} diff --git a/cmd/pipelines/show_render_test.go b/cmd/pipelines/show_render_test.go new file mode 100644 index 00000000000..7987951b8d2 --- /dev/null +++ b/cmd/pipelines/show_render_test.go @@ -0,0 +1,37 @@ +package pipelines + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderJSON(t *testing.T) { + var buf bytes.Buffer + err := renderJSON(&buf, []string{"id", "name"}, [][]string{{"1", "alice"}, {"2", "bob"}}) + require.NoError(t, err) + assert.JSONEq(t, `[{"id":"1","name":"alice"},{"id":"2","name":"bob"}]`, buf.String()) +} + +func TestRenderJSONEmpty(t *testing.T) { + var buf bytes.Buffer + err := renderJSON(&buf, []string{"id"}, nil) + require.NoError(t, err) + assert.JSONEq(t, `[]`, buf.String()) +} + +func TestRenderJSONPreservesColumnOrder(t *testing.T) { + var buf bytes.Buffer + err := renderJSON(&buf, []string{"name", "id"}, [][]string{{"alice", "1"}}) + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"name\": \"alice\",\n \"id\": \"1\"\n }\n]\n", buf.String()) +} + +func TestRenderJSONEscapesValues(t *testing.T) { + var buf bytes.Buffer + err := renderJSON(&buf, []string{"c"}, [][]string{{`a"b c`}}) + require.NoError(t, err) + assert.JSONEq(t, `[{"c":"a\"b\tc"}]`, buf.String()) +} diff --git a/cmd/pipelines/show_test.go b/cmd/pipelines/show_test.go new file mode 100644 index 00000000000..26a33aea937 --- /dev/null +++ b/cmd/pipelines/show_test.go @@ -0,0 +1,87 @@ +package pipelines + +import ( + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/sqlexec" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/config" + mocksql "github.com/databricks/databricks-sdk-go/experimental/mocks/service/sql" + "github.com/databricks/databricks-sdk-go/service/sql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestResolveWarehouseIDFlagWins(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + w := &databricks.WorkspaceClient{Config: &config.Config{WarehouseID: "from-config"}} + id, err := resolveWarehouseID(ctx, w, "from-flag") + require.NoError(t, err) + assert.Equal(t, "from-flag", id) +} + +func TestResolveWarehouseIDFallsBackToConfig(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + w := &databricks.WorkspaceClient{Config: &config.Config{WarehouseID: "from-config"}} + id, err := resolveWarehouseID(ctx, w, "") + require.NoError(t, err) + assert.Equal(t, "from-config", id) +} + +func TestResolveWarehouseIDUsesDefaultWarehouse(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + mockWH := mocksql.NewMockWarehousesInterface(t) + mockWH.EXPECT().Get(mock.Anything, sql.GetWarehouseRequest{Id: "default"}). + Return(&sql.GetWarehouseResponse{Id: "wh-default"}, nil) + + w := &databricks.WorkspaceClient{Config: &config.Config{}, Warehouses: mockWH} + id, err := resolveWarehouseID(ctx, w, "") + require.NoError(t, err) + assert.Equal(t, "wh-default", id) +} + +func TestExecuteSelectQuotesAndLimits(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + mockAPI := mocksql.NewMockStatementExecutionInterface(t) + + mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req sql.ExecuteStatementRequest) bool { + return req.WarehouseId == "wh-1" && + req.Statement == "SELECT * FROM `main`.`sales`.`orders` LIMIT 25" + })).Return(&sql.StatementResponse{ + StatementId: "s1", + Status: &sql.StatementStatus{State: sql.StatementStateSucceeded}, + Manifest: &sql.ResultManifest{Schema: &sql.ResultSchema{Columns: []sql.ColumnInfo{{Name: "id"}}}}, + Result: &sql.ResultData{DataArray: [][]string{{"1"}}}, + }, nil) + + quoted, err := quoteTableName("main.sales.orders") + require.NoError(t, err) + client := sqlexec.New(mockAPI, "wh-1") + result, err := client.Execute(ctx, "SELECT * FROM "+quoted+" LIMIT 25") + require.NoError(t, err) + assert.Equal(t, []string{"id"}, result.Columns) + assert.Equal(t, [][]string{{"1"}}, result.Rows) +} + +func TestExecuteSelectSurfacesStatementError(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + mockAPI := mocksql.NewMockStatementExecutionInterface(t) + + mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.Anything).Return(&sql.StatementResponse{ + StatementId: "s1", + Status: &sql.StatementStatus{ + State: sql.StatementStateFailed, + Error: &sql.ServiceError{ErrorCode: sql.ServiceErrorCodeBadRequest, Message: "TABLE_OR_VIEW_NOT_FOUND"}, + }, + }, nil) + + client := sqlexec.New(mockAPI, "wh-1") + _, err := client.Execute(ctx, "SELECT * FROM `x`.`y`.`z` LIMIT 25") + require.Error(t, err) + + var stmtErr *sqlexec.StatementError + require.ErrorAs(t, err, &stmtErr) + assert.Equal(t, sql.ServiceErrorCodeBadRequest, stmtErr.Code) +} diff --git a/go.mod b/go.mod index 7d924904c1f..1f037cd2bb3 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( golang.org/x/oauth2 v0.36.0 // BSD-3-Clause golang.org/x/sync v0.22.0 // BSD-3-Clause golang.org/x/sys v0.47.0 // BSD-3-Clause + golang.org/x/term v0.44.0 // BSD-3-Clause golang.org/x/text v0.39.0 // BSD-3-Clause gopkg.in/ini.v1 v1.67.3 // Apache-2.0 ) diff --git a/libs/tableprint/tableprint.go b/libs/tableprint/tableprint.go new file mode 100644 index 00000000000..0d271c1588d --- /dev/null +++ b/libs/tableprint/tableprint.go @@ -0,0 +1,179 @@ +// Package tableprint renders static, terminal-width-aware text tables. +// It fits columns to the available width, drops trailing columns, +// truncates wide cells, and works identically with an interactive terminal or a pipe. +package tableprint + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "golang.org/x/term" +) + +const ( + maxColumnWidth = 20 + columnSep = " " + ellipsis = "..." + separatorChar = "-" +) + +// writes table (with an "N rows" footer). +// if table is too wide trailing columns are dropped with a "(showing M of N columns)" note. +// cells are truncated to maxColumnWidth. +func Render(ctx context.Context, out io.Writer, columns []string, rows [][]string, width int) error { + if len(columns) == 0 { + return nil + } + n := len(columns) + + colWidth := make([]int, n) + for i, col := range columns { + colWidth[i] = min(cmdio.Width(col), maxColumnWidth) + } + for _, row := range rows { + for i := 0; i < n && i < len(row); i++ { + colWidth[i] = max(colWidth[i], min(cmdio.Width(row[i]), maxColumnWidth)) + } + } + + shown, cropped := fitColumns(colWidth, width) + + widths := append([]int(nil), colWidth[:shown]...) + if cropped { + widths = append(widths, cmdio.Width(ellipsis)) + } + + header := make([]string, 0, len(widths)) + for i := range shown { + header = append(header, truncateToWidth(columns[i], colWidth[i])) + } + if cropped { + header = append(header, cmdio.Faint(ctx, ellipsis)) + } + emitRow(out, header, widths) + + sep := make([]string, len(widths)) + for i, w := range widths { + sep[i] = strings.Repeat(separatorChar, w) + } + emitRow(out, sep, widths) + + for _, row := range rows { + cells := make([]string, 0, len(widths)) + for i := range shown { + cell := "" + if i < len(row) { + cell = truncateToWidth(row[i], colWidth[i]) + } + cells = append(cells, cell) + } + if cropped { + cells = append(cells, cmdio.Faint(ctx, ellipsis)) + } + emitRow(out, cells, widths) + } + + fmt.Fprintln(out) + if cropped { + fmt.Fprintln(out, cmdio.Faint(ctx, fmt.Sprintf("(showing %d of %d columns)", shown, n))) + } + fmt.Fprintf(out, "%d rows\n", len(rows)) + return nil +} + +// emitRow writes one table line. Every cell but the last is padded to its +// column width; the last is left unpadded to avoid trailing whitespace. +func emitRow(out io.Writer, cells []string, widths []int) { + parts := make([]string, len(cells)) + last := len(cells) - 1 + for i, c := range cells { + if i == last { + parts[i] = c + } else { + parts[i] = cmdio.PadRight(c, widths[i]) + } + } + fmt.Fprintln(out, strings.Join(parts, columnSep)) +} + +// figures out how many columns fit within width and whether any +// were dropped. width <= 0 means unlimited +func fitColumns(colWidth []int, width int) (shown int, cropped bool) { + n := len(colWidth) + total := 0 + for i, w := range colWidth { + if i > 0 { + total += cmdio.Width(columnSep) + } + total += w + } + if width <= 0 || total <= width { + return n, false + } + budget := width - cmdio.Width(columnSep) - cmdio.Width(ellipsis) + used := 0 + for i := range colWidth { + add := colWidth[i] + if i > 0 { + add += cmdio.Width(columnSep) + } + if used+add > budget { + s := max(i, 1) + return s, s < n + } + used += add + } + return n, false +} + +// shortens s so its display width does not exceed maxWidth, +// appending "..." to mark truncation. +func truncateToWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if cmdio.Width(s) <= maxWidth { + return s + } + budget := maxWidth - cmdio.Width(ellipsis) + if budget <= 0 { + return cutToWidth(s, maxWidth) + } + return cutToWidth(s, budget) + ellipsis +} + +// cutToWidth returns the longest prefix of s whose display width is <= w +func cutToWidth(s string, w int) string { + var b strings.Builder + used := 0 + for _, r := range s { + rw := cmdio.Width(string(r)) + if used+rw > w { + break + } + b.WriteRune(r) + used += rw + } + return b.String() +} + +// DetectWidth returns the terminal width, or 0 (meaning "no cap") +// when out is not an interactive terminal +func DetectWidth(out io.Writer) int { + f, ok := out.(*os.File) + if !ok { + return 0 + } + if !cmdio.IsOutputTTY(f) { + return 0 + } + width, _, err := term.GetSize(int(f.Fd())) + if err != nil { + return 0 + } + return width +} diff --git a/libs/tableprint/tableprint_test.go b/libs/tableprint/tableprint_test.go new file mode 100644 index 00000000000..fe628c53691 --- /dev/null +++ b/libs/tableprint/tableprint_test.go @@ -0,0 +1,140 @@ +package tableprint + +import ( + "bytes" + "strings" + "testing" + "unicode/utf8" + + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func renderString(t *testing.T, columns []string, rows [][]string, width int) string { + t.Helper() + var buf bytes.Buffer + require.NoError(t, Render(cmdio.MockDiscard(t.Context()), &buf, columns, rows, width)) + return buf.String() +} + +func TestRenderEmptyColumns(t *testing.T) { + assert.Empty(t, renderString(t, nil, nil, 0)) +} + +func TestRenderNoCap(t *testing.T) { + out := renderString(t, []string{"id", "name"}, [][]string{{"1", "alice"}, {"2", "bob"}}, 0) + assert.Equal(t, "id name\n-- -----\n1 alice\n2 bob\n\n2 rows\n", out) +} + +func TestRenderFitsWideBudget(t *testing.T) { + out := renderString(t, []string{"id", "name"}, [][]string{{"1", "alice"}}, 200) + assert.Equal(t, "id name\n-- -----\n1 alice\n\n1 rows\n", out) +} + +func TestRenderExactFitBoundaryNotCropped(t *testing.T) { + out := renderString(t, []string{"id", "name"}, [][]string{{"1", "alice"}}, 9) + assert.NotContains(t, out, ellipsis) + assert.NotContains(t, out, "showing") +} + +func TestRenderCropsColumns(t *testing.T) { + columns := []string{"a", "b", "c", "d", "e"} + out := renderString(t, columns, [][]string{{"1", "2", "3", "4", "5"}}, 10) + assert.Contains(t, out, ellipsis) + assert.Regexp(t, `\(showing \d of 5 columns\)`, out) + assert.Contains(t, out, "1 rows") +} + +func TestRenderCropAlwaysShowsAtLeastOneColumn(t *testing.T) { + out := renderString(t, []string{"a", "b", "c"}, [][]string{{"1", "2", "3"}}, 1) + assert.Contains(t, out, "(showing 1 of 3 columns)") +} + +func TestRenderSingleColumnNarrowNotCropped(t *testing.T) { + out := renderString(t, []string{"name"}, [][]string{{"alice"}}, 4) + assert.NotContains(t, out, ellipsis) + assert.NotContains(t, out, "showing") +} + +func TestRenderCapsColumnWidth(t *testing.T) { + long := strings.Repeat("x", 25) + out := renderString(t, []string{"c"}, [][]string{{long}}, 0) + assert.Contains(t, out, strings.Repeat("x", maxColumnWidth-cmdio.Width(ellipsis))+ellipsis) + assert.NotContains(t, out, long) +} + +func TestRenderCellAtCapNotTruncated(t *testing.T) { + exact := strings.Repeat("x", maxColumnWidth) + out := renderString(t, []string{"c"}, [][]string{{exact}}, 0) + assert.Contains(t, out, exact) + assert.NotContains(t, out, ellipsis) +} + +func TestRenderTruncatesWideGlyphCellSafely(t *testing.T) { + out := renderString(t, []string{"c"}, [][]string{{strings.Repeat("δΈ–", 30)}}, 0) + assert.True(t, utf8.ValidString(out)) + assert.Contains(t, out, ellipsis) +} + +func TestRenderEmptyRows(t *testing.T) { + out := renderString(t, []string{"id", "name"}, nil, 0) + assert.Equal(t, "id name\n-- ----\n\n0 rows\n", out) +} + +func TestRenderBlankValue(t *testing.T) { + out := renderString(t, []string{"a", "b"}, [][]string{{"", "x"}}, 0) + assert.Equal(t, "a b\n- -\n x\n\n1 rows\n", out) +} + +func TestRenderRaggedRow(t *testing.T) { + out := renderString(t, []string{"a", "b"}, [][]string{{"1"}}, 0) + assert.Equal(t, "a b\n- -\n1 \n\n1 rows\n", out) +} + +func TestFitColumns(t *testing.T) { + tests := []struct { + name string + colWidth []int + width int + wantShown int + wantCrop bool + }{ + {"no cap", []int{5, 5, 5}, 0, 3, false}, + {"fits exactly", []int{2, 5}, 9, 2, false}, + {"one over", []int{2, 5}, 8, 1, true}, + {"tiny width shows one", []int{3, 3, 3}, 1, 1, true}, + {"single column never cropped", []int{5}, 4, 1, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + shown, cropped := fitColumns(tc.colWidth, tc.width) + assert.Equal(t, tc.wantShown, shown) + assert.Equal(t, tc.wantCrop, cropped) + }) + } +} + +func TestTruncateToWidth(t *testing.T) { + tests := []struct { + name string + in string + maxWidth int + want string + }{ + {"under", "abc", 10, "abc"}, + {"exact", "abcde", 5, "abcde"}, + {"over", "abcdefgh", 5, "ab..."}, + {"width below ellipsis hard cuts", "abcdef", 2, "ab"}, + {"zero width", "abc", 0, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, truncateToWidth(tc.in, tc.maxWidth)) + }) + } +} + +func TestDetectWidthNonFile(t *testing.T) { + assert.Equal(t, 0, DetectWidth(&bytes.Buffer{})) +}