From 71b2af14a93ee841c181af2e78bb89d8475e5e5d Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:50:02 +0900 Subject: [PATCH 1/2] Wire query plan rendering into --plan-format. Extend split-mode --plan-format beyond json/yaml so text and graph renderers run in-process after the query, with flag validation and a TTY refusal for PNG. --- README.md | 69 +++++++++++++- flags_validation_test.go | 54 ++++++++++- internal/planrender/render.go | 5 + main.go | 23 +++-- output.go | 173 ++++++++++++++++++++++++++++++++-- output_test.go | 113 +++++++++++++++++++++- query_stats_modes_test.go | 82 +++++++++++++++- 7 files changed, 493 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 175e316..85d60e5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Yet another `gcloud spanner databases execute-sql` replacement for better compos * Configurable gRPC logging (`off`, `metadata`, `payload` with payload caveat) * (Experimental) CSV output * Split query-plan and row output (`--plan-output`) +* In-process query plan rendering (`--plan-format=text|dot|mermaid|d2|svg|png`) * (Experimental) Check whether the query can be executed as a partition query or not. This tool is still pre-release quality and none of guarantees. @@ -49,9 +50,10 @@ Flags: --plan-output=STRING Write the query-plan artifact here and strip stats.queryPlan from the primary document. Enables split mode. - --plan-format=STRING Format of the plan artifact: json or yaml. - Defaults to --format when that is json or - yaml, otherwise json. Requires --plan-output. + --plan-format=STRING Format of the plan artifact: json, yaml, + text, dot, mermaid, d2, svg, or png. Defaults + to --format when that is json or yaml, + otherwise json. Requires --plan-output. --discard-results Do not write the primary document (plan-only). Requires --plan-output. --redact-rows Redact result rows from output @@ -91,6 +93,21 @@ Flags: --try-partition-query (Experimental) Check whether the query can be executed as partition query or not +Plan rendering + --plan-text-style=STRING Text plan style: current, traditional, or compact. + Defaults to current. Requires --plan-format=text. + --plan-wrap-width=INT Wrap width for text plans. 0 disables wrapping. + Requires --plan-format=text. + --plan-print=STRING Text plan sections: basic, enhanced, full, none, + or a comma-separated section list. Defaults to + basic. Requires --plan-format=text. + --plan-full Include full graph node detail. Requires a graph + --plan-format (dot, mermaid, d2, svg, png). + --plan-show-query Add a query-text node to graph output. Requires a + graph --plan-format. + --plan-show-query-stats Add query statistics to the query-text node. + Requires a graph --plan-format. + Timestamp Bound --strong Perform a strong query. --read-timestamp=STRING Perform a query at the given timestamp. @@ -149,7 +166,7 @@ Setting `--plan-output` switches from the default combined document to split mod |------|---------|---------| | `--output PATH` (`-o`) | `-` | Destination of the primary document (metadata, rows, `stats` without `queryPlan`). | | `--plan-output PATH` | unset | Enables split mode: write the plan artifact here and remove `stats.queryPlan` from the primary document. | -| `--plan-format json\|yaml` | follows `--format` when that is `json` or `yaml`, otherwise `json` | Format of the plan artifact. | +| `--plan-format json\|yaml\|text\|dot\|mermaid\|d2\|svg\|png` | follows `--format` when that is `json` or `yaml`, otherwise `json` | Format of the plan artifact. `json`/`yaml` write a `ResultSet` envelope; the others render in-process. | | `--discard-results` | off | Do not write the primary document (plan-only). Requires `--plan-output`. | `--redact-rows` is independent of `--discard-results`: redact still emits metadata and a CSV header; discard writes no primary bytes at all. @@ -188,6 +205,37 @@ $ execspansql ${DATABASE_ID} --query-mode=PROFILE --discard-results \ --plan-output=plan.json --sql='SELECT * FROM Singers' ``` +Renderer formats write through the same `--plan-output` sink. `--plan-format=text` is the built-in equivalent of piping `.stats.queryPlan` into `rendertree`. Graph formats (`dot`, `mermaid`, `d2`, `svg`, `png`) use embedded `spannerplanviz`; `svg`/`png` do not need an external Graphviz install. + +| Flag | Applies to | Default | +|------|------------|---------| +| `--plan-text-style current\|traditional\|compact` | `text` | `current` | +| `--plan-wrap-width N` | `text` | `0` (off; never inferred from terminal width) | +| `--plan-print basic\|enhanced\|full\|none\|` | `text` | `basic` | +| `--plan-full` | graph formats | off | +| `--plan-show-query` | graph formats | off | +| `--plan-show-query-stats` | graph formats | off | + +`--redact-rows` does not redact plans: predicates and metadata can still contain literals. `png` to a terminal (`--plan-output=-` or `/dev/stderr` when that fd is a TTY) is rejected; redirect or write a file instead. Renderer-only flags that do not apply to the chosen `--plan-format` are errors, not silent no-ops. + +``` +$ execspansql ${DATABASE_ID} --query-mode=PROFILE --format=experimental_csv \ + --output=- --plan-output=/dev/stderr --plan-format=text \ + --sql='SELECT * FROM Singers' +``` + +``` +$ execspansql ${DATABASE_ID} --query-mode=PROFILE --redact-rows \ + --discard-results --plan-output=- --plan-format=text \ + --sql='SELECT * FROM Singers' +``` + +``` +$ execspansql ${DATABASE_ID} --query-mode=PROFILE --discard-results \ + --plan-output=plan.svg --plan-format=svg --plan-full \ + --sql='SELECT * FROM Singers' +``` + ### Parameter support Many Cloud Spanner clients don't support parameter. @@ -261,7 +309,15 @@ Output expands top-level `gojq.Iter` to one JSON/YAML document per row (JSONL-st #### Example: Extract QueryPlan -[rendertree] command takes QueryPlan, and it can be extracted by jq filter. +`--plan-output` with `--plan-format=text` renders the plan without a second binary. The jq + [rendertree] pipeline remains available for the combined document. + +``` +$ execspansql ${DATABASE_ID} --query-mode=PROFILE \ + --sql='SELECT * FROM Singers@{FORCE_INDEX=SingersByFirstLastName}' \ + --discard-results --plan-output=- --plan-format=text +``` + +[rendertree] can still consume `.stats.queryPlan` from the combined JSON document: ``` $ execspansql ${DATABASE_ID} --query-mode=PROFILE --format=json \ @@ -453,3 +509,6 @@ exit status 1 * Non-`NORMAL` query modes (`PLAN`, `PROFILE`, `WITH_PLAN_AND_STATS`, and `WITH_STATS`) cannot be combined with `--enable-partitioned-dml`. The Partitioned DML client path ignores query mode and would execute writes. * `--plan-output` requires a plan-producing query mode and cannot be combined with `--try-partition-query` or `--enable-partitioned-dml`. * Split mode disables jq early stop so the plan artifact can be captured after the last `PartialResultSet`. +* `--plan-format=png` cannot write to a terminal; use a file or a redirected stdout/stderr. +* `--redact-rows` does not redact query plans. +* The Spanner emulator often omits `planNodes` from PLAN/PROFILE results; `--plan-output` then publishes the primary document and exits non-zero without a plan file. diff --git a/flags_validation_test.go b/flags_validation_test.go index 92ddbdb..b3de980 100644 --- a/flags_validation_test.go +++ b/flags_validation_test.go @@ -424,11 +424,61 @@ func TestValidateExecutionOptions(t *testing.T) { mode: partitionedDML{}, err: "--plan-output cannot be combined with --enable-partitioned-dml", }, + { + name: "plan_format_allows_text", + o: opts{PlanOutput: "plan.txt", PlanFormat: "text", QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + }, + { + name: "plan_format_allows_svg", + o: opts{PlanOutput: "plan.svg", PlanFormat: "svg", QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + }, { name: "plan_format_rejects_unknown", - o: opts{PlanOutput: "plan.json", PlanFormat: "text", QueryMode: "PROFILE"}, + o: opts{PlanOutput: "plan.json", PlanFormat: "html", QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + err: "--plan-format must be json, yaml, text, dot, mermaid, d2, svg, or png", + }, + { + name: "plan_text_style_requires_plan_output", + o: opts{PlanTextStyle: "compact", QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + err: "--plan-text-style requires --plan-output", + }, + { + name: "plan_text_style_rejects_json", + o: opts{PlanOutput: "plan.json", PlanFormat: "json", PlanTextStyle: "compact", QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + err: "--plan-text-style cannot be used with --plan-format=json", + }, + { + name: "plan_full_rejects_text", + o: opts{PlanOutput: "plan.txt", PlanFormat: "text", PlanFull: true, QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + err: "--plan-full cannot be used with --plan-format=text", + }, + { + name: "plan_show_query_rejects_yaml", + o: opts{PlanOutput: "plan.yaml", PlanFormat: "yaml", PlanShowQuery: true, QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + err: "--plan-show-query cannot be used with --plan-format=yaml", + }, + { + name: "plan_wrap_width_rejects_dot", + o: opts{PlanOutput: "plan.dot", PlanFormat: "dot", PlanWrapWidth: 80, QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + err: "--plan-wrap-width cannot be used with --plan-format=dot", + }, + { + name: "plan_text_style_allows_text", + o: opts{PlanOutput: "plan.txt", PlanFormat: "text", PlanTextStyle: "compact", PlanPrint: "enhanced", QueryMode: "PROFILE"}, + mode: single{spanner.StrongRead()}, + }, + { + name: "plan_full_allows_mermaid", + o: opts{PlanOutput: "plan.mmd", PlanFormat: "mermaid", PlanFull: true, PlanShowQuery: true, QueryMode: "PROFILE", Sql: "SELECT 1"}, mode: single{spanner.StrongRead()}, - err: "--plan-format must be json or yaml", }, } diff --git a/internal/planrender/render.go b/internal/planrender/render.go index 0e80260..ae5512a 100644 --- a/internal/planrender/render.go +++ b/internal/planrender/render.go @@ -75,6 +75,11 @@ func Render(ctx context.Context, w io.Writer, format Format, rowType *sppb.Struc return renderGraph(ctx, w, format, rowType, stats, opts) } +// Validate reports whether opts apply to format. +func (o Options) Validate(format Format) error { + return o.validate(format) +} + func (o Options) validate(format Format) error { if o.WrapWidth < 0 { return fmt.Errorf("WrapWidth cannot be negative: %d", o.WrapWidth) diff --git a/main.go b/main.go index b632625..9d8fa14 100644 --- a/main.go +++ b/main.go @@ -60,7 +60,13 @@ type opts struct { Format string `name:"format" enum:"json,yaml,experimental_csv" default:"json" help:"Output format of the primary document."` Output string `name:"output" short:"o" default:"-" help:"Destination of the primary document. Use - for stdout; /dev/stdout and /dev/stderr are mapped in-process."` PlanOutput string `name:"plan-output" help:"Write the query-plan artifact here and strip stats.queryPlan from the primary document. Enables split mode."` - PlanFormat string `name:"plan-format" help:"Format of the plan artifact: json or yaml. Defaults to --format when that is json or yaml, otherwise json. Requires --plan-output."` + PlanFormat string `name:"plan-format" help:"Format of the plan artifact: json, yaml, text, dot, mermaid, d2, svg, or png. Defaults to --format when that is json or yaml, otherwise json. Requires --plan-output."` + PlanTextStyle string `name:"plan-text-style" help:"Text plan style: current, traditional, or compact. Defaults to current. Requires --plan-format=text." group:"Plan rendering"` + PlanWrapWidth int `name:"plan-wrap-width" help:"Wrap width for text plans. 0 disables wrapping. Requires --plan-format=text." group:"Plan rendering"` + PlanPrint string `name:"plan-print" help:"Text plan sections: basic, enhanced, full, none, or a comma-separated section list. Defaults to basic. Requires --plan-format=text." group:"Plan rendering"` + PlanFull bool `name:"plan-full" help:"Include full graph node detail. Requires a graph --plan-format (dot, mermaid, d2, svg, png)." group:"Plan rendering"` + PlanShowQuery bool `name:"plan-show-query" help:"Add a query-text node to graph output. Requires a graph --plan-format." group:"Plan rendering"` + PlanShowQueryStats bool `name:"plan-show-query-stats" help:"Add query statistics to the query-text node. Requires a graph --plan-format." group:"Plan rendering"` DiscardResults bool `name:"discard-results" help:"Do not write the primary document (plan-only). Requires --plan-output."` RedactRows bool `name:"redact-rows" help:"Redact result rows from output"` CompactOutput bool `name:"compact-output" short:"c" help:"Compact JSON output (--compact-output of jq)"` @@ -470,6 +476,7 @@ func runCLI(clientOptions ...option.ClientOption) (err error) { if err != nil { return err } + o.Sql = query tb, err := parseTimestampBound(o.TimestampBound.ReadTimestamp) if err != nil { @@ -599,7 +606,7 @@ func runAndWriteCsv(ctx context.Context, client *spanner.Client, stmt spanner.St if result != nil { md = result.Metadata } - return writePlan(sinks.plan, planFmt, md, stats) + return writePlan(ctx, sinks.plan, planFmt, md, stats, o) } writePlanFromDrain := func(result *spaniter.RowIteratorResult) error { if sinks.plan == nil { @@ -612,7 +619,7 @@ func runAndWriteCsv(ctx context.Context, client *spanner.Client, stmt spanner.St if err != nil { return err } - return writePlan(sinks.plan, planFmt, result.Metadata, stats) + return writePlan(ctx, sinks.plan, planFmt, result.Metadata, stats, o) } switch mode := mode.(type) { @@ -850,7 +857,7 @@ func runJqOutput( } sinks.MarkPrimaryComplete() if sinks.plan != nil { - return wrap(writePlan(sinks.plan, planFmt, metadata, planStats)) + return wrap(writePlan(ctx, sinks.plan, planFmt, metadata, planStats, o)) } return nil } @@ -871,7 +878,7 @@ func runJqOutput( if err != nil { return err } - return writePlan(sinks.plan, planFmt, result.Metadata, stats) + return writePlan(ctx, sinks.plan, planFmt, result.Metadata, stats, o) } writer := sinks.primary if writer == nil { @@ -881,7 +888,7 @@ func runJqOutput( if err != nil { return err } - return runJqOnRowIter(rowIter, o.RedactRows, jqCode, enc, sinks, planFmt) + return runJqOnRowIter(ctx, rowIter, o.RedactRows, jqCode, enc, sinks, planFmt, o) case partitionedDML: return fmt.Errorf("--jq-input-mode=lazy is not supported for partitioned DML") default: @@ -890,12 +897,14 @@ func runJqOutput( } func runJqOnRowIter( + ctx context.Context, rowIter *spanner.RowIterator, redactRows bool, jqCode *gojq.Code, enc encoder, sinks *outputSinks, planFmt string, + o opts, ) error { var lazyOpts []jqresult.LazyOption if sinks.hasPlan { @@ -923,7 +932,7 @@ func runJqOnRowIter( if err != nil { return err } - return writePlan(sinks.plan, planFmt, result.Metadata, stats) + return writePlan(ctx, sinks.plan, planFmt, result.Metadata, stats, o) } func newEncoder(writer io.Writer, format string, compactOutput bool, rawOutput bool) (encoder, error) { diff --git a/output.go b/output.go index 5a56a29..80192ff 100644 --- a/output.go +++ b/output.go @@ -1,15 +1,19 @@ package main import ( + "context" "errors" "fmt" "io" "os" "path/filepath" + "strings" sppb "cloud.google.com/go/spanner/apiv1/spannerpb" + "github.com/apstndb/execspansql/internal/planrender" "github.com/apstndb/execspansql/jqresult" svwriter "github.com/apstndb/spanvalue/writer" + "golang.org/x/term" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) @@ -19,11 +23,25 @@ const ( destDevStdout = "/dev/stdout" destDevStderr = "/dev/stderr" - planModesHelp = "PLAN, PROFILE, or WITH_PLAN_AND_STATS" + planModesHelp = "PLAN, PROFILE, or WITH_PLAN_AND_STATS" + planFormatHelp = "json, yaml, text, dot, mermaid, d2, svg, or png" ) var errNoQueryPlan = errors.New("query returned no query plan (nil or empty planNodes); not retrying the statement") +// planDestIsTerminal reports whether a stdout/stderr plan destination is a TTY. +// Tests replace this to avoid depending on the process's real descriptors. +var planDestIsTerminal = func(kind destKind) bool { + switch kind { + case destKindStdout: + return term.IsTerminal(int(os.Stdout.Fd())) + case destKindStderr: + return term.IsTerminal(int(os.Stderr.Fd())) + default: + return false + } +} + type destKind int const ( @@ -85,7 +103,7 @@ func isPlanProducingQueryMode(mode string) bool { func effectivePlanFormat(o opts) string { if o.PlanFormat != "" { - return o.PlanFormat + return strings.ToLower(o.PlanFormat) } switch o.Format { case "json", "yaml": @@ -95,6 +113,115 @@ func effectivePlanFormat(o opts) string { } } +func planRenderFlagNames(o opts) []string { + var names []string + if o.PlanTextStyle != "" { + names = append(names, "--plan-text-style") + } + if o.PlanWrapWidth != 0 { + names = append(names, "--plan-wrap-width") + } + if o.PlanPrint != "" { + names = append(names, "--plan-print") + } + if o.PlanFull { + names = append(names, "--plan-full") + } + if o.PlanShowQuery { + names = append(names, "--plan-show-query") + } + if o.PlanShowQueryStats { + names = append(names, "--plan-show-query-stats") + } + return names +} + +func planRenderOptions(o opts) planrender.Options { + ro := planrender.Options{ + TextStyle: o.PlanTextStyle, + WrapWidth: o.PlanWrapWidth, + PrintSections: o.PlanPrint, + Full: o.PlanFull, + ShowQuery: o.PlanShowQuery, + ShowQueryStats: o.PlanShowQueryStats, + } + if o.PlanShowQuery { + ro.Query = o.Sql + } + return ro +} + +func validatePlanFormatValue(format string) error { + switch strings.ToLower(format) { + case "json", "yaml", "text", "dot", "mermaid", "d2", "svg", "png": + return nil + default: + return fmt.Errorf("--plan-format must be %s", planFormatHelp) + } +} + +func flagsCannotApply(names []string, format string) error { + if len(names) == 0 { + return nil + } + verb := "cannot" + if len(names) == 1 { + return fmt.Errorf("%s cannot be used with --plan-format=%s", names[0], format) + } + return fmt.Errorf("%s %s be used with --plan-format=%s", strings.Join(names, ", "), verb, format) +} + +func validatePlanRenderOptions(o opts) error { + format := effectivePlanFormat(o) + var textFlags, graphFlags []string + if o.PlanTextStyle != "" { + textFlags = append(textFlags, "--plan-text-style") + } + if o.PlanWrapWidth != 0 { + textFlags = append(textFlags, "--plan-wrap-width") + } + if o.PlanPrint != "" { + textFlags = append(textFlags, "--plan-print") + } + if o.PlanFull { + graphFlags = append(graphFlags, "--plan-full") + } + if o.PlanShowQuery { + graphFlags = append(graphFlags, "--plan-show-query") + } + if o.PlanShowQueryStats { + graphFlags = append(graphFlags, "--plan-show-query-stats") + } + + switch format { + case "json", "yaml": + return flagsCannotApply(append(append([]string{}, textFlags...), graphFlags...), format) + case "text": + if err := flagsCannotApply(graphFlags, format); err != nil { + return err + } + default: + if err := flagsCannotApply(textFlags, format); err != nil { + return err + } + } + + if format == "json" || format == "yaml" { + return nil + } + pf, err := planrender.ParseFormat(format) + if err != nil { + return fmt.Errorf("--plan-format must be %s", planFormatHelp) + } + if err := planRenderOptions(o).Validate(pf); err != nil { + return err + } + if pf.IsBinary() && planDestIsTerminal(resolveDestination(o.PlanOutput).kind) { + return fmt.Errorf("--plan-format=png cannot write to a terminal; use a file or redirect") + } + return nil +} + func validatePlanOutputOptions(o opts) error { hasPlan := o.PlanOutput != "" if o.PlanFormat != "" && !hasPlan { @@ -103,8 +230,13 @@ func validatePlanOutputOptions(o opts) error { if o.DiscardResults && !hasPlan { return fmt.Errorf("--discard-results requires --plan-output") } - if o.PlanFormat != "" && o.PlanFormat != "json" && o.PlanFormat != "yaml" { - return fmt.Errorf("--plan-format must be json or yaml") + if names := planRenderFlagNames(o); len(names) > 0 && !hasPlan { + return fmt.Errorf("%s requires --plan-output", strings.Join(names, ", ")) + } + if o.PlanFormat != "" { + if err := validatePlanFormatValue(o.PlanFormat); err != nil { + return err + } } if !hasPlan { return nil @@ -122,7 +254,7 @@ func validatePlanOutputOptions(o opts) error { if o.EnablePartitionedDML { return fmt.Errorf("--plan-output cannot be combined with --enable-partitioned-dml") } - return nil + return validatePlanRenderOptions(o) } func validateDestinations(o opts) error { @@ -432,13 +564,23 @@ func stripQueryPlanForPrimary(rs *sppb.ResultSet) (planStats *sppb.ResultSetStat return planStats, metadata } -func writePlan(w io.Writer, format string, metadata *sppb.ResultSetMetadata, stats *sppb.ResultSetStats) error { +func writePlan(ctx context.Context, w io.Writer, format string, metadata *sppb.ResultSetMetadata, stats *sppb.ResultSetStats, o opts) error { if w == nil { return nil } if !hasUsableQueryPlan(stats) { return errNoQueryPlan } + format = strings.ToLower(format) + switch format { + case "json", "yaml": + return writePlanEnvelope(w, format, metadata, stats) + default: + return renderPlan(ctx, w, format, metadata, stats, o) + } +} + +func writePlanEnvelope(w io.Writer, format string, metadata *sppb.ResultSetMetadata, stats *sppb.ResultSetStats) error { envelope := &sppb.ResultSet{ Metadata: metadata, Stats: stats, @@ -458,6 +600,25 @@ func writePlan(w io.Writer, format string, metadata *sppb.ResultSetMetadata, sta return closeEncoder(enc) } +func renderPlan(ctx context.Context, w io.Writer, format string, metadata *sppb.ResultSetMetadata, stats *sppb.ResultSetStats, o opts) error { + pf, err := planrender.ParseFormat(format) + if err != nil { + return err + } + var rowType *sppb.StructType + if metadata != nil { + rowType = metadata.GetRowType() + } + err = planrender.Render(ctx, w, pf, rowType, stats, planRenderOptions(o)) + if err == nil { + return nil + } + if errors.Is(err, planrender.ErrNoQueryPlan) { + return errNoQueryPlan + } + return fmt.Errorf("query succeeded, plan rendering failed: %w", err) +} + func statsFromWriterResult(r *svwriter.RowIteratorResult, encodeRowCount bool) (*sppb.ResultSetStats, error) { if r == nil { return nil, nil diff --git a/output_test.go b/output_test.go index 18dc567..766a3bb 100644 --- a/output_test.go +++ b/output_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "errors" "os" "path/filepath" @@ -44,6 +45,7 @@ func TestEffectivePlanFormat(t *testing.T) { want string }{ {name: "explicit_yaml", o: opts{PlanFormat: "yaml", Format: "json"}, want: "yaml"}, + {name: "explicit_text_case", o: opts{PlanFormat: "TEXT", Format: "json"}, want: "text"}, {name: "follows_json", o: opts{Format: "json"}, want: "json"}, {name: "follows_yaml", o: opts{Format: "yaml"}, want: "yaml"}, {name: "csv_defaults_to_json", o: opts{Format: "experimental_csv"}, want: "json"}, @@ -263,7 +265,7 @@ func TestWritePlanEnvelopeOmitsRows(t *testing.T) { planStats, md := stripQueryPlanForPrimary(rs) var buf bytes.Buffer - if err := writePlan(&buf, "json", md, planStats); err != nil { + if err := writePlan(context.Background(), &buf, "json", md, planStats, opts{}); err != nil { t.Fatal(err) } m, err := jqresult.ProtoToMap(&sppb.ResultSet{Metadata: md, Stats: planStats}) @@ -279,7 +281,7 @@ func TestWritePlanEnvelopeOmitsRows(t *testing.T) { } var yamlBuf bytes.Buffer - if err := writePlan(&yamlBuf, "yaml", md, planStats); err != nil { + if err := writePlan(context.Background(), &yamlBuf, "yaml", md, planStats, opts{}); err != nil { t.Fatal(err) } if !strings.Contains(yamlBuf.String(), "queryPlan") { @@ -290,9 +292,9 @@ func TestWritePlanEnvelopeOmitsRows(t *testing.T) { func TestWritePlanRejectsEmptyPlan(t *testing.T) { t.Parallel() - err := writePlan(ioDiscardWriter{}, "json", nil, &sppb.ResultSetStats{ + err := writePlan(context.Background(), ioDiscardWriter{}, "json", nil, &sppb.ResultSetStats{ QueryPlan: &sppb.QueryPlan{}, - }) + }, opts{}) if !errors.Is(err, errNoQueryPlan) { t.Fatalf("error = %v, want errNoQueryPlan", err) } @@ -529,6 +531,17 @@ func TestProcessFlagsOutputDefaults(t *testing.T) { if got.Output != "rows.json" || got.PlanOutput != "plan.json" || got.PlanFormat != "yaml" || !got.DiscardResults { t.Fatalf("got %+v", got) } + + os.Args = []string{"execspansql", "database", "--project", "p", "--instance", "i", "--sql", "SELECT 1", + "--plan-output", "plan.txt", "--plan-format", "text", "--plan-text-style", "compact", + "--plan-wrap-width", "80", "--plan-print", "enhanced"} + got, err = processFlags() + if err != nil { + t.Fatal(err) + } + if got.PlanFormat != "text" || got.PlanTextStyle != "compact" || got.PlanWrapWidth != 80 || got.PlanPrint != "enhanced" { + t.Fatalf("renderer flags: %+v", got) + } } func TestSplitModeValidationBeforeClient(t *testing.T) { @@ -547,12 +560,98 @@ func TestSplitModeValidationBeforeClient(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "PLAN, PROFILE, or WITH_PLAN_AND_STATS") { t.Fatalf("NORMAL plan-output: error = %v", err) } + + orig := planDestIsTerminal + planDestIsTerminal = func(kind destKind) bool { return kind == destKindStdout } + defer func() { planDestIsTerminal = orig }() + err = runMain(t, []string{ + "database", "--project", "p", "--instance", "i", "--sql", "SELECT 1", + "--query-mode", "PROFILE", "--discard-results", "--plan-output", "-", + "--plan-format", "png", + }) + if err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("png to TTY: error = %v", err) + } } type ioDiscardWriter struct{} func (ioDiscardWriter) Write([]byte) (int, error) { return 0, nil } +func TestWritePlanTextContainsOperator(t *testing.T) { + t.Parallel() + + rs := profileResultSetForSplitTest() + planStats, md := stripQueryPlanForPrimary(rs) + var buf bytes.Buffer + if err := writePlan(context.Background(), &buf, "text", md, planStats, opts{}); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.Contains(got, "Scan") { + t.Fatalf("text plan = %q, want Scan", got) + } + if strings.Contains(got, "queryPlan") { + t.Fatalf("text plan still looks like JSON: %s", got) + } +} + +func TestWritePlanGraphSmoke(t *testing.T) { + t.Parallel() + + rs := profileResultSetForSplitTest() + planStats, md := stripQueryPlanForPrimary(rs) + for _, format := range []string{"dot", "mermaid", "d2"} { + var buf bytes.Buffer + if err := writePlan(context.Background(), &buf, format, md, planStats, opts{}); err != nil { + t.Fatalf("%s: %v", format, err) + } + if !strings.Contains(buf.String(), "Scan") { + t.Fatalf("%s plan = %q, want Scan", format, buf.String()) + } + } +} + +func TestWritePlanPNGMagic(t *testing.T) { + rs := profileResultSetForSplitTest() + planStats, md := stripQueryPlanForPrimary(rs) + var buf bytes.Buffer + if err := writePlan(context.Background(), &buf, "png", md, planStats, opts{}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if len(got) < 8 || !bytes.Equal(got[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) { + t.Fatalf("png magic = %x", got[:min(8, len(got))]) + } +} + +func TestValidatePNGOnTerminal(t *testing.T) { + orig := planDestIsTerminal + t.Cleanup(func() { planDestIsTerminal = orig }) + + planDestIsTerminal = func(kind destKind) bool { + return kind == destKindStdout || kind == destKindStderr + } + err := validatePlanOutputOptions(opts{ + PlanOutput: "-", PlanFormat: "png", QueryMode: "PROFILE", DiscardResults: true, + }) + if err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("error = %v, want terminal refusal", err) + } + + planDestIsTerminal = func(destKind) bool { return false } + if err := validatePlanOutputOptions(opts{ + PlanOutput: "-", PlanFormat: "png", QueryMode: "PROFILE", DiscardResults: true, + }); err != nil { + t.Fatalf("redirected stdout png: %v", err) + } + if err := validatePlanOutputOptions(opts{ + PlanOutput: "plan.png", PlanFormat: "png", QueryMode: "PROFILE", + }); err != nil { + t.Fatalf("file png: %v", err) + } +} + func profileResultSetForSplitTest() *sppb.ResultSet { return &sppb.ResultSet{ Metadata: &sppb.ResultSetMetadata{ @@ -562,7 +661,11 @@ func profileResultSetForSplitTest() *sppb.ResultSet { }, Rows: []*structpb.ListValue{{Values: []*structpb.Value{structpb.NewStringValue("1")}}}, Stats: &sppb.ResultSetStats{ - QueryPlan: &sppb.QueryPlan{PlanNodes: []*sppb.PlanNode{{DisplayName: "Scan"}}}, + QueryPlan: &sppb.QueryPlan{PlanNodes: []*sppb.PlanNode{{ + Index: 0, + Kind: sppb.PlanNode_RELATIONAL, + DisplayName: "Scan", + }}}, QueryStats: &structpb.Struct{Fields: map[string]*structpb.Value{ "elapsed_time": structpb.NewStringValue("1 msecs"), }}, diff --git a/query_stats_modes_test.go b/query_stats_modes_test.go index 44821dc..6f97235 100644 --- a/query_stats_modes_test.go +++ b/query_stats_modes_test.go @@ -137,7 +137,11 @@ func (s *queryStatsModeServer) ExecuteStreamingSql(_ *sppb.ExecuteSqlRequest, st "summary": structpb.NewStringValue("test stats"), }}, } - stats.QueryPlan = &sppb.QueryPlan{PlanNodes: []*sppb.PlanNode{{DisplayName: "Fake Scan"}}} + stats.QueryPlan = &sppb.QueryPlan{PlanNodes: []*sppb.PlanNode{{ + Index: 0, + Kind: sppb.PlanNode_RELATIONAL, + DisplayName: "Fake Scan", + }}} return stream.Send(&sppb.PartialResultSet{Stats: stats}) } @@ -339,3 +343,79 @@ func TestMainSendsAdditionalQueryStatsModes(t *testing.T) { } } } + +func TestPlanRenderCLI(t *testing.T) { + startQueryStatsModeServer(t, &queryStatsModeServer{}) + + t.Run("text", func(t *testing.T) { + dir := t.TempDir() + planPath := filepath.Join(dir, "plan.txt") + err := runMain(t, []string{ + "database", "--project", "project", "--instance", "instance", + "--sql", "SELECT 'value'", + "--query-mode", "PROFILE", + "--discard-results", + "--plan-output", planPath, + "--plan-format", "text", + "--timeout", "5s", + }) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(planPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "Fake Scan") { + t.Fatalf("text plan = %s", b) + } + }) + + t.Run("mermaid", func(t *testing.T) { + dir := t.TempDir() + planPath := filepath.Join(dir, "plan.mmd") + err := runMain(t, []string{ + "database", "--project", "project", "--instance", "instance", + "--sql", "SELECT 'value'", + "--query-mode", "PROFILE", + "--discard-results", + "--plan-output", planPath, + "--plan-format", "mermaid", + "--timeout", "5s", + }) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(planPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "Fake") { + t.Fatalf("mermaid plan = %s", b) + } + }) + + t.Run("svg", func(t *testing.T) { + dir := t.TempDir() + planPath := filepath.Join(dir, "plan.svg") + err := runMain(t, []string{ + "database", "--project", "project", "--instance", "instance", + "--sql", "SELECT 'value'", + "--query-mode", "PROFILE", + "--discard-results", + "--plan-output", planPath, + "--plan-format", "svg", + "--timeout", "5s", + }) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(planPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), " Date: Sat, 12 Sep 2026 16:09:23 +0900 Subject: [PATCH 2/2] Clarify rendered plan artifact contents --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85d60e5..d3734ca 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ In split mode the two destinations must differ. Both on stdout (any spelling) is Document contents: - Primary document: the current `ResultSet` with `stats.queryPlan` removed. `stats.queryStats` and `stats.rowCount*` stay. CSV primary output is unchanged (rows only). -- Plan artifact: a `ResultSet` envelope without `rows` — `metadata` (for `rowType`) plus the full `stats` (`queryPlan`, `queryStats`, `rowCount*`). jq flags apply only to the primary document; the plan is never filtered. +- Plan artifact: `json`/`yaml` write a `ResultSet` envelope without `rows` — `metadata` (for `rowType`) plus the full `stats` (`queryPlan`, `queryStats`, `rowCount*`). Renderer formats write the rendered bytes instead. jq flags apply only to the primary document; the plan is never filtered. Split mode disables jq early stop: remaining rows are drained so the final plan/stats can be captured, at the same server cost as reading everything. Rows drained only for the plan are not retained. `--jq-input-mode=lazy` still caches rows that jq actually consumed.