Skip to content

Commit 06c3a34

Browse files
authored
feat: add CSV output format support with multiple CLI options (#393)
This pull request significantly enhances spanner-mycli by introducing robust CSV output capabilities for query results, providing users with a structured, machine-readable output option that adheres to RFC 4180. Key changes: - Added comprehensive CSV output format support with automatic escaping of special characters - Introduced --csv flag for direct CSV output and generic --format flag for flexible format selection - Implemented case-insensitive format parsing via parseDisplayMode function for consistency - Enhanced error handling for edge cases (empty columns) with proper logging - Fixed potential panic in Vertical display mode when rows have more columns than headers - Used defer statement for CSV writer flush to prevent data loss on errors - Added extensive test coverage for CSV output including special characters and edge cases - Updated documentation in README.md and system_variables.md This implementation follows Go best practices for error handling and resource management, ensuring reliable CSV output even when writing to files via the --tee option. Fixes #392
1 parent 9316917 commit 06c3a34

File tree

8 files changed

+373
-35
lines changed

8 files changed

+373
-35
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ There are differences between spanner-mycli and spanner-cli that include not onl
4343
* Support `--skip-column-names` flag to suppress column headers in output (useful for scripting)
4444
* Support `--host` and `--port` flags as first-class options
4545
* Support `--deployment-endpoint` as an alias for `--endpoint`
46-
* Support `--html` and `--xml` output format options with proper escaping (security-enhanced compared to reference implementation)
46+
* Support `--html`, `--xml`, and `--csv` output format options with proper escaping (security-enhanced compared to reference implementation)
4747
* Generalized concepts to extend without a lot of original syntax
4848
* Generalized system variables concept inspired by Spanner JDBC properties
4949
* `SET <name> = <value>` statement
@@ -113,6 +113,8 @@ spanner:
113113
-t, --table Display output in table format for batch mode.
114114
--html Display output in HTML format.
115115
--xml Display output in XML format.
116+
--csv Display output in CSV format.
117+
--format=[table|tab|vertical|html|xml|csv] Output format (alternative to individual format flags)
116118
-v, --verbose Display verbose output.
117119
--credential= Use the specific credential file
118120
--prompt= Set the prompt to the specified format (default: spanner%t> )
@@ -888,8 +890,9 @@ They have almost same semantics with [Spanner JDBC properties](https://cloud.goo
888890
> - `TAB` - Tab-separated values (default for batch mode)
889891
> - `HTML` - HTML table format (compatible with Google Cloud Spanner CLI)
890892
> - `XML` - XML format (compatible with Google Cloud Spanner CLI)
893+
> - `CSV` - Comma-separated values (RFC 4180 compliant with automatic escaping)
891894
>
892-
> You can change the output format at runtime using `SET CLI_FORMAT = 'HTML';` or use command-line flags `--table`, `--html`, or `--xml`.
895+
> You can change the output format at runtime using `SET CLI_FORMAT = 'CSV';` or use command-line flags `--table`, `--html`, `--xml`, or `--csv`.
893896
894897
### Batch statements
895898

cli_output.go

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"cmp"
55
_ "embed"
6+
"encoding/csv"
67
"encoding/xml"
78
"fmt"
89
"html"
@@ -41,8 +42,35 @@ const (
4142
DisplayModeTab
4243
DisplayModeHTML
4344
DisplayModeXML
45+
DisplayModeCSV
4446
)
4547

48+
// parseDisplayMode converts a string format name to DisplayMode.
49+
// It accepts both uppercase and lowercase format names.
50+
// Returns an error if the format name is invalid.
51+
func parseDisplayMode(format string) (DisplayMode, error) {
52+
switch strings.ToUpper(format) {
53+
case "TABLE":
54+
return DisplayModeTable, nil
55+
case "TABLE_COMMENT":
56+
return DisplayModeTableComment, nil
57+
case "TABLE_DETAIL_COMMENT":
58+
return DisplayModeTableDetailComment, nil
59+
case "VERTICAL":
60+
return DisplayModeVertical, nil
61+
case "TAB":
62+
return DisplayModeTab, nil
63+
case "HTML":
64+
return DisplayModeHTML, nil
65+
case "XML":
66+
return DisplayModeXML, nil
67+
case "CSV":
68+
return DisplayModeCSV, nil
69+
default:
70+
return DisplayModeTable, fmt.Errorf("invalid format: %v", format)
71+
}
72+
}
73+
4674
// renderTableHeader renders TableHeader. It is nil safe.
4775
func renderTableHeader(header TableHeader, verbose bool) []string {
4876
if header == nil {
@@ -65,6 +93,17 @@ func printTableData(sysVars *systemVariables, screenWidth int, out io.Writer, re
6593

6694
columnNames := renderTableHeader(result.TableHeader, false)
6795

96+
// Early return if no columns - Spanner requires at least one column in SELECT,
97+
// so this only happens for edge cases where no output is expected
98+
if len(columnNames) == 0 {
99+
// Log logic error where we have rows but no columns
100+
if len(result.Rows) > 0 {
101+
slog.Error("printTableData called with empty column headers but non-empty rows - this indicates a logic error",
102+
"rowCount", len(result.Rows))
103+
}
104+
return
105+
}
106+
68107
switch mode {
69108
case DisplayModeTable, DisplayModeTableComment, DisplayModeTableDetailComment:
70109
rw := runewidthex.NewCondition()
@@ -140,8 +179,14 @@ func printTableData(sysVars *systemVariables, screenWidth int, out io.Writer, re
140179
for i, row := range result.Rows {
141180
fmt.Fprintf(out, "*************************** %d. row ***************************\n", i+1)
142181
for j, column := range row { // j represents the index of the column in the row
143-
144-
fmt.Fprintf(out, format, columnNames[j], column)
182+
var columnName string
183+
if j < len(columnNames) {
184+
columnName = columnNames[j]
185+
} else {
186+
// Use a default column name if row has more columns than headers
187+
columnName = fmt.Sprintf("Column_%d", j+1)
188+
}
189+
fmt.Fprintf(out, format, columnName, column)
145190
}
146191
}
147192
case DisplayModeTab:
@@ -177,6 +222,12 @@ func printTableData(sysVars *systemVariables, screenWidth int, out io.Writer, re
177222
if err := printXMLResultSet(out, columnNames, result.Rows, sysVars.SkipColumnNames); err != nil {
178223
slog.Error("printXMLResultSet() failed", "err", err)
179224
}
225+
case DisplayModeCSV:
226+
// Output data in CSV format using encoding/csv for RFC 4180 compliance.
227+
// This provides automatic escaping of special characters (commas, quotes, newlines).
228+
if err := printCSVTable(out, columnNames, result.Rows, sysVars.SkipColumnNames); err != nil {
229+
slog.Error("printCSVTable() failed", "err", err)
230+
}
180231
}
181232
}
182233

@@ -540,7 +591,7 @@ func formatTypedHeaderColumn(field *sppb.StructType_Field) string {
540591
// Note: This function streams output row-by-row for memory efficiency.
541592
func printHTMLTable(out io.Writer, columnNames []string, rows []Row, skipColumnNames bool) error {
542593
if len(columnNames) == 0 {
543-
return nil
594+
return fmt.Errorf("no columns to output")
544595
}
545596

546597
if _, err := fmt.Fprint(out, "<TABLE BORDER='1'>"); err != nil {
@@ -619,7 +670,7 @@ type xmlResultSet struct {
619670
// with the TABLE format over memory efficiency.
620671
func printXMLResultSet(out io.Writer, columnNames []string, rows []Row, skipColumnNames bool) error {
621672
if len(columnNames) == 0 {
622-
return nil
673+
return fmt.Errorf("no columns to output")
623674
}
624675

625676
// Build the result set structure
@@ -660,3 +711,41 @@ func printXMLResultSet(out io.Writer, columnNames []string, rows []Row, skipColu
660711
_, err := fmt.Fprintln(out) // Add final newline
661712
return err
662713
}
714+
715+
// printCSVTable outputs query results in CSV format.
716+
// The format follows RFC 4180 with automatic escaping via encoding/csv.
717+
// Column headers are included unless skipColumnNames is true.
718+
//
719+
// Note: Spanner requires at least one column in SELECT queries, so columnNames
720+
// should never be empty for valid query results. The empty check is defensive
721+
// programming for edge cases like client-side statements or error conditions.
722+
func printCSVTable(out io.Writer, columnNames []string, rows []Row, skipColumnNames bool) error {
723+
if len(columnNames) == 0 {
724+
return fmt.Errorf("no columns to output")
725+
}
726+
727+
csvWriter := csv.NewWriter(out)
728+
// Defer Flush to ensure the buffer is written to the output, even on error.
729+
defer csvWriter.Flush()
730+
731+
if !skipColumnNames {
732+
if err := csvWriter.Write(columnNames); err != nil {
733+
return err
734+
}
735+
}
736+
737+
for _, row := range rows {
738+
if err := csvWriter.Write(row); err != nil {
739+
return err
740+
}
741+
}
742+
743+
// The deferred Flush() will write any remaining buffered data.
744+
// We must check for an error from the writer, which can happen
745+
// during a Write or the final Flush.
746+
if err := csvWriter.Error(); err != nil {
747+
return fmt.Errorf("csv writer error: %w", err)
748+
}
749+
750+
return nil
751+
}

cli_output_test.go

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"strings"
66
"testing"
7+
8+
"github.com/MakeNowJust/heredoc/v2"
79
)
810

911
func TestPrintTableDataHTML(t *testing.T) {
@@ -216,8 +218,10 @@ func TestCLIFormatSystemVariable(t *testing.T) {
216218
{"set TAB", "TAB", DisplayModeTab, false},
217219
{"set HTML", "HTML", DisplayModeHTML, false},
218220
{"set XML", "XML", DisplayModeXML, false},
221+
{"set CSV", "CSV", DisplayModeCSV, false},
219222
{"set html lowercase", "html", DisplayModeHTML, false},
220223
{"set xml lowercase", "xml", DisplayModeXML, false},
224+
{"set csv lowercase", "csv", DisplayModeCSV, false},
221225
{"set invalid", "INVALID", DisplayModeTable, true},
222226
}
223227

@@ -252,6 +256,7 @@ func TestCLIFormatSystemVariableGetter(t *testing.T) {
252256
{DisplayModeTab, "TAB"},
253257
{DisplayModeHTML, "HTML"},
254258
{DisplayModeXML, "XML"},
259+
{DisplayModeCSV, "CSV"},
255260
{DisplayMode(999), "TABLE"}, // Invalid mode should return default
256261
}
257262

@@ -344,8 +349,11 @@ func TestHTMLAndXMLHelpers(t *testing.T) {
344349
t.Run("printHTMLTable with empty input", func(t *testing.T) {
345350
var buf bytes.Buffer
346351
err := printHTMLTable(&buf, []string{}, []Row{}, false)
347-
if err != nil {
348-
t.Errorf("unexpected error: %v", err)
352+
if err == nil {
353+
t.Error("expected error for empty columns, got nil")
354+
}
355+
if err != nil && err.Error() != "no columns to output" {
356+
t.Errorf("unexpected error message: %v", err)
349357
}
350358
if buf.String() != "" {
351359
t.Errorf("expected empty output, got: %q", buf.String())
@@ -355,8 +363,25 @@ func TestHTMLAndXMLHelpers(t *testing.T) {
355363
t.Run("printXMLResultSet with empty input", func(t *testing.T) {
356364
var buf bytes.Buffer
357365
err := printXMLResultSet(&buf, []string{}, []Row{}, false)
358-
if err != nil {
359-
t.Errorf("unexpected error: %v", err)
366+
if err == nil {
367+
t.Error("expected error for empty columns, got nil")
368+
}
369+
if err != nil && err.Error() != "no columns to output" {
370+
t.Errorf("unexpected error message: %v", err)
371+
}
372+
if buf.String() != "" {
373+
t.Errorf("expected empty output, got: %q", buf.String())
374+
}
375+
})
376+
377+
t.Run("printCSVTable with empty input", func(t *testing.T) {
378+
var buf bytes.Buffer
379+
err := printCSVTable(&buf, []string{}, []Row{}, false)
380+
if err == nil {
381+
t.Error("expected error for empty columns, got nil")
382+
}
383+
if err != nil && err.Error() != "no columns to output" {
384+
t.Errorf("unexpected error message: %v", err)
360385
}
361386
if buf.String() != "" {
362387
t.Errorf("expected empty output, got: %q", buf.String())
@@ -390,3 +415,104 @@ func TestHTMLAndXMLHelpers(t *testing.T) {
390415
}
391416
})
392417
}
418+
419+
func TestPrintTableDataCSV(t *testing.T) {
420+
tests := []struct {
421+
name string
422+
result *Result
423+
skipColNames bool
424+
wantOutput string
425+
}{
426+
{
427+
name: "simple CSV output",
428+
result: &Result{
429+
TableHeader: toTableHeader("num", "str", "bool"),
430+
Rows: []Row{
431+
{"1", "test", "true"},
432+
{"2", "data", "false"},
433+
},
434+
},
435+
wantOutput: heredoc.Doc(`
436+
num,str,bool
437+
1,test,true
438+
2,data,false
439+
`),
440+
},
441+
{
442+
name: "CSV with special characters",
443+
result: &Result{
444+
TableHeader: toTableHeader("name", "description", "value"),
445+
Rows: []Row{
446+
{"John, Jr.", "Says \"Hello\"", "100"},
447+
{"Jane\nDoe", "Has,comma", "$50"},
448+
{"Bob", "Normal text", "75"},
449+
},
450+
},
451+
wantOutput: heredoc.Doc(`
452+
name,description,value
453+
"John, Jr.","Says ""Hello""",100
454+
"Jane
455+
Doe","Has,comma",$50
456+
Bob,Normal text,75
457+
`),
458+
},
459+
{
460+
name: "CSV with skip column names",
461+
result: &Result{
462+
TableHeader: toTableHeader("col1", "col2"),
463+
Rows: []Row{
464+
{"val1", "val2"},
465+
{"val3", "val4"},
466+
},
467+
},
468+
skipColNames: true,
469+
wantOutput: heredoc.Doc(`
470+
val1,val2
471+
val3,val4
472+
`),
473+
},
474+
{
475+
name: "empty result",
476+
result: &Result{
477+
TableHeader: nil,
478+
Rows: []Row{},
479+
},
480+
wantOutput: "",
481+
},
482+
{
483+
name: "CSV with quotes and newlines",
484+
result: &Result{
485+
TableHeader: toTableHeader("text"),
486+
Rows: []Row{
487+
{"Line 1\nLine 2"},
488+
{"\"Quoted\""},
489+
{"Normal"},
490+
},
491+
},
492+
wantOutput: heredoc.Doc(`
493+
text
494+
"Line 1
495+
Line 2"
496+
"""Quoted"""
497+
Normal
498+
`),
499+
},
500+
}
501+
502+
for _, tt := range tests {
503+
t.Run(tt.name, func(t *testing.T) {
504+
var buf bytes.Buffer
505+
sysVars := &systemVariables{
506+
CLIFormat: DisplayModeCSV,
507+
SkipColumnNames: tt.skipColNames,
508+
}
509+
510+
printTableData(sysVars, 0, &buf, tt.result)
511+
512+
got := buf.String()
513+
if got != tt.wantOutput {
514+
t.Errorf("printTableData() = %q, want %q", got, tt.wantOutput)
515+
}
516+
})
517+
}
518+
}

docs/system_variables.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ TODO
1919
SELECT * FROM users; -- Output without column headers
2020
```
2121
- **Notes**:
22-
- Affects table format (`CLI_FORMAT='TABLE'`) and tab format (`CLI_FORMAT='TAB'`)
22+
- Affects table format (`CLI_FORMAT='TABLE'`), tab format (`CLI_FORMAT='TAB'`), CSV format (`CLI_FORMAT='CSV'`), HTML format (`CLI_FORMAT='HTML'`), and XML format (`CLI_FORMAT='XML'`)
2323
- Headers are always preserved in vertical format (`CLI_FORMAT='VERTICAL'`) as they are integral to the format
2424
- Can be set via `--skip-column-names` command-line flag
2525
- Useful for scripting and data processing where only raw data is needed
@@ -53,6 +53,7 @@ TODO
5353
- `TAB` - Tab-separated values (default for batch mode)
5454
- `HTML` - HTML table format
5555
- `XML` - XML format
56+
- `CSV` - Comma-separated values (RFC 4180 compliant)
5657
- **Usage**:
5758
```sql
5859
SET CLI_FORMAT = 'VERTICAL';
@@ -64,15 +65,20 @@ TODO
6465
SET CLI_FORMAT = 'XML';
6566
SELECT * FROM users; -- Output as XML
6667

68+
SET CLI_FORMAT = 'CSV';
69+
SELECT * FROM users; -- Output as CSV (comma-separated values)
70+
6771
SET CLI_FORMAT = 'TABLE_DETAIL_COMMENT';
6872
SELECT * FROM users; -- Output as table with execution stats, all wrapped in /* */ comments
6973
```
7074
- **Notes**:
7175
- Can be set via `--html` flag (sets to HTML format)
7276
- Can be set via `--xml` flag (sets to XML format)
77+
- Can be set via `--csv` flag (sets to CSV format)
7378
- Can be set via `--table` flag (sets to TABLE format in batch mode)
7479
- HTML and XML formats are compatible with Google Cloud Spanner CLI
75-
- All special characters are properly escaped in HTML and XML formats for security
80+
- All special characters are properly escaped in HTML, XML, and CSV formats for security
81+
- CSV format follows RFC 4180 standard with automatic escaping of commas, quotes, and newlines
7682
- The format affects how query results are displayed, not how they are executed
7783
- `TABLE_DETAIL_COMMENT` is particularly useful with `CLI_ECHO_INPUT=TRUE` and `CLI_MARKDOWN_CODEBLOCK=TRUE` for documentation
7884

0 commit comments

Comments
 (0)