-
Notifications
You must be signed in to change notification settings - Fork 63
sql: readable human output — table by default, plus --vertical and \G support #1342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mcrauwel
wants to merge
2
commits into
main
Choose a base branch
from
mcrauwel/sql-vertical-output
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+376
−10
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| 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()) | ||
| } | ||
|
|
||
| // 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 { | ||
| lines[i] = strings.Split(cell, "\n") | ||
| if len(lines[i]) > height { | ||
| height = len(lines[i]) | ||
| } | ||
| } | ||
| 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, | ||
| // 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])) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| 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) | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.