From 1ccd595ddfa1a4ebfd055ca7fc1ad76d7ea1020b Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:57:14 +0900 Subject: [PATCH] Support output for article (#127) * Implement CLI_FORMAT={TABLE_COMMENT|TABLE_DETAIL_COMMENT} * Implement CLI_MARKDOWN_CODEBLOCK * Implement CLI_ECHO_INPUT * Add test for output * Update README about Markdown output --- README.md | 36 ++++++++++++++++++ cli.go | 63 +++++++++++++++++++++++-------- cli_test.go | 92 +++++++++++++++++++++++++++++++++++++-------- system_variables.go | 10 +++++ 4 files changed, 170 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index fbfbc03..f0ba12c 100644 --- a/README.md +++ b/README.md @@ -1396,6 +1396,42 @@ deleted rows scanned: 3 rows optimizer version: 7 optimizer statistics: auto_20241128_05_46_13UTC ``` + +### Markdown output + +spanner-mycli can emit input and output in Markdown. + +TODO: More description + +```` +$ spanner-mycli -v --set 'CLI_FORMAT=TABLE_DETAIL_COMMENT' --set 'CLI_MARKDOWN_CODEBLOCK=TRUE' --set 'CLI_ECHO_INPUT=TRUE' + +spanner> GRAPH FinGraph + -> MATCH (n:Account) + -> RETURN LABELS(n) AS labels, PROPERTY_NAMES(n) AS props, n.id; +```sql +GRAPH FinGraph +MATCH (n:Account) +RETURN LABELS(n) AS labels, PROPERTY_NAMES(n) AS props, n.id +/*--------------+------------------------------------------+-------+ +| labels | props | id | +| ARRAY | ARRAY | INT64 | ++---------------+------------------------------------------+-------+ +| [Account] | [create_time, id, is_blocked, nick_name] | 7 | +| [Account] | [create_time, id, is_blocked, nick_name] | 16 | +| [Account] | [create_time, id, is_blocked, nick_name] | 20 | ++---------------+------------------------------------------+-------+ +3 rows in set (6.47 msecs) +timestamp: 2025-02-02T10:22:04.867235+09:00 +cpu time: 4.66 msecs +rows scanned: 3 rows +deleted rows scanned: 0 rows +optimizer version: 7 +optimizer statistics: auto_20250201_06_22_44UTC +*/ +``` +```` + ## How to develop Run unit tests. diff --git a/cli.go b/cli.go index 5f83a04..19995d6 100644 --- a/cli.go +++ b/cli.go @@ -72,6 +72,8 @@ type DisplayMode int const ( DisplayModeTable DisplayMode = iota + DisplayModeTableComment + DisplayModeTableDetailComment DisplayModeVertical DisplayModeTab ) @@ -471,7 +473,7 @@ func (c *Cli) RunInteractive(ctx context.Context) int { } } - c.PrintResult(size, result, c.SystemVariables.CLIFormat, true) + c.PrintResult(size, result, true, input.statement) fmt.Fprintf(c.OutStream, "\n") cancel() @@ -517,7 +519,7 @@ func (c *Cli) RunBatch(ctx context.Context, input string) int { c.updateSystemVariables(result) } - c.PrintResult(math.MaxInt, result, c.SystemVariables.CLIFormat, false) + c.PrintResult(math.MaxInt, result, false, "") } return exitCodeSuccess @@ -556,7 +558,7 @@ func (c *Cli) PrintBatchError(err error) { printError(c.ErrStream, err) } -func (c *Cli) PrintResult(screenWidth int, result *Result, mode DisplayMode, interactive bool) { +func (c *Cli) PrintResult(screenWidth int, result *Result, interactive bool, input string) { ostream := c.OutStream var cmd *exec.Cmd if c.SystemVariables.UsePager { @@ -589,7 +591,7 @@ func (c *Cli) PrintResult(screenWidth int, result *Result, mode DisplayMode, int } }() } - printResult(c.SystemVariables.Debug, screenWidth, ostream, result, mode, interactive, c.SystemVariables.Verbose) + printResult(c.SystemVariables, screenWidth, ostream, result, interactive, input) } func (c *Cli) PrintProgressingMark() func() { @@ -901,15 +903,26 @@ func adjustByHeader(headers []string, availableWidth int) []int { return adjustWidths } -func printResult(debug bool, screenWidth int, out io.Writer, result *Result, mode DisplayMode, interactive, verbose bool) { +func printResult(sysVars *systemVariables, screenWidth int, out io.Writer, result *Result, interactive bool, input string) { + mode := sysVars.CLIFormat + + if sysVars.MarkdownCodeblock { + fmt.Fprintln(out, "```sql") + } + + if sysVars.EchoInput && input != "" { + fmt.Fprintln(out, input) + } + // screenWidth <= means no limit. if screenWidth <= 0 { screenWidth = math.MaxInt } - switch { - case mode == DisplayModeTable: - table := tablewriter.NewWriter(out) + switch mode { + case DisplayModeTable, DisplayModeTableComment, DisplayModeTableDetailComment: + var tableBuf strings.Builder + table := tablewriter.NewWriter(&tableBuf) table.SetAutoFormatHeaders(false) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) @@ -924,12 +937,12 @@ func printResult(debug bool, screenWidth int, out io.Writer, result *Result, mod slices.Values(result.ColumnTypes), )) header := slices.Collect(xiter.Map(formatTypedHeaderColumn, slices.Values(result.ColumnTypes))) - adjustedWidths = calculateOptimalWidth(debug, screenWidth, names, slices.Concat(sliceOf(toRow(header...)), result.Rows)) + adjustedWidths = calculateOptimalWidth(sysVars.Debug, screenWidth, names, slices.Concat(sliceOf(toRow(header...)), result.Rows)) } else { - adjustedWidths = calculateOptimalWidth(debug, screenWidth, result.ColumnNames, slices.Concat(sliceOf(toRow(result.ColumnNames...)), result.Rows)) + adjustedWidths = calculateOptimalWidth(sysVars.Debug, screenWidth, result.ColumnNames, slices.Concat(sliceOf(toRow(result.ColumnNames...)), result.Rows)) } var forceTableRender bool - if verbose && len(result.ColumnTypes) > 0 { + if sysVars.Verbose && len(result.ColumnTypes) > 0 { forceTableRender = true headers := slices.Collect(hiter.Unify( @@ -952,7 +965,20 @@ func printResult(debug bool, screenWidth int, out io.Writer, result *Result, mod if forceTableRender || len(result.Rows) > 0 { table.Render() } - case mode == DisplayModeVertical: + + s := strings.TrimSpace(tableBuf.String()) + if mode == DisplayModeTableComment || mode == DisplayModeTableDetailComment { + topLeftRe := regexp.MustCompile(`^\+-`) + s = topLeftRe.ReplaceAllLiteralString(s, "/*") + } + + if mode == DisplayModeTableComment { + bottomRightRe := regexp.MustCompile(`-\+$`) + s = bottomRightRe.ReplaceAllLiteralString(s, "*/") + } + + fmt.Fprintln(out, s) + case DisplayModeVertical: maxLen := 0 for _, columnName := range result.ColumnNames { if len(columnName) > maxLen { @@ -966,7 +992,7 @@ func printResult(debug bool, screenWidth int, out io.Writer, result *Result, mod fmt.Fprintf(out, format, result.ColumnNames[j], column) } } - case mode == DisplayModeTab: + case DisplayModeTab: if len(result.ColumnNames) > 0 { fmt.Fprintln(out, strings.Join(result.ColumnNames, "\t")) for _, row := range result.Rows { @@ -990,10 +1016,17 @@ func printResult(debug bool, screenWidth int, out io.Writer, result *Result, mod } fmt.Fprintln(out) } - if verbose || result.ForceVerbose { + if sysVars.Verbose || result.ForceVerbose { fmt.Fprint(out, resultLine(result, true)) } else if interactive { - fmt.Fprint(out, resultLine(result, verbose)) + fmt.Fprint(out, resultLine(result, sysVars.Verbose)) + } + if mode == DisplayModeTableDetailComment { + fmt.Fprintln(out, "*/") + } + + if sysVars.MarkdownCodeblock { + fmt.Fprintln(out, "```") } } diff --git a/cli_test.go b/cli_test.go index ce74c20..0e221cf 100644 --- a/cli_test.go +++ b/cli_test.go @@ -128,16 +128,18 @@ func TestBuildCommands(t *testing.T) { func TestPrintResult(t *testing.T) { t.Run("DisplayModeTable", func(t *testing.T) { tests := []struct { + sysVars *systemVariables desc string - displayMode DisplayMode result *Result screenWidth int - verbose bool + input string want string }{ { - desc: "DisplayModeTable: simple table", - displayMode: DisplayModeTable, + desc: "DisplayModeTable: simple table", + sysVars: &systemVariables{ + CLIFormat: DisplayModeTable, + }, result: &Result{ ColumnNames: []string{"foo", "bar"}, Rows: []Row{ @@ -156,10 +158,64 @@ func TestPrintResult(t *testing.T) { `, "\n"), }, { - desc: "DisplayModeTable: most preceding column name", - displayMode: DisplayModeTable, + desc: "DisplayModeTableComment: simple table", + sysVars: &systemVariables{ + CLIFormat: DisplayModeTableComment, + }, + result: &Result{ + ColumnNames: []string{"foo", "bar"}, + Rows: []Row{ + {[]string{"1", "2"}}, + {[]string{"3", "4"}}, + }, + IsMutation: false, + }, + want: strings.TrimPrefix(` +/*----+-----+ +| foo | bar | ++-----+-----+ +| 1 | 2 | +| 3 | 4 | ++-----+----*/ +`, "\n"), + }, + { + desc: "DisplayModeTableCommentDetail, echo, verbose, markdown", + sysVars: &systemVariables{ + CLIFormat: DisplayModeTableDetailComment, + EchoInput: true, + Verbose: true, + MarkdownCodeblock: true, + }, + input: "SELECT foo, bar\nFROM input", + result: &Result{ + ColumnNames: []string{"foo", "bar"}, + Rows: []Row{ + {[]string{"1", "2"}}, + {[]string{"3", "4"}}, + }, + IsMutation: false, + }, + want: "```sql" + ` +SELECT foo, bar +FROM input +/*----+-----+ +| foo | bar | ++-----+-----+ +| 1 | 2 | +| 3 | 4 | ++-----+-----+ +Empty set +*/ +` + "```\n", + }, + { + desc: "DisplayModeTable: most preceding column name", + sysVars: &systemVariables{ + CLIFormat: DisplayModeTable, + Verbose: true, + }, screenWidth: 20, - verbose: true, result: &Result{ ColumnTypes: typector.MustNameCodeSlicesToStructTypeFields( sliceOf("NAME", "LONG_NAME"), @@ -184,10 +240,12 @@ Empty set `, "\n"), }, { - desc: "DisplayModeTable: also respect column type", - displayMode: DisplayModeTable, + desc: "DisplayModeTable: also respect column type", + sysVars: &systemVariables{ + CLIFormat: DisplayModeTable, + Verbose: true, + }, screenWidth: 19, - verbose: true, result: &Result{ ColumnTypes: typector.MustNameCodeSlicesToStructTypeFields( sliceOf("NAME", "LONG_NAME"), @@ -212,10 +270,12 @@ Empty set `, "\n"), }, { - desc: "DisplayModeTable: also respect column value", - displayMode: DisplayModeTable, + desc: "DisplayModeTable: also respect column value", + sysVars: &systemVariables{ + CLIFormat: DisplayModeTable, + Verbose: true, + }, screenWidth: 25, - verbose: true, result: &Result{ ColumnTypes: typector.MustNameCodeSlicesToStructTypeFields( sliceOf("English", "Japanese"), @@ -243,7 +303,7 @@ Empty set for _, test := range tests { t.Run(test.desc, func(t *testing.T) { out := &bytes.Buffer{} - printResult(false, test.screenWidth, out, test.result, test.displayMode, false, test.verbose) + printResult(test.sysVars, test.screenWidth, out, test.result, false, test.input) got := out.String() if diff := cmp.Diff(test.want, got); diff != "" { @@ -263,7 +323,7 @@ Empty set ), IsMutation: false, } - printResult(false, math.MaxInt, out, result, DisplayModeVertical, false, false) + printResult(&systemVariables{CLIFormat: DisplayModeVertical}, math.MaxInt, out, result, false, "") expected := strings.TrimPrefix(` *************************** 1. row *************************** @@ -290,7 +350,7 @@ bar: 4 ), IsMutation: false, } - printResult(false, math.MaxInt, out, result, DisplayModeTab, false, false) + printResult(&systemVariables{CLIFormat: DisplayModeTab}, math.MaxInt, out, result, false, "") expected := "foo\tbar\n" + "1\t2\n" + diff --git a/system_variables.go b/system_variables.go index 9958223..3b2af89 100644 --- a/system_variables.go +++ b/system_variables.go @@ -58,6 +58,7 @@ type systemVariables struct { RequestTag string UsePager bool DatabaseDialect databasepb.DatabaseDialect + MarkdownCodeblock bool // it is internal variable and hidden from system variable statements ProtoDescriptor *descriptorpb.FileDescriptorSet @@ -73,6 +74,7 @@ type systemVariables struct { AutoWrap bool EchoExecutedDDL bool EnableHighlight bool + EchoInput bool // TODO: Expose as CLI_* EnableProgressBar bool @@ -325,6 +327,10 @@ var accessorMap = map[string]accessor{ switch strings.ToUpper(unquoteString(value)) { case "TABLE": this.CLIFormat = DisplayModeTable + case "TABLE_COMMENT": + this.CLIFormat = DisplayModeTableComment + case "TABLE_DETAIL_COMMENT": + this.CLIFormat = DisplayModeTableDetailComment case "VERTICAL": this.CLIFormat = DisplayModeVertical case "TAB": @@ -383,6 +389,7 @@ var accessorMap = map[string]accessor{ "CLI_ROLE": { Getter: stringGetter(func(sysVars *systemVariables) *string { return &sysVars.Role }), }, + "CLI_ECHO_INPUT": boolAccessor(func(sysVars *systemVariables) *bool { return &sysVars.EchoInput }), "CLI_ENDPOINT": { Getter: stringGetter(func(sysVars *systemVariables) *string { return &sysVars.Endpoint }), }, @@ -510,6 +517,9 @@ var accessorMap = map[string]accessor{ "CLI_PROTOTEXT_MULTILINE": boolAccessor(func(variables *systemVariables) *bool { return &variables.MultilineProtoText }), + "CLI_MARKDOWN_CODEBLOCK": boolAccessor(func(variables *systemVariables) *bool { + return &variables.MarkdownCodeblock + }), "CLI_QUERY_MODE": { Getter: func(this *systemVariables, name string) (map[string]string, error) { if this.QueryMode == nil {