From cb79b2131fd8fb8d155f9906a968bde0967a8223 Mon Sep 17 00:00:00 2001 From: Matthias Crauwels Date: Mon, 17 Aug 2026 14:09:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?sql:=20readable=20human=20output=20?= =?UTF-8?q?=E2=80=94=20table=20by=20default,=20--vertical=20and=20\G=20sup?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pscale sql printed each row with Go's %v map formatting, which is unordered and unreadable for wide results (e.g. SHOW REPLICA STATUS). A trailing \G, which mysql users reach for out of habit, was passed to the server verbatim and rejected with a syntax error. - Render human output as a mysql-style table, columns in server order - Add --vertical to print one column per line (mysql \G style) - Strip a trailing \G / \g client terminator before sending the query; \G also enables vertical output, matching the mysql client - Render NULL as NULL instead of Co-Authored-By: Claude Fable 5 --- internal/cmd/sql/render.go | 130 ++++++++++++++++++++++ internal/cmd/sql/render_test.go | 191 ++++++++++++++++++++++++++++++++ internal/cmd/sql/sql.go | 26 +++-- 3 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 internal/cmd/sql/render.go create mode 100644 internal/cmd/sql/render_test.go diff --git a/internal/cmd/sql/render.go b/internal/cmd/sql/render.go new file mode 100644 index 00000000..24d004de --- /dev/null +++ b/internal/cmd/sql/render.go @@ -0,0 +1,130 @@ +package sql + +import ( + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/sqlquery" +) + +// printHumanResult prints a query result for the human output format: rows as +// a mysql-style table, or one column per line when vertical is set. +func printHumanResult(ch *cmdutil.Helper, result *sqlquery.Result, vertical bool) { + if result.RowsAffected > 0 && result.RowCount == 0 { + ch.Printer.Printf("Rows affected: %d\n", result.RowsAffected) + return + } + if result.RowCount > 0 { + var b strings.Builder + if vertical { + renderVertical(&b, result.Columns, result.Rows) + } else { + renderTable(&b, result.Columns, result.Rows) + } + ch.Printer.Printf("%s", b.String()) + } + ch.Printer.Printf("Returned %d row(s)\n", result.RowCount) +} + +// stripVerticalTerminator removes a trailing \G or \g client terminator from a +// query. These are mysql client constructs, not SQL: servers reject them with a +// syntax error. A trailing \G also requests vertical output, mirroring the +// mysql client, so the second return value reports whether it was present. +func stripVerticalTerminator(query string) (string, bool) { + trimmed := strings.TrimRight(query, " \t\r\n") + if strings.HasSuffix(trimmed, `\G`) { + return strings.TrimRight(trimmed[:len(trimmed)-2], " \t\r\n"), true + } + if strings.HasSuffix(trimmed, `\g`) { + return strings.TrimRight(trimmed[:len(trimmed)-2], " \t\r\n"), false + } + return query, false +} + +func formatValue(v any) string { + if v == nil { + return "NULL" + } + return fmt.Sprintf("%v", v) +} + +// cellWidth returns the display width of a value, using its longest line so +// multi-line values (e.g. GTID sets) don't blow up the whole column. +func cellWidth(s string) int { + width := 0 + for _, line := range strings.Split(s, "\n") { + if w := utf8.RuneCountInString(line); w > width { + width = w + } + } + return width +} + +// renderTable writes rows as a mysql-style ASCII table, with columns in the +// order the server returned them. +func renderTable(w io.Writer, columns []string, rows []map[string]any) { + widths := make([]int, len(columns)) + for i, col := range columns { + widths[i] = utf8.RuneCountInString(col) + } + cells := make([][]string, len(rows)) + for r, row := range rows { + cells[r] = make([]string, len(columns)) + for i, col := range columns { + s := formatValue(row[col]) + cells[r][i] = s + if cw := cellWidth(s); cw > widths[i] { + widths[i] = cw + } + } + } + + var border strings.Builder + for _, width := range widths { + border.WriteString("+") + border.WriteString(strings.Repeat("-", width+2)) + } + border.WriteString("+\n") + + fmt.Fprint(w, border.String()) + writeTableRow(w, columns, widths) + fmt.Fprint(w, border.String()) + for _, row := range cells { + writeTableRow(w, row, widths) + } + fmt.Fprint(w, border.String()) +} + +func writeTableRow(w io.Writer, cells []string, widths []int) { + for i, cell := range cells { + // Pad relative to the last line so single-line cells align and + // multi-line cells still close their border cleanly. + lines := strings.Split(cell, "\n") + pad := widths[i] - utf8.RuneCountInString(lines[len(lines)-1]) + if pad < 0 { + pad = 0 + } + fmt.Fprintf(w, "| %s%s ", cell, strings.Repeat(" ", pad)) + } + fmt.Fprint(w, "|\n") +} + +// renderVertical writes rows in the mysql \G style: one column per line, +// column names right-aligned, in the order the server returned them. +func renderVertical(w io.Writer, columns []string, rows []map[string]any) { + nameWidth := 0 + for _, col := range columns { + if l := utf8.RuneCountInString(col); l > nameWidth { + nameWidth = l + } + } + for i, row := range rows { + fmt.Fprintf(w, "*************************** %d. row ***************************\n", i+1) + for _, col := range columns { + fmt.Fprintf(w, "%*s: %s\n", nameWidth, col, formatValue(row[col])) + } + } +} diff --git a/internal/cmd/sql/render_test.go b/internal/cmd/sql/render_test.go new file mode 100644 index 00000000..522a55a5 --- /dev/null +++ b/internal/cmd/sql/render_test.go @@ -0,0 +1,191 @@ +package sql + +import ( + "bytes" + "strings" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/printer" + "github.com/planetscale/cli/internal/sqlquery" +) + +func TestStripVerticalTerminator(t *testing.T) { + tests := []struct { + name string + query string + wantQuery string + wantVertical bool + }{ + {"no terminator", "SELECT 1", "SELECT 1", false}, + {"trailing G", `SHOW REPLICA STATUS\G`, "SHOW REPLICA STATUS", true}, + {"trailing G with whitespace", "SELECT 1 \\G \n", "SELECT 1", true}, + {"lowercase g strips without vertical", `SELECT 1\g`, "SELECT 1", false}, + {"semicolon untouched", "SELECT 1;", "SELECT 1;", false}, + {"backslash G inside string literal", `SELECT '\G stuff' FROM t`, `SELECT '\G stuff' FROM t`, false}, + {"only terminator", `\G`, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotQuery, gotVertical := stripVerticalTerminator(tt.query) + if gotQuery != tt.wantQuery || gotVertical != tt.wantVertical { + t.Fatalf("stripVerticalTerminator(%q) = (%q, %v), want (%q, %v)", + tt.query, gotQuery, gotVertical, tt.wantQuery, tt.wantVertical) + } + }) + } +} + +func TestRenderTablePreservesColumnOrderAndAlignment(t *testing.T) { + columns := []string{"id", "name", "deleted_at"} + rows := []map[string]any{ + {"id": int64(1), "name": "alice", "deleted_at": nil}, + {"id": int64(2), "name": "bo", "deleted_at": "2026-01-02"}, + } + + var b bytes.Buffer + renderTable(&b, columns, rows) + + want := strings.Join([]string{ + "+----+-------+------------+", + "| id | name | deleted_at |", + "+----+-------+------------+", + "| 1 | alice | NULL |", + "| 2 | bo | 2026-01-02 |", + "+----+-------+------------+", + "", + }, "\n") + if b.String() != want { + t.Fatalf("renderTable output:\n%s\nwant:\n%s", b.String(), want) + } +} + +func TestRenderTableMultiLineValue(t *testing.T) { + columns := []string{"gtid"} + rows := []map[string]any{{"gtid": "aaaa:1-5,\nbb:1-2"}} + + var b bytes.Buffer + renderTable(&b, columns, rows) + + got := b.String() + if !strings.Contains(got, "aaaa:1-5,\nbb:1-2") { + t.Fatalf("multi-line value must be printed verbatim:\n%s", got) + } + // The column is sized to the longest line, not the whole value, and the + // row's final line still closes its border. + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + border := lines[0] + if border != "+-----------+" { + t.Fatalf("border = %q, want width of longest line", border) + } + if lines[len(lines)-1] != border { + t.Fatalf("table must end with border, got %q", lines[len(lines)-1]) + } + if !strings.HasSuffix(lines[len(lines)-2], "|") { + t.Fatalf("last row line must close its border, got %q", lines[len(lines)-2]) + } +} + +func TestRenderVertical(t *testing.T) { + columns := []string{"Replica_IO_Running", "Seconds_Behind_Source", "Last_Error"} + rows := []map[string]any{ + {"Replica_IO_Running": "Yes", "Seconds_Behind_Source": int64(0), "Last_Error": nil}, + {"Replica_IO_Running": "No", "Seconds_Behind_Source": int64(12), "Last_Error": ""}, + } + + var b bytes.Buffer + renderVertical(&b, columns, rows) + + want := strings.Join([]string{ + "*************************** 1. row ***************************", + " Replica_IO_Running: Yes", + "Seconds_Behind_Source: 0", + " Last_Error: NULL", + "*************************** 2. row ***************************", + " Replica_IO_Running: No", + "Seconds_Behind_Source: 12", + " Last_Error: ", + "", + }, "\n") + if b.String() != want { + t.Fatalf("renderVertical output:\n%s\nwant:\n%s", b.String(), want) + } +} + +func humanHelper(out *bytes.Buffer) *cmdutil.Helper { + format := printer.Human + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + Config: &config.Config{}, + } + ch.Printer.SetHumanOutput(out) + return ch +} + +func TestPrintHumanResultRowsAffected(t *testing.T) { + var out bytes.Buffer + printHumanResult(humanHelper(&out), &sqlquery.Result{RowsAffected: 3}, false) + if got, want := out.String(), "Rows affected: 3\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestPrintHumanResultZeroRows(t *testing.T) { + var out bytes.Buffer + printHumanResult(humanHelper(&out), &sqlquery.Result{Columns: []string{"id"}}, false) + if got, want := out.String(), "Returned 0 row(s)\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestPrintHumanResultTable(t *testing.T) { + var out bytes.Buffer + result := &sqlquery.Result{ + RowCount: 1, + Columns: []string{"id", "name"}, + Rows: []map[string]any{{"id": int64(1), "name": "alice"}}, + } + printHumanResult(humanHelper(&out), result, false) + + got := out.String() + if !strings.Contains(got, "| id | name |") { + t.Fatalf("expected table header in output:\n%s", got) + } + if !strings.HasSuffix(got, "Returned 1 row(s)\n") { + t.Fatalf("expected row count after table:\n%s", got) + } + if strings.Contains(got, "map[") { + t.Fatalf("output must not contain Go map syntax:\n%s", got) + } +} + +func TestPrintHumanResultVertical(t *testing.T) { + var out bytes.Buffer + result := &sqlquery.Result{ + RowCount: 1, + Columns: []string{"id", "name"}, + Rows: []map[string]any{{"id": int64(1), "name": "alice"}}, + } + printHumanResult(humanHelper(&out), result, true) + + got := out.String() + if !strings.Contains(got, "*************************** 1. row ***************************") { + t.Fatalf("expected vertical row header in output:\n%s", got) + } + if !strings.Contains(got, "name: alice") { + t.Fatalf("expected column line in output:\n%s", got) + } +} + +func TestSQLCmdHasVerticalFlag(t *testing.T) { + format := printer.Human + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + Config: &config.Config{}, + } + cmd := SQLCmd(ch) + if cmd.Flags().Lookup("vertical") == nil { + t.Fatal("expected --vertical flag on sql subcommand") + } +} diff --git a/internal/cmd/sql/sql.go b/internal/cmd/sql/sql.go index 91739de1..7a5abf09 100644 --- a/internal/cmd/sql/sql.go +++ b/internal/cmd/sql/sql.go @@ -17,6 +17,7 @@ func SQLCmd(ch *cmdutil.Helper) *cobra.Command { role string replica bool force bool + vertical bool } cmd := &cobra.Command{ @@ -40,6 +41,10 @@ one shard; enumerate shards with SHOW VITESS_SHARDS. PostgreSQL databases use --dbname (default postgres). +Human output prints rows as a table. Pass --vertical (or end the query with \G, +like the mysql client) to print one column per line, which is easier to read for +wide results such as SHOW REPLICA STATUS. + Place flags after positional arguments (see Usage). --org is required: pscale sql --org --format json --query "SELECT 1"`, @@ -51,14 +56,20 @@ Place flags after positional arguments (see Usage). --org is required: pscale sql --org --format json --replica --query "SELECT 1" # MySQL — keyspace optional (@primary default) - pscale sql --org --format json --keyspace --query "SELECT 1"`, + pscale sql --org --format json --keyspace --query "SELECT 1" + + # Vertical output for wide rows (--vertical, or end the query with \G) + pscale sql --org --replica --query "SHOW REPLICA STATUS\G"`, PersistentPreRunE: cmdutil.CheckAuthentication(ch.Config), RunE: func(cmd *cobra.Command, args []string) error { + query, verticalTerminator := stripVerticalTerminator(flags.query) + vertical := flags.vertical || verticalTerminator + result, err := sqlquery.Execute(cmd.Context(), ch, sqlquery.Options{ Organization: ch.Config.Organization, Database: args[0], Branch: args[1], - Query: flags.query, + Query: query, Keyspace: flags.keyspace, PostgresDB: flags.postgresDB, Role: flags.role, @@ -73,14 +84,7 @@ Place flags after positional arguments (see Usage). --org is required: case printer.JSON: return ch.Printer.PrintJSON(result) case printer.Human: - if result.RowsAffected > 0 && result.RowCount == 0 { - ch.Printer.Printf("Rows affected: %d\n", result.RowsAffected) - return nil - } - ch.Printer.Printf("Returned %d row(s)\n", result.RowCount) - for i, row := range result.Rows { - ch.Printer.Printf("%d: %v\n", i+1, row) - } + printHumanResult(ch, result, vertical) return nil default: return ch.Printer.PrintResource(result.Rows) @@ -99,6 +103,8 @@ Place flags after positional arguments (see Usage). --org is required: "When enabled, the password will route all reads to the branch's primary replicas and all read-only regions.") cmd.Flags().BoolVar(&flags.force, "force", false, "Allow destructive SQL (DELETE, DROP, TRUNCATE). Only use after the user explicitly approves.") + cmd.Flags().BoolVar(&flags.vertical, "vertical", false, + "Print each row vertically, one column per line. Same as ending the query with \\G in the mysql client.") cmd.MarkFlagRequired("query") // nolint:errcheck cmd.MarkPersistentFlagRequired("org") // nolint:errcheck From 43a5ea1a68d7991af3ef80b2de950af80d9eb33c Mon Sep 17 00:00:00 2001 From: Matthias Crauwels Date: Mon, 17 Aug 2026 14:27:09 +0200 Subject: [PATCH 2/2] sql: keep table grid closed for multi-line values A cell with embedded newlines (e.g. a GTID set) was printed verbatim mid-row, pushing later columns onto a borderless continuation line. Spread the logical row over multiple physical lines instead, padding every column on every line. Co-Authored-By: Claude Fable 5 --- internal/cmd/sql/render.go | 30 ++++++++++++----- internal/cmd/sql/render_test.go | 57 ++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/internal/cmd/sql/render.go b/internal/cmd/sql/render.go index 24d004de..62353537 100644 --- a/internal/cmd/sql/render.go +++ b/internal/cmd/sql/render.go @@ -98,18 +98,32 @@ func renderTable(w io.Writer, columns []string, rows []map[string]any) { fmt.Fprint(w, border.String()) } +// writeTableRow writes one logical row. A cell with embedded newlines spreads +// the row over multiple physical lines, padding every column on every line so +// the grid stays closed. func writeTableRow(w io.Writer, cells []string, widths []int) { + lines := make([][]string, len(cells)) + height := 1 for i, cell := range cells { - // Pad relative to the last line so single-line cells align and - // multi-line cells still close their border cleanly. - lines := strings.Split(cell, "\n") - pad := widths[i] - utf8.RuneCountInString(lines[len(lines)-1]) - if pad < 0 { - pad = 0 + lines[i] = strings.Split(cell, "\n") + if len(lines[i]) > height { + height = len(lines[i]) } - fmt.Fprintf(w, "| %s%s ", cell, strings.Repeat(" ", pad)) } - fmt.Fprint(w, "|\n") + for line := 0; line < height; line++ { + for i := range cells { + var s string + if line < len(lines[i]) { + s = lines[i][line] + } + pad := widths[i] - utf8.RuneCountInString(s) + if pad < 0 { + pad = 0 + } + fmt.Fprintf(w, "| %s%s ", s, strings.Repeat(" ", pad)) + } + fmt.Fprint(w, "|\n") + } } // renderVertical writes rows in the mysql \G style: one column per line, diff --git a/internal/cmd/sql/render_test.go b/internal/cmd/sql/render_test.go index 522a55a5..04a0d062 100644 --- a/internal/cmd/sql/render_test.go +++ b/internal/cmd/sql/render_test.go @@ -68,22 +68,47 @@ func TestRenderTableMultiLineValue(t *testing.T) { var b bytes.Buffer renderTable(&b, columns, rows) - got := b.String() - if !strings.Contains(got, "aaaa:1-5,\nbb:1-2") { - t.Fatalf("multi-line value must be printed verbatim:\n%s", got) - } - // The column is sized to the longest line, not the whole value, and the - // row's final line still closes its border. - lines := strings.Split(strings.TrimRight(got, "\n"), "\n") - border := lines[0] - if border != "+-----------+" { - t.Fatalf("border = %q, want width of longest line", border) - } - if lines[len(lines)-1] != border { - t.Fatalf("table must end with border, got %q", lines[len(lines)-1]) - } - if !strings.HasSuffix(lines[len(lines)-2], "|") { - t.Fatalf("last row line must close its border, got %q", lines[len(lines)-2]) + want := strings.Join([]string{ + "+-----------+", + "| gtid |", + "+-----------+", + "| aaaa:1-5, |", + "| bb:1-2 |", + "+-----------+", + "", + }, "\n") + if b.String() != want { + t.Fatalf("renderTable output:\n%s\nwant:\n%s", b.String(), want) + } +} + +func TestRenderTableMultiLineValueKeepsLaterColumnsAligned(t *testing.T) { + columns := []string{"id", "gtid", "host"} + rows := []map[string]any{ + {"id": int64(1), "gtid": "a:1-5,\nb:1-2", "host": "h1"}, + } + + var b bytes.Buffer + renderTable(&b, columns, rows) + + want := strings.Join([]string{ + "+----+--------+------+", + "| id | gtid | host |", + "+----+--------+------+", + "| 1 | a:1-5, | h1 |", + "| | b:1-2 | |", + "+----+--------+------+", + "", + }, "\n") + if b.String() != want { + t.Fatalf("renderTable output:\n%s\nwant:\n%s", b.String(), want) + } + + // Every physical line must open and close its border. + for i, line := range strings.Split(strings.TrimRight(b.String(), "\n"), "\n") { + if !strings.HasPrefix(line, "+") && (!strings.HasPrefix(line, "| ") || !strings.HasSuffix(line, "|")) { + t.Fatalf("line %d does not close its border: %q", i+1, line) + } } }