From b2e31e90adb82035de957731a56cacc6a3d82eb7 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 16:15:53 +0100 Subject: [PATCH 1/5] fix(cli): retain nested table output Render complex single-object fields as JSON and append indexed details below section tables while retaining their scalar columns. Complex list wrappers also gain details, including pagination without a results path. Direct arrays and declared-results aggregation retain their existing output. --- .changesets/1791385750-872cdcf2.yaml | 10 + templates/perms.go | 1 + templates/templates/cli/README.md | 2 +- .../auxiliary/internal/output/output.go.stmpl | 320 ++++++++++++++---- .../internal/output/output_test.go.stmpl | 199 +++++++++++ .../templates/cli/cli_harness_test.go.stmpl | 5 +- .../primary/table_additional_test.go.stmpl | 63 ++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 6 +- zSDKs/sdk-cli/internal/output/output.go | 320 ++++++++++++++---- zSDKs/sdk-cli/internal/output/output_test.go | 226 +++++++++++++ zSDKs/sdk-cli/tests/cli_harness_test.go | 5 +- 11 files changed, 1007 insertions(+), 150 deletions(-) create mode 100644 .changesets/1791385750-872cdcf2.yaml create mode 100644 templates/templates/cli/tests/primary/table_additional_test.go.stmpl diff --git a/.changesets/1791385750-872cdcf2.yaml b/.changesets/1791385750-872cdcf2.yaml new file mode 100644 index 000000000..780eee103 --- /dev/null +++ b/.changesets/1791385750-872cdcf2.yaml @@ -0,0 +1,10 @@ +id: 1791385750-872cdcf2 +features: + - core +targets: + - cli +type: fix +bump: patch +description: preserve nested table fields and add JSON details to complex wrapper rows +author: ThomasRooney +date: "2026-10-07" diff --git a/templates/perms.go b/templates/perms.go index a68f0cd1f..1a0c810b9 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -208,6 +208,7 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/tests/primary/security_ranking_test.go.stmpl": 0o644, "templates/cli/tests/primary/servers_test.go.stmpl": 0o644, "templates/cli/tests/primary/stringconstraints_test.go.stmpl": 0o644, + "templates/cli/tests/primary/table_additional_test.go.stmpl": 0o644, "templates/cli/tests/primary/telemetry_test.go.stmpl": 0o644, "templates/cli/tests/primary/toon_test.go.stmpl": 0o644, "templates/cli/tests/primary/unions_additional_test.go.stmpl": 0o644, diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 02c9cc68a..d952f753d 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -672,7 +672,7 @@ Three output paths: - `pretty` (default): Aligned key-value pairs with color, nested indentation - `json`: Marshaled from the typed Go struct (note: field order and precision may differ from original API JSON) - `yaml`: Via `gopkg.in/yaml.v3` - - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output + - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output. Single-object nested structs, maps, and complex arrays render as compact JSON cells using wire names and custom JSON marshalers. Existing section columns stay in place; rows with populated nested values gain a compact JSON detail line below the table, indexed by their one-based position in the original array (nil rows are skipped). A complex list wrapper receives these details too: runtime response shapes do not distinguish a wrapper from a resource. Direct array tables and `--all` tables that aggregate a declared results array of structs keep their existing scalar columns and output. Pagination without a results path renders whole page objects; complex sections in those page objects also gain indexed details. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values, retain non-nil empty maps/arrays, and report serialization errors with field/row context - `toon`: [Token-Oriented Object Notation](https://github.com/toon-format/spec) via `github.com/alpkeskin/gotoon` — compact, line-oriented, 30–60% fewer tokens than JSON. gotoon omits the final newline, so `encodeTOON()` terminates non-empty output with one, matching JSON and YAML and giving per-item streaming/pagination output its blank-line separator `pretty`, `yaml` and `toon` never reflect over the typed Go structs directly. `normalizeForOutput()` (`output.go.stmpl`) marshals the value once through the SDK's `utils.MarshalJSON` and decodes it back with `json.Decoder.UseNumber`, and the renderers work from that wire shape (`marshalYAML()`, `encodeTOON()`, `prettyPrint()`, shared by `Result()` and the per-item streaming/pagination path in `outputitems.go.stmpl`). Reflecting encoders would otherwise leak generated union internals — wrapper keys such as `ErrorEvent:`, `null` sibling variants, `Type`/`UnknownRaw` — plus raw struct-tag options as keys (`"error,omitzero":`) and lowercased Go field names in YAML. Numbers stay exact: `json.Number` is converted per renderer (`convertNumbers()` with `yamlNumber()`/`toonNumber()`, `formatJSONNumber()` for pretty) to a native `int64`/`float64` only when that value re-encodes to the identical JSON lexeme (`nativeNumber()`); otherwise the lexeme is kept verbatim — a tagged `!!int`/`!!float` scalar node for yaml, a string for TOON (which has no wider numeric type), the literal for pretty — so bigint/decimal fields never lose digits. TOON's own encoder goes through `float64`, so native integers above 2^53 round there (a gotoon property, unchanged). `--include-headers` output (`outputWithHeaders()`) already worked from the marshalled JSON. Covered by `TestEventStreamUnionEventsRenderWireShape` and `TestEventStreamNumbersKeepPrecision` in `tests/primary/eventstreaming_test.go.stmpl`. diff --git a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index 989a1f085..7f014220d 100644 --- a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl @@ -1321,7 +1321,7 @@ func applyJqToTyped(out io.Writer, content interface{}, jqExpr string, colorize, // For slices/arrays of structs, each struct becomes a row with struct fields as columns. // For single structs, outputs a vertical key-value table. Slice-of-struct fields // (such as the items of a list response) render as titled sub-tables. -// Other complex nested fields are skipped. +// Single objects include complex fields as JSON cells or indexed section details. func printTable(out io.Writer, content any) error { v := reflect.ValueOf(content) for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { @@ -1412,117 +1412,295 @@ func printTableRows(out io.Writer, v reflect.Value) error { return tw.Flush() } -// printTableSingle renders a single struct as a vertical key-value table. func printTableSingle(out io.Writer, v reflect.Value) error { - if hasTableSections(v.Type()) { - return printTableSections(out, v) + t := v.Type() + sections := hasTableSections(t) + var buf bytes.Buffer + var tw *tabwriter.Writer + blocks := 0 + startBlock := func() { + if blocks > 0 { + fmt.Fprintln(&buf) + } + blocks++ } - cols := collectTableColumns(v.Type()) - if len(cols) == 0 { + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !singleTableField(f) { + continue + } + value := v.Field(i) + if isTableSection(f.Type) { + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + tw = nil + } + startBlock() + fmt.Fprintln(&buf, safeTableText(strings.ToUpper(tableFieldName(f)))) + var section bytes.Buffer + if err := printSingleTableRows(§ion, value); err != nil { + return fmt.Errorf("table field %s: %w", tableFieldName(f), err) + } + for _, line := range strings.SplitAfter(section.String(), "\n") { + if line != "" { + buf.WriteString(" " + line) + } + } + continue + } + if nilSingleTableValue(value) && (sections || !isTableScalar(f.Type)) { + continue + } + cell, err := singleTableCell(value) + if err != nil { + return fmt.Errorf("table field %s: %w", tableFieldName(f), err) + } + if tw == nil { + startBlock() + tw = newTabWriter(&buf) + } + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(strings.ToUpper(tableFieldName(f))), cell) + } + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + } + if blocks == 0 { return fmt.Errorf("no displayable fields found") } + _, err := out.Write(buf.Bytes()) + return err +} - tw := newTabWriter(out) - for _, c := range cols { - fmt.Fprintf(tw, "%s\t%s\n", strings.ToUpper(c.name), formatTableCell(v.Field(c.index))) +func singleTableField(f reflect.StructField) bool { + return f.IsExported() && strings.Split(f.Tag.Get("json"), ",")[0] != "-" && f.Type.Kind() != reflect.Func && f.Type.Kind() != reflect.Chan +} + +func singleTableColumns(t reflect.Type) []tableColumn { + var cols []tableColumn + for _, c := range collectTableColumns(t) { + if singleTableField(t.Field(c.index)) { + cols = append(cols, c) + } } - return tw.Flush() + return cols } -// isTableSection reports whether a field of type t is a slice or array of -// structs with displayable columns, rendered as its own sub-table. func isTableSection(t reflect.Type) bool { t = derefType(t) + marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() + if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) { + return false + } if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { return false } elem := derefType(t.Elem()) - return elem.Kind() == reflect.Struct && len(collectTableColumns(elem)) > 0 + return elem.Kind() == reflect.Struct && len(singleTableColumns(elem)) > 0 } func hasTableSections(t reflect.Type) bool { for i := 0; i < t.NumField(); i++ { - if t.Field(i).IsExported() && isTableSection(t.Field(i).Type) { + if singleTableField(t.Field(i)) && isTableSection(t.Field(i).Type) { return true } } return false } -// printTableSections renders a struct in field order: runs of non-nil scalar -// fields as key-value blocks and each slice-of-struct field as a titled, -// indented sub-table. -func printTableSections(out io.Writer, v reflect.Value) error { - t := v.Type() - var buf bytes.Buffer - var tw *tabwriter.Writer - blocks := 0 - startBlock := func() { - if blocks > 0 { - fmt.Fprintln(&buf) +func nilSingleTableValue(v reflect.Value) bool { + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return true } - blocks++ + v = v.Elem() } - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if !f.IsExported() { - continue + return (v.Kind() == reflect.Slice || v.Kind() == reflect.Map) && v.IsNil() +} + +func complexTableValue(v reflect.Value) bool { + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return false } - if isTableScalar(f.Type) { - if isNilTableValue(v.Field(i)) { - continue - } - if tw == nil { - startBlock() - tw = newTabWriter(&buf) + v = v.Elem() + } + if v.Type() == reflect.TypeOf(json.RawMessage{}) { + return true + } + switch v.Kind() { + case reflect.Struct: + return v.Type() != timeType + case reflect.Map: + return true + case reflect.Array, reflect.Slice: + if !isTableScalar(v.Type()) || derefType(v.Type().Elem()).Kind() == reflect.Slice || derefType(v.Type().Elem()).Kind() == reflect.Array { + return true + } + for i := 0; i < v.Len(); i++ { + if complexTableValue(v.Index(i)) { + return true } - fmt.Fprintf(tw, "%s\t%s\n", strings.ToUpper(tableFieldName(f)), formatTableCell(v.Field(i))) - continue } - if !isTableSection(f.Type) { - continue + } + return false +} + +func tableJSON(v reflect.Value) (string, error) { + for v.Kind() == reflect.Interface && !v.IsNil() { + v = v.Elem() + } + for v.Kind() == reflect.Ptr && !v.IsNil() { + if _, ok := v.Interface().(json.Marshaler); ok { + return marshalTableJSON(v.Interface()) } - if tw != nil { - if err := tw.Flush(); err != nil { - return err - } - tw = nil - } - startBlock() - fmt.Fprintln(&buf, strings.ToUpper(tableFieldName(f))) - rows := v.Field(i) - for rows.Kind() == reflect.Ptr && !rows.IsNil() { - rows = rows.Elem() - } - var section bytes.Buffer - if rows.Kind() == reflect.Ptr { - fmt.Fprintln(§ion, "(empty)") - } else if err := printTableRows(§ion, rows); err != nil { - return err + v = v.Elem() + } + address := v + if v.Kind() != reflect.Ptr { + if v.CanAddr() { + address = v.Addr() + } else { + address = reflect.New(v.Type()) + address.Elem().Set(v) } - for _, line := range strings.SplitAfter(section.String(), "\n") { - if line != "" { - buf.WriteString(" " + line) + } + if _, ok := address.Interface().(json.Marshaler); ok { + return marshalTableJSON(address.Interface()) + } + if (v.Kind() == reflect.Slice || v.Kind() == reflect.Array) && v.Type().Elem().Kind() == reflect.Interface && !nilSingleTableValue(v) { + items := make([]any, v.Len()) + for i := 0; i < v.Len(); i++ { + item := v.Index(i) + for item.Kind() == reflect.Interface && !item.IsNil() { + item = item.Elem() + } + if item.Kind() == reflect.Struct { + copy := reflect.New(item.Type()) + copy.Elem().Set(item) + items[i] = copy.Interface() + } else { + items[i] = item.Interface() } } + return marshalTableJSON(items) } - if tw != nil { - if err := tw.Flush(); err != nil { - return err + return marshalTableJSON(address.Interface()) +} + +func marshalTableJSON(value any) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return safeTableJSON(string(data)), nil +} + +func singleTableCell(v reflect.Value) (string, error) { + if complexTableValue(v) { + return tableJSON(v) + } + return safeTableText(formatTableCell(v)), nil +} + +func safeTableJSON(s string) string { + var b strings.Builder + for _, r := range s { + if r < 32 || (r >= 127 && r <= 159) { + fmt.Fprintf(&b, "\\u%04x", r) + } else { + b.WriteRune(r) } } - _, err := out.Write(buf.Bytes()) - return err + return b.String() } -func isNilTableValue(v reflect.Value) bool { - for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { - if v.IsNil() { +func safeTableText(s string) string { + var b strings.Builder + for _, r := range s { + if r >= 127 && r <= 159 { + fmt.Fprintf(&b, "\\u%04x", r) + } else if r < 32 { + escaped := strconv.QuoteToASCII(string(r)) + b.WriteString(escaped[1 : len(escaped)-1]) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func singleTableRowNeedsDetail(v reflect.Value) bool { + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + if singleTableField(f) && !nilSingleTableValue(v.Field(i)) && complexTableValue(v.Field(i)) { return true } - v = v.Elem() + if f.IsExported() && strings.Split(f.Tag.Get("json"), ",")[0] == "-" && !v.Field(i).IsZero() { + if _, ok := reflect.New(v.Type()).Interface().(json.Marshaler); ok { + return true + } + } + } + return false +} + +func printSingleTableRows(out io.Writer, rows reflect.Value) error { + for rows.Kind() == reflect.Ptr { + if rows.IsNil() { + fmt.Fprintln(out, "(empty)") + return nil + } + rows = rows.Elem() + } + if rows.Len() == 0 { + fmt.Fprintln(out, "(empty)") + return nil + } + cols := singleTableColumns(derefType(rows.Type().Elem())) + tw := newTabWriter(out) + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = safeTableText(strings.ToUpper(c.name)) } - return v.Kind() == reflect.Slice && v.IsNil() + fmt.Fprintln(tw, strings.Join(headers, "\t")) + var details bytes.Buffer + for i := 0; i < rows.Len(); i++ { + original := rows.Index(i) + row := original + for row.Kind() == reflect.Ptr && !row.IsNil() { + row = row.Elem() + } + if row.Kind() != reflect.Struct { + continue + } + vals := make([]string, len(cols)) + for j, c := range cols { + cell, err := singleTableCell(row.Field(c.index)) + if err != nil { + return fmt.Errorf("row %d field %s: %w", i+1, c.name, err) + } + vals[j] = cell + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + if singleTableRowNeedsDetail(row) { + data, err := tableJSON(original) + if err != nil { + return fmt.Errorf("row %d: %w", i+1, err) + } + if data != "null" { + fmt.Fprintf(&details, "[%d] %s\n", i+1, data) + } + } + } + if err := tw.Flush(); err != nil { + return err + } + _, err := out.Write(details.Bytes()) + return err } // printTableMap renders a map as a two-column key-value table. diff --git a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl index 002c3a711..5925d40cb 100644 --- a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl @@ -2,6 +2,7 @@ package output import ( "bytes" + "encoding/json" {{- if hasCliAsyncIntents}} "context" "errors" @@ -38,6 +39,204 @@ func TestPrintTableDerivesColumnsFromFirstStructElement(t *testing.T) { } } + +type nestedTableEntry struct { + Kind string `json:"kind"` + Payload map[string]any `json:"payload,omitempty"` + Hidden string `json:"-"` +} + +type nestedTableUnion struct { + Kind string `json:"kind"` + Variant *nestedTableEntry `json:"variant,omitempty"` + UnknownRaw json.RawMessage `json:"-"` +} + +func (u *nestedTableUnion) MarshalJSON() ([]byte, error) { + if u.UnknownRaw != nil { + return u.UnknownRaw, nil + } + return json.Marshal(u.Variant) +} + +type customTableSlice []any + +func (customTableSlice) MarshalJSON() ([]byte, error) { + return []byte(`{"text":"hello"}`), nil +} + +type customTableRows []nestedTableEntry + +func (customTableRows) MarshalJSON() ([]byte, error) { + return []byte(`{"text":"hello"}`), nil +} + +type pointerTableRows []nestedTableEntry + +func (*pointerTableRows) MarshalJSON() ([]byte, error) { + return []byte(`{"text":"hello"}`), nil +} + +func TestPrintTableSingleContainerMarshaler(t *testing.T) { + for name, object := range map[string]any{ + "value": struct { Entries customTableRows `json:"entries"` }{customTableRows{ {Kind: "internal"}}}, + "pointer receiver": struct { Entries pointerTableRows `json:"entries"` }{pointerTableRows{ {Kind: "internal"}}}, + "pointer field": struct { Entries *pointerTableRows `json:"entries"` }{&pointerTableRows{ {Kind: "internal"}}}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, object) + if err != nil || buf.String() != "ENTRIES {\"text\":\"hello\"}\n" { + t.Fatalf("container marshaler bypassed: got %q, error %v", buf.String(), err) + } + }) + } +} + +func TestPrintTableSingleNestedValues(t *testing.T) { + for name, value := range map[string]any{ + "map": map[string]any{"text": "hello"}, + "object": struct { Text string `json:"text"` }{"hello"}, + "array": [1]map[string]string{ {"text": "hello"}}, + "map slice": []map[string]string{ {"text": "hello"}}, + "nested slice": [][]string{ {"hello"}}, + "interface slice": []any{map[string]string{"text": "hello"}}, + "raw JSON": json.RawMessage(`{"text":"hello"}`), + "pointer marshaler": nestedTableUnion{Variant: &nestedTableEntry{Kind: "hello"}}, + "interface marshaler": []any{nestedTableUnion{Variant: &nestedTableEntry{Kind: "hello"}}}, + "pointer interface slice": &[]any{nestedTableUnion{Variant: &nestedTableEntry{Kind: "hello"}}}, + "custom container": customTableSlice{nestedTableUnion{Variant: &nestedTableEntry{Kind: "wrong"}}}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { Value any `json:"wire_value"` }{value}) + if err != nil || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || !strings.HasPrefix(buf.String(), "WIRE_VALUE ") { + t.Fatalf("unexpected table: %q, error: %v", buf.String(), err) + } + }) + } +} + +func TestPrintTableSingleSectionDetails(t *testing.T) { + rows := []*nestedTableEntry{nil, {Kind: "note", Payload: map[string]any{"text": "hello"}, Hidden: "secret"}} + var buf bytes.Buffer + err := printTable(&buf, struct { Entries []*nestedTableEntry `json:"entries"` }{rows}) + want := "ENTRIES\n KIND\n note\n [2] {\"kind\":\"note\",\"payload\":{\"text\":\"hello\"}}\n" + if err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } + buf.Reset() + if err := printTable(&buf, rows); err != nil { + t.Fatal(err) + } + if got, want := buf.String(), "KIND HIDDEN\nnote secret\n"; got != want { + t.Fatalf("direct list changed: got %q want %q", got, want) + } +} + +func TestPrintTableSingleUnionDetails(t *testing.T) { + for name, row := range map[string]nestedTableUnion{ + "known": {Kind: "note", Variant: &nestedTableEntry{Kind: "note", Payload: map[string]any{"text": "hello"}}}, + "unknown": {Kind: "future", UnknownRaw: json.RawMessage(`{"kind":"future","text":"hello"}`)}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{row}}) + if err != nil || !strings.Contains(buf.String(), "[1] {") || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || strings.Contains(buf.String(), "UNKNOWNRAW") { + t.Fatalf("unexpected union table %q, error %v", buf.String(), err) + } + }) + } +} + +func TestPrintTableSingleControls(t *testing.T) { + value := "line\ncolumn\ttab\rreturn\vvertical\fform\x00nul\x1bescape\x7fdelete\u0085next" + var buf bytes.Buffer + err := printTable(&buf, struct { + Name string `json:"name"` + Metadata map[string]string `json:"metadata"` + Entries []nestedTableEntry `json:"entries"` + }{value, map[string]string{"text": value}, []nestedTableEntry{ {Kind: value, Payload: map[string]any{"text": value}}}}) + if err != nil { + t.Fatal(err) + } + for _, r := range buf.String() { + if r != '\n' && (r < 32 || (r >= 127 && r <= 159)) { + t.Fatalf("unescaped control %U in %q", r, buf.String()) + } + } + for _, line := range strings.Split(buf.String(), "\n") { + if start := strings.Index(line, "{"); start >= 0 { + if !json.Valid([]byte(line[start:])) { + t.Fatalf("invalid JSON cell: %q", line) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(line[start:]), &decoded); err != nil { + t.Fatalf("invalid JSON detail %q: %v", line, err) + } + if text, ok := decoded["text"]; ok && text != value { + t.Fatalf("control text changed: %q", text) + } + if payload, ok := decoded["payload"].(map[string]any); ok && payload["text"] != value { + t.Fatalf("detail control text changed: %q", payload["text"]) + } + } + } + if strings.Count(buf.String(), "\n") != 7 { + t.Fatalf("payload broke table lines: %q", buf.String()) + } +} + +func TestPrintTableSingleEmptyAndHidden(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Zero int `json:"zero"` + False bool `json:"false"` + Empty string `json:"empty"` + NilScalar *string `json:"nil_scalar"` + NilMap map[string]string `json:"nil_map"` + EmptyMap map[string]string `json:"empty_map"` + EmptySlice []map[string]string `json:"empty_slice"` + Hidden []nestedTableEntry `json:"-"` + }{EmptyMap: map[string]string{}, EmptySlice: []map[string]string{}, Hidden: []nestedTableEntry{ {Kind: "secret"}}}) + want := "ZERO 0\nFALSE false\nEMPTY \nNIL_SCALAR \nEMPTY_MAP {}\nEMPTY_SLICE []\n" + if err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } +} + +func TestPrintTableSingleHiddenOnlyRows(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { Entries []struct { Hidden string `json:"-"` } `json:"entries"` }{Entries: []struct { Hidden string `json:"-"` }{ {"secret"}}}) + if err != nil || buf.String() != "ENTRIES [{}]\n" { + t.Fatalf("unexpected hidden-only table %q, error %v", buf.String(), err) + } +} + +func TestPrintTableSingleNullUnionDetail(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{ {Kind: "unknown", UnknownRaw: json.RawMessage(`null`)}}}) + if err != nil || buf.String() != "ENTRIES\n KIND\n unknown\n" { + t.Fatalf("unexpected null detail %q, error %v", buf.String(), err) + } +} + +func TestPrintTableSingleMarshalError(t *testing.T) { + for name, object := range map[string]any{ + "field": struct { Metadata map[string]any `json:"metadata"` }{map[string]any{"bad": make(chan int)}}, + "custom marshaler": struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{ {Kind: "unknown", UnknownRaw: json.RawMessage(`invalid`)}}}, + "row": struct { Entries []nestedTableEntry `json:"entries"` }{[]nestedTableEntry{ {Kind: "note", Payload: map[string]any{"bad": make(chan int)}}}}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, object) + if err == nil || !strings.Contains(err.Error(), "table field") || buf.Len() != 0 { + t.Fatalf("expected contextual error without partial output; got %q, error %v", buf.String(), err) + } + }) + } +} + {{- if hasCliAsyncIntents}} func TestAwaitAsyncDeadlineKeepsPollError(t *testing.T) { diff --git a/templates/templates/cli/cli_harness_test.go.stmpl b/templates/templates/cli/cli_harness_test.go.stmpl index 99da8bd73..203a1d176 100644 --- a/templates/templates/cli/cli_harness_test.go.stmpl +++ b/templates/templates/cli/cli_harness_test.go.stmpl @@ -1113,8 +1113,9 @@ func TestTableOutputRendersResourceSections(t *testing.T) { }, } assert.Equal(t, tableLines( - "ID o1", - "NAME sam", + "ID o1", + "NAME sam", + `PARENT {"created":"0001-01-01T00:00:00Z"}`, "", "MEMBERS", " NAME CREATED TAGS", diff --git a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl new file mode 100644 index 000000000..01e0018d5 --- /dev/null +++ b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl @@ -0,0 +1,63 @@ +package tests + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTableNestedResourceOutput(t *testing.T) { + type entry struct { + Kind string `json:"kind"` + Payload map[string]string `json:"payload"` + } + object := struct { + ID string `json:"id"` + Metadata map[string]int `json:"metadata"` + Entries []entry `json:"entries"` + }{"record-1", map[string]int{"count": 2}, []entry{ {"note", map[string]string{"text": "hello\nworld\tvalue"}}}} + got := renderTable(t, object) + assert.Equal(t, tableLines( + "ID record-1", + `METADATA {"count":2}`, + "", + "ENTRIES", + " KIND", + " note", + ` [1] {"kind":"note","payload":{"text":"hello\nworld\tvalue"}}`, + ), got) + assert.Equal(t, tableLines("KIND", "note"), renderTable(t, object.Entries)) +} + +func TestTableGeneratedUnionWireShape(t *testing.T) { + s := newEchoServer() + defer s.Close() + h := NewCLITestHarness(t) + err := h.RunWithServer(s.URL, []string{"unions", "strongly-typed-one-of-post", + "--strongly-typed-one-of-object", deepObjectWithTypeJSON, + "--output-format", "table", + }) + require.NoError(t, err) + got := h.GetStdout() + assert.Contains(t, got, `"str":"test"`) + assert.NotContains(t, got, `"DeepObjectWithType"`) + assert.NotContains(t, got, `"SimpleObjectWithType"`) + assert.NotContains(t, got, "0x") + found := false + for _, line := range strings.Split(got, "\n") { + if start := strings.Index(line, "{"); start >= 0 { + assert.True(t, json.Valid([]byte(line[start:])), "invalid JSON table cell: %s", line) + var decoded map[string]any + require.NoError(t, json.Unmarshal([]byte(line[start:]), &decoded)) + assert.Equal(t, "deepObjectWithType", decoded["type"]) + object, ok := decoded["obj"].(map[string]any) + require.True(t, ok, "expected nested object in generated union wire shape") + assert.Equal(t, "test", object["str"]) + found = true + } + } + assert.True(t, found, "expected a compact JSON cell") +} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index ba46d959c..bffadf36d 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -274,9 +274,9 @@ trackedFiles: internal/output/jq.go: last_write_checksum: sha1:4ec4f6429c1d7eaa780a9e33147ba8087b678169 internal/output/output.go: - last_write_checksum: sha1:83b7169a0320aaf7f3462504dca6799f856b55d5 + last_write_checksum: sha1:689ccc0f8589e8582ed35fe76e33a8e956a05a32 internal/output/output_test.go: - last_write_checksum: sha1:a3d8e11b6ad5fa2b199decf5dc69771632db3417 + last_write_checksum: sha1:9f0eb8525a1051b7589b9e80bde3ff389323b24f internal/output/outputitems.go: last_write_checksum: sha1:d327eee8d9a395821fef201257e033bae50cda51 internal/output/paginated.go: @@ -656,7 +656,7 @@ trackedFiles: tests/async_test.go: last_write_checksum: sha1:fb5dafb085f988d482359c61cdb2b800528ceed0 tests/cli_harness_test.go: - last_write_checksum: sha1:b347035c99b9f95ac8e82a7e5d30afe78ed92c23 + last_write_checksum: sha1:228abfbf690b0df6828853bf11a2e4bb2920283e tests/common_helpers_test.go: last_write_checksum: sha1:02f189ac10117b746a3691b30c87fecf518292fa tests/conflicts_test.go: diff --git a/zSDKs/sdk-cli/internal/output/output.go b/zSDKs/sdk-cli/internal/output/output.go index 53142356a..94cf89d61 100644 --- a/zSDKs/sdk-cli/internal/output/output.go +++ b/zSDKs/sdk-cli/internal/output/output.go @@ -1303,7 +1303,7 @@ func applyJqToTyped(out io.Writer, content interface{}, jqExpr string, colorize, // For slices/arrays of structs, each struct becomes a row with struct fields as columns. // For single structs, outputs a vertical key-value table. Slice-of-struct fields // (such as the items of a list response) render as titled sub-tables. -// Other complex nested fields are skipped. +// Single objects include complex fields as JSON cells or indexed section details. func printTable(out io.Writer, content any) error { v := reflect.ValueOf(content) for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { @@ -1394,117 +1394,295 @@ func printTableRows(out io.Writer, v reflect.Value) error { return tw.Flush() } -// printTableSingle renders a single struct as a vertical key-value table. func printTableSingle(out io.Writer, v reflect.Value) error { - if hasTableSections(v.Type()) { - return printTableSections(out, v) + t := v.Type() + sections := hasTableSections(t) + var buf bytes.Buffer + var tw *tabwriter.Writer + blocks := 0 + startBlock := func() { + if blocks > 0 { + fmt.Fprintln(&buf) + } + blocks++ } - cols := collectTableColumns(v.Type()) - if len(cols) == 0 { + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !singleTableField(f) { + continue + } + value := v.Field(i) + if isTableSection(f.Type) { + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + tw = nil + } + startBlock() + fmt.Fprintln(&buf, safeTableText(strings.ToUpper(tableFieldName(f)))) + var section bytes.Buffer + if err := printSingleTableRows(§ion, value); err != nil { + return fmt.Errorf("table field %s: %w", tableFieldName(f), err) + } + for _, line := range strings.SplitAfter(section.String(), "\n") { + if line != "" { + buf.WriteString(" " + line) + } + } + continue + } + if nilSingleTableValue(value) && (sections || !isTableScalar(f.Type)) { + continue + } + cell, err := singleTableCell(value) + if err != nil { + return fmt.Errorf("table field %s: %w", tableFieldName(f), err) + } + if tw == nil { + startBlock() + tw = newTabWriter(&buf) + } + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(strings.ToUpper(tableFieldName(f))), cell) + } + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + } + if blocks == 0 { return fmt.Errorf("no displayable fields found") } + _, err := out.Write(buf.Bytes()) + return err +} - tw := newTabWriter(out) - for _, c := range cols { - fmt.Fprintf(tw, "%s\t%s\n", strings.ToUpper(c.name), formatTableCell(v.Field(c.index))) +func singleTableField(f reflect.StructField) bool { + return f.IsExported() && strings.Split(f.Tag.Get("json"), ",")[0] != "-" && f.Type.Kind() != reflect.Func && f.Type.Kind() != reflect.Chan +} + +func singleTableColumns(t reflect.Type) []tableColumn { + var cols []tableColumn + for _, c := range collectTableColumns(t) { + if singleTableField(t.Field(c.index)) { + cols = append(cols, c) + } } - return tw.Flush() + return cols } -// isTableSection reports whether a field of type t is a slice or array of -// structs with displayable columns, rendered as its own sub-table. func isTableSection(t reflect.Type) bool { t = derefType(t) + marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() + if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) { + return false + } if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { return false } elem := derefType(t.Elem()) - return elem.Kind() == reflect.Struct && len(collectTableColumns(elem)) > 0 + return elem.Kind() == reflect.Struct && len(singleTableColumns(elem)) > 0 } func hasTableSections(t reflect.Type) bool { for i := 0; i < t.NumField(); i++ { - if t.Field(i).IsExported() && isTableSection(t.Field(i).Type) { + if singleTableField(t.Field(i)) && isTableSection(t.Field(i).Type) { return true } } return false } -// printTableSections renders a struct in field order: runs of non-nil scalar -// fields as key-value blocks and each slice-of-struct field as a titled, -// indented sub-table. -func printTableSections(out io.Writer, v reflect.Value) error { - t := v.Type() - var buf bytes.Buffer - var tw *tabwriter.Writer - blocks := 0 - startBlock := func() { - if blocks > 0 { - fmt.Fprintln(&buf) +func nilSingleTableValue(v reflect.Value) bool { + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return true } - blocks++ + v = v.Elem() } - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if !f.IsExported() { - continue + return (v.Kind() == reflect.Slice || v.Kind() == reflect.Map) && v.IsNil() +} + +func complexTableValue(v reflect.Value) bool { + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return false } - if isTableScalar(f.Type) { - if isNilTableValue(v.Field(i)) { - continue - } - if tw == nil { - startBlock() - tw = newTabWriter(&buf) + v = v.Elem() + } + if v.Type() == reflect.TypeOf(json.RawMessage{}) { + return true + } + switch v.Kind() { + case reflect.Struct: + return v.Type() != timeType + case reflect.Map: + return true + case reflect.Array, reflect.Slice: + if !isTableScalar(v.Type()) || derefType(v.Type().Elem()).Kind() == reflect.Slice || derefType(v.Type().Elem()).Kind() == reflect.Array { + return true + } + for i := 0; i < v.Len(); i++ { + if complexTableValue(v.Index(i)) { + return true } - fmt.Fprintf(tw, "%s\t%s\n", strings.ToUpper(tableFieldName(f)), formatTableCell(v.Field(i))) - continue } - if !isTableSection(f.Type) { - continue + } + return false +} + +func tableJSON(v reflect.Value) (string, error) { + for v.Kind() == reflect.Interface && !v.IsNil() { + v = v.Elem() + } + for v.Kind() == reflect.Ptr && !v.IsNil() { + if _, ok := v.Interface().(json.Marshaler); ok { + return marshalTableJSON(v.Interface()) } - if tw != nil { - if err := tw.Flush(); err != nil { - return err - } - tw = nil - } - startBlock() - fmt.Fprintln(&buf, strings.ToUpper(tableFieldName(f))) - rows := v.Field(i) - for rows.Kind() == reflect.Ptr && !rows.IsNil() { - rows = rows.Elem() - } - var section bytes.Buffer - if rows.Kind() == reflect.Ptr { - fmt.Fprintln(§ion, "(empty)") - } else if err := printTableRows(§ion, rows); err != nil { - return err + v = v.Elem() + } + address := v + if v.Kind() != reflect.Ptr { + if v.CanAddr() { + address = v.Addr() + } else { + address = reflect.New(v.Type()) + address.Elem().Set(v) } - for _, line := range strings.SplitAfter(section.String(), "\n") { - if line != "" { - buf.WriteString(" " + line) + } + if _, ok := address.Interface().(json.Marshaler); ok { + return marshalTableJSON(address.Interface()) + } + if (v.Kind() == reflect.Slice || v.Kind() == reflect.Array) && v.Type().Elem().Kind() == reflect.Interface && !nilSingleTableValue(v) { + items := make([]any, v.Len()) + for i := 0; i < v.Len(); i++ { + item := v.Index(i) + for item.Kind() == reflect.Interface && !item.IsNil() { + item = item.Elem() + } + if item.Kind() == reflect.Struct { + copy := reflect.New(item.Type()) + copy.Elem().Set(item) + items[i] = copy.Interface() + } else { + items[i] = item.Interface() } } + return marshalTableJSON(items) } - if tw != nil { - if err := tw.Flush(); err != nil { - return err + return marshalTableJSON(address.Interface()) +} + +func marshalTableJSON(value any) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return safeTableJSON(string(data)), nil +} + +func singleTableCell(v reflect.Value) (string, error) { + if complexTableValue(v) { + return tableJSON(v) + } + return safeTableText(formatTableCell(v)), nil +} + +func safeTableJSON(s string) string { + var b strings.Builder + for _, r := range s { + if r < 32 || (r >= 127 && r <= 159) { + fmt.Fprintf(&b, "\\u%04x", r) + } else { + b.WriteRune(r) } } - _, err := out.Write(buf.Bytes()) - return err + return b.String() } -func isNilTableValue(v reflect.Value) bool { - for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { - if v.IsNil() { +func safeTableText(s string) string { + var b strings.Builder + for _, r := range s { + if r >= 127 && r <= 159 { + fmt.Fprintf(&b, "\\u%04x", r) + } else if r < 32 { + escaped := strconv.QuoteToASCII(string(r)) + b.WriteString(escaped[1 : len(escaped)-1]) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func singleTableRowNeedsDetail(v reflect.Value) bool { + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + if singleTableField(f) && !nilSingleTableValue(v.Field(i)) && complexTableValue(v.Field(i)) { return true } - v = v.Elem() + if f.IsExported() && strings.Split(f.Tag.Get("json"), ",")[0] == "-" && !v.Field(i).IsZero() { + if _, ok := reflect.New(v.Type()).Interface().(json.Marshaler); ok { + return true + } + } + } + return false +} + +func printSingleTableRows(out io.Writer, rows reflect.Value) error { + for rows.Kind() == reflect.Ptr { + if rows.IsNil() { + fmt.Fprintln(out, "(empty)") + return nil + } + rows = rows.Elem() + } + if rows.Len() == 0 { + fmt.Fprintln(out, "(empty)") + return nil + } + cols := singleTableColumns(derefType(rows.Type().Elem())) + tw := newTabWriter(out) + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = safeTableText(strings.ToUpper(c.name)) } - return v.Kind() == reflect.Slice && v.IsNil() + fmt.Fprintln(tw, strings.Join(headers, "\t")) + var details bytes.Buffer + for i := 0; i < rows.Len(); i++ { + original := rows.Index(i) + row := original + for row.Kind() == reflect.Ptr && !row.IsNil() { + row = row.Elem() + } + if row.Kind() != reflect.Struct { + continue + } + vals := make([]string, len(cols)) + for j, c := range cols { + cell, err := singleTableCell(row.Field(c.index)) + if err != nil { + return fmt.Errorf("row %d field %s: %w", i+1, c.name, err) + } + vals[j] = cell + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + if singleTableRowNeedsDetail(row) { + data, err := tableJSON(original) + if err != nil { + return fmt.Errorf("row %d: %w", i+1, err) + } + if data != "null" { + fmt.Fprintf(&details, "[%d] %s\n", i+1, data) + } + } + } + if err := tw.Flush(); err != nil { + return err + } + _, err := out.Write(details.Bytes()) + return err } // printTableMap renders a map as a two-column key-value table. diff --git a/zSDKs/sdk-cli/internal/output/output_test.go b/zSDKs/sdk-cli/internal/output/output_test.go index 068f8837f..b19cc6c11 100644 --- a/zSDKs/sdk-cli/internal/output/output_test.go +++ b/zSDKs/sdk-cli/internal/output/output_test.go @@ -5,6 +5,7 @@ package output import ( "bytes" "context" + "encoding/json" "errors" "reflect" "strings" @@ -36,6 +37,231 @@ func TestPrintTableDerivesColumnsFromFirstStructElement(t *testing.T) { } } +type nestedTableEntry struct { + Kind string `json:"kind"` + Payload map[string]any `json:"payload,omitempty"` + Hidden string `json:"-"` +} + +type nestedTableUnion struct { + Kind string `json:"kind"` + Variant *nestedTableEntry `json:"variant,omitempty"` + UnknownRaw json.RawMessage `json:"-"` +} + +func (u *nestedTableUnion) MarshalJSON() ([]byte, error) { + if u.UnknownRaw != nil { + return u.UnknownRaw, nil + } + return json.Marshal(u.Variant) +} + +type customTableSlice []any + +func (customTableSlice) MarshalJSON() ([]byte, error) { + return []byte(`{"text":"hello"}`), nil +} + +type customTableRows []nestedTableEntry + +func (customTableRows) MarshalJSON() ([]byte, error) { + return []byte(`{"text":"hello"}`), nil +} + +type pointerTableRows []nestedTableEntry + +func (*pointerTableRows) MarshalJSON() ([]byte, error) { + return []byte(`{"text":"hello"}`), nil +} + +func TestPrintTableSingleContainerMarshaler(t *testing.T) { + for name, object := range map[string]any{ + "value": struct { + Entries customTableRows `json:"entries"` + }{customTableRows{{Kind: "internal"}}}, + "pointer receiver": struct { + Entries pointerTableRows `json:"entries"` + }{pointerTableRows{{Kind: "internal"}}}, + "pointer field": struct { + Entries *pointerTableRows `json:"entries"` + }{&pointerTableRows{{Kind: "internal"}}}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, object) + if err != nil || buf.String() != "ENTRIES {\"text\":\"hello\"}\n" { + t.Fatalf("container marshaler bypassed: got %q, error %v", buf.String(), err) + } + }) + } +} + +func TestPrintTableSingleNestedValues(t *testing.T) { + for name, value := range map[string]any{ + "map": map[string]any{"text": "hello"}, + "object": struct { + Text string `json:"text"` + }{"hello"}, + "array": [1]map[string]string{{"text": "hello"}}, + "map slice": []map[string]string{{"text": "hello"}}, + "nested slice": [][]string{{"hello"}}, + "interface slice": []any{map[string]string{"text": "hello"}}, + "raw JSON": json.RawMessage(`{"text":"hello"}`), + "pointer marshaler": nestedTableUnion{Variant: &nestedTableEntry{Kind: "hello"}}, + "interface marshaler": []any{nestedTableUnion{Variant: &nestedTableEntry{Kind: "hello"}}}, + "pointer interface slice": &[]any{nestedTableUnion{Variant: &nestedTableEntry{Kind: "hello"}}}, + "custom container": customTableSlice{nestedTableUnion{Variant: &nestedTableEntry{Kind: "wrong"}}}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Value any `json:"wire_value"` + }{value}) + if err != nil || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || !strings.HasPrefix(buf.String(), "WIRE_VALUE ") { + t.Fatalf("unexpected table: %q, error: %v", buf.String(), err) + } + }) + } +} + +func TestPrintTableSingleSectionDetails(t *testing.T) { + rows := []*nestedTableEntry{nil, {Kind: "note", Payload: map[string]any{"text": "hello"}, Hidden: "secret"}} + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []*nestedTableEntry `json:"entries"` + }{rows}) + want := "ENTRIES\n KIND\n note\n [2] {\"kind\":\"note\",\"payload\":{\"text\":\"hello\"}}\n" + if err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } + buf.Reset() + if err := printTable(&buf, rows); err != nil { + t.Fatal(err) + } + if got, want := buf.String(), "KIND HIDDEN\nnote secret\n"; got != want { + t.Fatalf("direct list changed: got %q want %q", got, want) + } +} + +func TestPrintTableSingleUnionDetails(t *testing.T) { + for name, row := range map[string]nestedTableUnion{ + "known": {Kind: "note", Variant: &nestedTableEntry{Kind: "note", Payload: map[string]any{"text": "hello"}}}, + "unknown": {Kind: "future", UnknownRaw: json.RawMessage(`{"kind":"future","text":"hello"}`)}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []nestedTableUnion `json:"entries"` + }{[]nestedTableUnion{row}}) + if err != nil || !strings.Contains(buf.String(), "[1] {") || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || strings.Contains(buf.String(), "UNKNOWNRAW") { + t.Fatalf("unexpected union table %q, error %v", buf.String(), err) + } + }) + } +} + +func TestPrintTableSingleControls(t *testing.T) { + value := "line\ncolumn\ttab\rreturn\vvertical\fform\x00nul\x1bescape\x7fdelete\u0085next" + var buf bytes.Buffer + err := printTable(&buf, struct { + Name string `json:"name"` + Metadata map[string]string `json:"metadata"` + Entries []nestedTableEntry `json:"entries"` + }{value, map[string]string{"text": value}, []nestedTableEntry{{Kind: value, Payload: map[string]any{"text": value}}}}) + if err != nil { + t.Fatal(err) + } + for _, r := range buf.String() { + if r != '\n' && (r < 32 || (r >= 127 && r <= 159)) { + t.Fatalf("unescaped control %U in %q", r, buf.String()) + } + } + for _, line := range strings.Split(buf.String(), "\n") { + if start := strings.Index(line, "{"); start >= 0 { + if !json.Valid([]byte(line[start:])) { + t.Fatalf("invalid JSON cell: %q", line) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(line[start:]), &decoded); err != nil { + t.Fatalf("invalid JSON detail %q: %v", line, err) + } + if text, ok := decoded["text"]; ok && text != value { + t.Fatalf("control text changed: %q", text) + } + if payload, ok := decoded["payload"].(map[string]any); ok && payload["text"] != value { + t.Fatalf("detail control text changed: %q", payload["text"]) + } + } + } + if strings.Count(buf.String(), "\n") != 7 { + t.Fatalf("payload broke table lines: %q", buf.String()) + } +} + +func TestPrintTableSingleEmptyAndHidden(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Zero int `json:"zero"` + False bool `json:"false"` + Empty string `json:"empty"` + NilScalar *string `json:"nil_scalar"` + NilMap map[string]string `json:"nil_map"` + EmptyMap map[string]string `json:"empty_map"` + EmptySlice []map[string]string `json:"empty_slice"` + Hidden []nestedTableEntry `json:"-"` + }{EmptyMap: map[string]string{}, EmptySlice: []map[string]string{}, Hidden: []nestedTableEntry{{Kind: "secret"}}}) + want := "ZERO 0\nFALSE false\nEMPTY \nNIL_SCALAR \nEMPTY_MAP {}\nEMPTY_SLICE []\n" + if err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } +} + +func TestPrintTableSingleHiddenOnlyRows(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []struct { + Hidden string `json:"-"` + } `json:"entries"` + }{Entries: []struct { + Hidden string `json:"-"` + }{{"secret"}}}) + if err != nil || buf.String() != "ENTRIES [{}]\n" { + t.Fatalf("unexpected hidden-only table %q, error %v", buf.String(), err) + } +} + +func TestPrintTableSingleNullUnionDetail(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []nestedTableUnion `json:"entries"` + }{[]nestedTableUnion{{Kind: "unknown", UnknownRaw: json.RawMessage(`null`)}}}) + if err != nil || buf.String() != "ENTRIES\n KIND\n unknown\n" { + t.Fatalf("unexpected null detail %q, error %v", buf.String(), err) + } +} + +func TestPrintTableSingleMarshalError(t *testing.T) { + for name, object := range map[string]any{ + "field": struct { + Metadata map[string]any `json:"metadata"` + }{map[string]any{"bad": make(chan int)}}, + "custom marshaler": struct { + Entries []nestedTableUnion `json:"entries"` + }{[]nestedTableUnion{{Kind: "unknown", UnknownRaw: json.RawMessage(`invalid`)}}}, + "row": struct { + Entries []nestedTableEntry `json:"entries"` + }{[]nestedTableEntry{{Kind: "note", Payload: map[string]any{"bad": make(chan int)}}}}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, object) + if err == nil || !strings.Contains(err.Error(), "table field") || buf.Len() != 0 { + t.Fatalf("expected contextual error without partial output; got %q, error %v", buf.String(), err) + } + }) + } +} + func TestAwaitAsyncDeadlineKeepsPollError(t *testing.T) { cfg := &asyncConfig{Command: "wait", Backoff: 1, States: map[string]string{"pending": "pending"}} timings := asyncTimings{interval: time.Millisecond, maxInterval: time.Millisecond, timeout: 500 * time.Millisecond} diff --git a/zSDKs/sdk-cli/tests/cli_harness_test.go b/zSDKs/sdk-cli/tests/cli_harness_test.go index a96db167c..64abf7f20 100644 --- a/zSDKs/sdk-cli/tests/cli_harness_test.go +++ b/zSDKs/sdk-cli/tests/cli_harness_test.go @@ -945,8 +945,9 @@ func TestTableOutputRendersResourceSections(t *testing.T) { }, } assert.Equal(t, tableLines( - "ID o1", - "NAME sam", + "ID o1", + "NAME sam", + `PARENT {"created":"0001-01-01T00:00:00Z"}`, "", "MEMBERS", " NAME CREATED TAGS", From a46ac1ff482abcde8f7a33002410710903b8b29b Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 16:19:44 +0100 Subject: [PATCH 2/5] test(cli): cover nested streaming tables Verify compact JSON data cells for streamed union events and document the per-item pretty fallback and single-result error behavior. --- templates/templates/cli/README.md | 4 ++- .../primary/table_additional_test.go.stmpl | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index d952f753d..dd25fad4a 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -672,11 +672,13 @@ Three output paths: - `pretty` (default): Aligned key-value pairs with color, nested indentation - `json`: Marshaled from the typed Go struct (note: field order and precision may differ from original API JSON) - `yaml`: Via `gopkg.in/yaml.v3` - - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output. Single-object nested structs, maps, and complex arrays render as compact JSON cells using wire names and custom JSON marshalers. Existing section columns stay in place; rows with populated nested values gain a compact JSON detail line below the table, indexed by their one-based position in the original array (nil rows are skipped). A complex list wrapper receives these details too: runtime response shapes do not distinguish a wrapper from a resource. Direct array tables and `--all` tables that aggregate a declared results array of structs keep their existing scalar columns and output. Pagination without a results path renders whole page objects; complex sections in those page objects also gain indexed details. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values, retain non-nil empty maps/arrays, and report serialization errors with field/row context + - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output. Single-object nested structs, maps, and complex arrays render as compact JSON cells using wire names and custom JSON marshalers. Existing section columns stay in place; rows with populated nested values gain a compact JSON detail line below the table, indexed by their one-based position in the original array (nil rows are skipped). A complex list wrapper receives these details too: runtime response shapes do not distinguish a wrapper from a resource. Direct array tables and `--all` tables that aggregate a declared results array of structs keep their existing scalar columns and output. Pagination without a results path renders whole page objects; complex sections in those page objects also gain indexed details. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values, retain non-nil empty maps/arrays, and use field/row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails - `toon`: [Token-Oriented Object Notation](https://github.com/toon-format/spec) via `github.com/alpkeskin/gotoon` — compact, line-oriented, 30–60% fewer tokens than JSON. gotoon omits the final newline, so `encodeTOON()` terminates non-empty output with one, matching JSON and YAML and giving per-item streaming/pagination output its blank-line separator `pretty`, `yaml` and `toon` never reflect over the typed Go structs directly. `normalizeForOutput()` (`output.go.stmpl`) marshals the value once through the SDK's `utils.MarshalJSON` and decodes it back with `json.Decoder.UseNumber`, and the renderers work from that wire shape (`marshalYAML()`, `encodeTOON()`, `prettyPrint()`, shared by `Result()` and the per-item streaming/pagination path in `outputitems.go.stmpl`). Reflecting encoders would otherwise leak generated union internals — wrapper keys such as `ErrorEvent:`, `null` sibling variants, `Type`/`UnknownRaw` — plus raw struct-tag options as keys (`"error,omitzero":`) and lowercased Go field names in YAML. Numbers stay exact: `json.Number` is converted per renderer (`convertNumbers()` with `yamlNumber()`/`toonNumber()`, `formatJSONNumber()` for pretty) to a native `int64`/`float64` only when that value re-encodes to the identical JSON lexeme (`nativeNumber()`); otherwise the lexeme is kept verbatim — a tagged `!!int`/`!!float` scalar node for yaml, a string for TOON (which has no wider numeric type), the literal for pretty — so bigint/decimal fields never lose digits. TOON's own encoder goes through `float64`, so native integers above 2^53 round there (a gotoon property, unchanged). `--include-headers` output (`outputWithHeaders()`) already worked from the marshalled JSON. Covered by `TestEventStreamUnionEventsRenderWireShape` and `TestEventStreamNumbersKeepPrecision` in `tests/primary/eventstreaming_test.go.stmpl`. +Streaming `--output-format table` applies the same single-object renderer to each event, with a blank line between events. An event wrapper containing only a nested `data` value now renders a `DATA` compact JSON cell; it previously had no displayable table fields and fell back to pretty output. JSON, YAML, TOON, and pretty streaming formats keep their existing rendering paths. Covered by `TestTableStreamingUnionDataCells` in `tests/primary/table_additional_test.go.stmpl`. + 3. **jq filtering** (`--jq`): Applies a [gojq](https://github.com/itchyny/gojq) expression to the JSON output. Overrides `--output-format` since the result is always JSON — except under `--raw-output` (jq `-r` semantics: string results are written as raw text plus a newline, unquoted, unescaped, never colorized; non-string results stay JSON), so `--jq '.data' --raw-output | base64 -d > image.png` works. The flag's default is the `cli.jqRawOutput` gen.yaml key (default `false`); it threads through `outputJqResults` (single results, `--include-headers`, artifacts) and `outputOneItem` (streams, `--all` pagination). **Color default** (`cli.defaultColor` gen.yaml key: `auto` | `always` | `never`, default `auto`): the default value of `--color`. `never` makes uncolored ("raw") output the default so redirected/piped bytes are always clean; `--color`, `NO_COLOR`/`FORCE_COLOR`, and agent mode (never colorized) still apply. JSON and pretty-output keys use bold sky blue (`#38BDF8`), matching the default interactive accent rather than terminal dark blue. diff --git a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl index 01e0018d5..da5b572f2 100644 --- a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl +++ b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl @@ -61,3 +61,29 @@ func TestTableGeneratedUnionWireShape(t *testing.T) { } assert.True(t, found, "expected a compact JSON cell") } + +func TestTableStreamingUnionDataCells(t *testing.T) { + server := newSSEServer(t, + `{"event_type":"delta","text":"Hello\n\t\u007f"}`, + `{"event_type":"delta","text":"world"}`, + ) + defer server.Close() + + h := NewCLITestHarness(t) + err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events", "--output-format", "table"}) + require.NoError(t, err) + lines := strings.Split(strings.TrimSuffix(h.GetStdout(), "\n"), "\n") + require.Len(t, lines, 3) + assert.Empty(t, lines[1]) + for i, text := range []string{"Hello\n\t\x7f", "world"} { + line := lines[i*2] + require.True(t, strings.HasPrefix(line, "DATA "), "expected a table cell, got %q", line) + data := strings.TrimPrefix(line, "DATA ") + require.True(t, json.Valid([]byte(data)), "invalid JSON table cell: %s", data) + var event map[string]any + require.NoError(t, json.Unmarshal([]byte(data), &event)) + assert.Equal(t, map[string]any{"event_type": "delta", "text": text}, event) + } + assert.NotContains(t, h.GetStdout(), "DiscriminatedDeltaEvent") + assert.NotContains(t, h.GetStdout(), "UnknownRaw") +} From c29dc2af4b28a7074167e235e27ccb9dc07d1c7b Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 16:30:19 +0100 Subject: [PATCH 3/5] fix(cli): honor scalar JSON marshalers Use custom JSON wire values for single-object scalar cells, including pointer-receiver values in interface arrays. Preserve timestamp and direct-list formatting. --- templates/templates/cli/README.md | 2 +- .../auxiliary/internal/output/output.go.stmpl | 11 ++- .../internal/output/output_test.go.stmpl | 74 ++++++++++++++++- zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/output/output.go | 11 ++- zSDKs/sdk-cli/internal/output/output_test.go | 81 +++++++++++++++++++ 6 files changed, 173 insertions(+), 10 deletions(-) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index dd25fad4a..f247f8b3b 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -672,7 +672,7 @@ Three output paths: - `pretty` (default): Aligned key-value pairs with color, nested indentation - `json`: Marshaled from the typed Go struct (note: field order and precision may differ from original API JSON) - `yaml`: Via `gopkg.in/yaml.v3` - - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output. Single-object nested structs, maps, and complex arrays render as compact JSON cells using wire names and custom JSON marshalers. Existing section columns stay in place; rows with populated nested values gain a compact JSON detail line below the table, indexed by their one-based position in the original array (nil rows are skipped). A complex list wrapper receives these details too: runtime response shapes do not distinguish a wrapper from a resource. Direct array tables and `--all` tables that aggregate a declared results array of structs keep their existing scalar columns and output. Pagination without a results path renders whole page objects; complex sections in those page objects also gain indexed details. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values, retain non-nil empty maps/arrays, and use field/row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails + - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output. Single-object nested structs, maps, and complex arrays render as compact JSON cells using wire names and custom JSON marshalers. Existing section columns stay in place; rows with populated nested values gain a compact JSON detail line below the table, indexed by their one-based position in the original array (nil rows are skipped). A complex list wrapper receives these details too: runtime response shapes do not distinguish a wrapper from a resource. Direct array tables and `--all` tables that aggregate a declared results array of structs keep their existing scalar columns and output. Pagination without a results path renders whole page objects; complex sections in those page objects also gain indexed details. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values, retain non-nil empty maps/arrays, and use field/row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and per-item pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails. Declared-results `--all` struct aggregation bypasses `outputOneItem()` and uses the existing `PaginatedResult()` `flushRows` fallback - `toon`: [Token-Oriented Object Notation](https://github.com/toon-format/spec) via `github.com/alpkeskin/gotoon` — compact, line-oriented, 30–60% fewer tokens than JSON. gotoon omits the final newline, so `encodeTOON()` terminates non-empty output with one, matching JSON and YAML and giving per-item streaming/pagination output its blank-line separator `pretty`, `yaml` and `toon` never reflect over the typed Go structs directly. `normalizeForOutput()` (`output.go.stmpl`) marshals the value once through the SDK's `utils.MarshalJSON` and decodes it back with `json.Decoder.UseNumber`, and the renderers work from that wire shape (`marshalYAML()`, `encodeTOON()`, `prettyPrint()`, shared by `Result()` and the per-item streaming/pagination path in `outputitems.go.stmpl`). Reflecting encoders would otherwise leak generated union internals — wrapper keys such as `ErrorEvent:`, `null` sibling variants, `Type`/`UnknownRaw` — plus raw struct-tag options as keys (`"error,omitzero":`) and lowercased Go field names in YAML. Numbers stay exact: `json.Number` is converted per renderer (`convertNumbers()` with `yamlNumber()`/`toonNumber()`, `formatJSONNumber()` for pretty) to a native `int64`/`float64` only when that value re-encodes to the identical JSON lexeme (`nativeNumber()`); otherwise the lexeme is kept verbatim — a tagged `!!int`/`!!float` scalar node for yaml, a string for TOON (which has no wider numeric type), the literal for pretty — so bigint/decimal fields never lose digits. TOON's own encoder goes through `float64`, so native integers above 2^53 round there (a gotoon property, unchanged). `--include-headers` output (`outputWithHeaders()`) already worked from the marshalled JSON. Covered by `TestEventStreamUnionEventsRenderWireShape` and `TestEventStreamNumbersKeepPrecision` in `tests/primary/eventstreaming_test.go.stmpl`. diff --git a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index 7f014220d..0474cf1a8 100644 --- a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl @@ -1528,12 +1528,16 @@ func complexTableValue(v reflect.Value) bool { } v = v.Elem() } - if v.Type() == reflect.TypeOf(json.RawMessage{}) { + if v.Type() == timeType { + return false + } + marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() + if v.Type().Implements(marshaler) || reflect.PointerTo(v.Type()).Implements(marshaler) { return true } switch v.Kind() { case reflect.Struct: - return v.Type() != timeType + return true case reflect.Map: return true case reflect.Array, reflect.Slice: @@ -1578,7 +1582,8 @@ func tableJSON(v reflect.Value) (string, error) { for item.Kind() == reflect.Interface && !item.IsNil() { item = item.Elem() } - if item.Kind() == reflect.Struct { + marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() + if item.Kind() == reflect.Struct || (item.Kind() != reflect.Ptr && reflect.PointerTo(item.Type()).Implements(marshaler)) { copy := reflect.New(item.Type()) copy.Elem().Set(item) items[i] = copy.Interface() diff --git a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl index 5925d40cb..ce81f1311 100644 --- a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl @@ -10,8 +10,8 @@ import ( "reflect" "strings" "testing" - {{- if hasCliAsyncIntents}} "time" + {{- if hasCliAsyncIntents}} "github.com/spf13/cobra" {{- end}} @@ -93,6 +93,78 @@ func TestPrintTableSingleContainerMarshaler(t *testing.T) { } } +type tableScalarText string + +func (v tableScalarText) MarshalJSON() ([]byte, error) { + return json.Marshal("wire:" + string(v)) +} + +type tableScalarNumber int + +func (v *tableScalarNumber) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]int{"amount": int(*v) + 100}) +} + +type invalidTableScalar bool + +func (invalidTableScalar) MarshalJSON() ([]byte, error) { + return []byte("invalid"), nil +} + +func TestPrintTableSingleScalarMarshalers(t *testing.T) { + number := tableScalarNumber(7) + for name, tc := range map[string]struct { value any; want string }{ + "string value": {tableScalarText("raw"), `"wire:raw"`}, + "integer value pointer receiver": {number, `{"amount":107}`}, + "integer pointer": {&number, `{"amount":107}`}, + "typed slice": {[]tableScalarNumber{number}, `[{"amount":107}]`}, + "interface slice": {[]any{number}, `[{"amount":107}]`}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { Value any `json:"wire_value"` }{tc.value}) + if err != nil || buf.String() != "WIRE_VALUE " + tc.want + "\n" { + t.Fatalf("scalar marshaler bypassed: got %q, error %v", buf.String(), err) + } + if !json.Valid([]byte(strings.TrimPrefix(strings.TrimSuffix(buf.String(), "\n"), "WIRE_VALUE "))) { + t.Fatalf("invalid scalar JSON cell: %q", buf.String()) + } + }) + } +} + +func TestPrintTableSingleScalarMarshalerErrors(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { Value invalidTableScalar `json:"value"` }{true}) + if err == nil || !strings.Contains(err.Error(), "table field value") || buf.Len() != 0 { + t.Fatalf("expected contextual scalar serialization error without partial output, got %q, error %v", buf.String(), err) + } +} + +func TestPrintTableScalarMarshalerCompatibility(t *testing.T) { + when := time.Date(2024, 5, 1, 12, 30, 0, 123, time.UTC) + row := struct { + Value tableScalarText `json:"value"` + Created time.Time `json:"created"` + Updated *time.Time `json:"updated"` + }{"raw", when, &when} + var buf bytes.Buffer + if err := printTable(&buf, row); err != nil { + t.Fatal(err) + } + want := "VALUE \"wire:raw\"\nCREATED 2024-05-01T12:30:00.000000123Z\nUPDATED 2024-05-01T12:30:00.000000123Z\n" + if buf.String() != want { + t.Fatalf("single-object scalar/time mismatch: got %q want %q", buf.String(), want) + } + buf.Reset() + if err := printTable(&buf, []struct { Value tableScalarText `json:"value"` }{ {"raw"}}); err != nil { + t.Fatal(err) + } + if buf.String() != "VALUE\nraw\n" { + t.Fatalf("direct scalar list changed: %q", buf.String()) + } +} + func TestPrintTableSingleNestedValues(t *testing.T) { for name, value := range map[string]any{ "map": map[string]any{"text": "hello"}, diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index bffadf36d..d30da92bd 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -274,9 +274,9 @@ trackedFiles: internal/output/jq.go: last_write_checksum: sha1:4ec4f6429c1d7eaa780a9e33147ba8087b678169 internal/output/output.go: - last_write_checksum: sha1:689ccc0f8589e8582ed35fe76e33a8e956a05a32 + last_write_checksum: sha1:d442083d5a69be429a2b1706be3e22432c3a2c61 internal/output/output_test.go: - last_write_checksum: sha1:9f0eb8525a1051b7589b9e80bde3ff389323b24f + last_write_checksum: sha1:89704ca27395633ffa64add2d630e5f92f227a5a internal/output/outputitems.go: last_write_checksum: sha1:d327eee8d9a395821fef201257e033bae50cda51 internal/output/paginated.go: diff --git a/zSDKs/sdk-cli/internal/output/output.go b/zSDKs/sdk-cli/internal/output/output.go index 94cf89d61..7b52b167c 100644 --- a/zSDKs/sdk-cli/internal/output/output.go +++ b/zSDKs/sdk-cli/internal/output/output.go @@ -1510,12 +1510,16 @@ func complexTableValue(v reflect.Value) bool { } v = v.Elem() } - if v.Type() == reflect.TypeOf(json.RawMessage{}) { + if v.Type() == timeType { + return false + } + marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() + if v.Type().Implements(marshaler) || reflect.PointerTo(v.Type()).Implements(marshaler) { return true } switch v.Kind() { case reflect.Struct: - return v.Type() != timeType + return true case reflect.Map: return true case reflect.Array, reflect.Slice: @@ -1560,7 +1564,8 @@ func tableJSON(v reflect.Value) (string, error) { for item.Kind() == reflect.Interface && !item.IsNil() { item = item.Elem() } - if item.Kind() == reflect.Struct { + marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() + if item.Kind() == reflect.Struct || (item.Kind() != reflect.Ptr && reflect.PointerTo(item.Type()).Implements(marshaler)) { copy := reflect.New(item.Type()) copy.Elem().Set(item) items[i] = copy.Interface() diff --git a/zSDKs/sdk-cli/internal/output/output_test.go b/zSDKs/sdk-cli/internal/output/output_test.go index b19cc6c11..57b755377 100644 --- a/zSDKs/sdk-cli/internal/output/output_test.go +++ b/zSDKs/sdk-cli/internal/output/output_test.go @@ -96,6 +96,87 @@ func TestPrintTableSingleContainerMarshaler(t *testing.T) { } } +type tableScalarText string + +func (v tableScalarText) MarshalJSON() ([]byte, error) { + return json.Marshal("wire:" + string(v)) +} + +type tableScalarNumber int + +func (v *tableScalarNumber) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]int{"amount": int(*v) + 100}) +} + +type invalidTableScalar bool + +func (invalidTableScalar) MarshalJSON() ([]byte, error) { + return []byte("invalid"), nil +} + +func TestPrintTableSingleScalarMarshalers(t *testing.T) { + number := tableScalarNumber(7) + for name, tc := range map[string]struct { + value any + want string + }{ + "string value": {tableScalarText("raw"), `"wire:raw"`}, + "integer value pointer receiver": {number, `{"amount":107}`}, + "integer pointer": {&number, `{"amount":107}`}, + "typed slice": {[]tableScalarNumber{number}, `[{"amount":107}]`}, + "interface slice": {[]any{number}, `[{"amount":107}]`}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Value any `json:"wire_value"` + }{tc.value}) + if err != nil || buf.String() != "WIRE_VALUE "+tc.want+"\n" { + t.Fatalf("scalar marshaler bypassed: got %q, error %v", buf.String(), err) + } + if !json.Valid([]byte(strings.TrimPrefix(strings.TrimSuffix(buf.String(), "\n"), "WIRE_VALUE "))) { + t.Fatalf("invalid scalar JSON cell: %q", buf.String()) + } + }) + } +} + +func TestPrintTableSingleScalarMarshalerErrors(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Value invalidTableScalar `json:"value"` + }{true}) + if err == nil || !strings.Contains(err.Error(), "table field value") || buf.Len() != 0 { + t.Fatalf("expected contextual scalar serialization error without partial output, got %q, error %v", buf.String(), err) + } +} + +func TestPrintTableScalarMarshalerCompatibility(t *testing.T) { + when := time.Date(2024, 5, 1, 12, 30, 0, 123, time.UTC) + row := struct { + Value tableScalarText `json:"value"` + Created time.Time `json:"created"` + Updated *time.Time `json:"updated"` + }{"raw", when, &when} + var buf bytes.Buffer + if err := printTable(&buf, row); err != nil { + t.Fatal(err) + } + want := "VALUE \"wire:raw\"\nCREATED 2024-05-01T12:30:00.000000123Z\nUPDATED 2024-05-01T12:30:00.000000123Z\n" + if buf.String() != want { + t.Fatalf("single-object scalar/time mismatch: got %q want %q", buf.String(), want) + } + buf.Reset() + if err := printTable(&buf, []struct { + Value tableScalarText `json:"value"` + }{{"raw"}}); err != nil { + t.Fatal(err) + } + if buf.String() != "VALUE\nraw\n" { + t.Fatalf("direct scalar list changed: %q", buf.String()) + } +} + func TestPrintTableSingleNestedValues(t *testing.T) { for name, value := range map[string]any{ "map": map[string]any{"text": "hello"}, From a0bb5261e91d8877b1793324af704297f7034c9f Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 18:51:05 +0100 Subject: [PATCH 4/5] fix(cli): render wire values in table columns --- .changesets/1791385750-872cdcf2.yaml | 10 - .changesets/1791392322-d5d01b4a.yaml | 10 + templates/templates/cli/README.md | 4 +- .../auxiliary/internal/output/output.go.stmpl | 276 +++++++++++++----- .../internal/output/output_test.go.stmpl | 154 +++++++++- .../templates/cli/cli_harness_test.go.stmpl | 46 +-- .../primary/table_additional_test.go.stmpl | 98 ++++++- zSDKs/sdk-cli/.speakeasy/gen.lock | 6 +- zSDKs/sdk-cli/internal/output/output.go | 276 +++++++++++++----- zSDKs/sdk-cli/internal/output/output_test.go | 152 +++++++++- zSDKs/sdk-cli/tests/cli_harness_test.go | 46 +-- 11 files changed, 844 insertions(+), 234 deletions(-) delete mode 100644 .changesets/1791385750-872cdcf2.yaml create mode 100644 .changesets/1791392322-d5d01b4a.yaml diff --git a/.changesets/1791385750-872cdcf2.yaml b/.changesets/1791385750-872cdcf2.yaml deleted file mode 100644 index 780eee103..000000000 --- a/.changesets/1791385750-872cdcf2.yaml +++ /dev/null @@ -1,10 +0,0 @@ -id: 1791385750-872cdcf2 -features: - - core -targets: - - cli -type: fix -bump: patch -description: preserve nested table fields and add JSON details to complex wrapper rows -author: ThomasRooney -date: "2026-10-07" diff --git a/.changesets/1791392322-d5d01b4a.yaml b/.changesets/1791392322-d5d01b4a.yaml new file mode 100644 index 000000000..2280abb81 --- /dev/null +++ b/.changesets/1791392322-d5d01b4a.yaml @@ -0,0 +1,10 @@ +id: 1791392322-d5d01b4a +features: + - core +targets: + - cli +type: fix +bump: patch +description: render nested table values in columns +author: ThomasRooney +date: "2026-10-07" diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index f247f8b3b..010354d70 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -672,12 +672,12 @@ Three output paths: - `pretty` (default): Aligned key-value pairs with color, nested indentation - `json`: Marshaled from the typed Go struct (note: field order and precision may differ from original API JSON) - `yaml`: Via `gopkg.in/yaml.v3` - - `table`: Aligned columns via `printTable()` — multi-row for slices, vertical key-value for single structs, two-column for maps. A struct with slice-of-struct fields (a list response such as `{items, nextPageToken}`, or a resource holding a list) renders in field order: non-nil plain-value fields as key-value blocks and each slice-of-struct field as a titled, indented sub-table. `time.Time` fields are cells in RFC 3339, like JSON output. Single-object nested structs, maps, and complex arrays render as compact JSON cells using wire names and custom JSON marshalers. Existing section columns stay in place; rows with populated nested values gain a compact JSON detail line below the table, indexed by their one-based position in the original array (nil rows are skipped). A complex list wrapper receives these details too: runtime response shapes do not distinguish a wrapper from a resource. Direct array tables and `--all` tables that aggregate a declared results array of structs keep their existing scalar columns and output. Pagination without a results path renders whole page objects; complex sections in those page objects also gain indexed details. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values, retain non-nil empty maps/arrays, and use field/row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and per-item pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails. Declared-results `--all` struct aggregation bypasses `outputOneItem()` and uses the existing `PaginatedResult()` `flushRows` fallback + - `table`: Aligned columns via `printTable()` — multi-row for slices, `FIELD`/`VALUE` tables for single structs, two-column for maps. Single-object field labels use their JSON names. Nested structs, maps, and complex arrays render as compact JSON cells using custom JSON marshalers; serialized strings such as dates are displayed without JSON quotes. Root custom marshalers define the displayed fields too, including flattened additional properties and omitted fields. Arrays of structs render as titled sub-tables with one row per non-nil entry; columns come from the serialized object's fields in first-seen order across all rows, including fields introduced by later union variants. Nested values occupy compact JSON cells in those columns. Fields absent from a row have blank cells, explicit JSON nulls remain `null`, and fields omitted by the row's JSON serialization do not introduce columns. Custom row marshalers that produce non-object values, or sections with no object keys, use a single `VALUE` column containing each row's complete JSON value. Section scalar arrays also use JSON cells. List wrappers and pagination without a declared results path use these same tables. Direct array tables and `--all` tables that aggregate a declared results array of structs retain their existing columns, with control characters escaped in row cells, column headers, and map keys/values. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values on the reflected object path and preserve explicit nulls from custom root serialization, retain non-nil empty maps/arrays, and use object, field, or row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and per-item pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails. Declared-results `--all` struct aggregation bypasses `outputOneItem()` and uses the existing `PaginatedResult()` `flushRows` fallback - `toon`: [Token-Oriented Object Notation](https://github.com/toon-format/spec) via `github.com/alpkeskin/gotoon` — compact, line-oriented, 30–60% fewer tokens than JSON. gotoon omits the final newline, so `encodeTOON()` terminates non-empty output with one, matching JSON and YAML and giving per-item streaming/pagination output its blank-line separator `pretty`, `yaml` and `toon` never reflect over the typed Go structs directly. `normalizeForOutput()` (`output.go.stmpl`) marshals the value once through the SDK's `utils.MarshalJSON` and decodes it back with `json.Decoder.UseNumber`, and the renderers work from that wire shape (`marshalYAML()`, `encodeTOON()`, `prettyPrint()`, shared by `Result()` and the per-item streaming/pagination path in `outputitems.go.stmpl`). Reflecting encoders would otherwise leak generated union internals — wrapper keys such as `ErrorEvent:`, `null` sibling variants, `Type`/`UnknownRaw` — plus raw struct-tag options as keys (`"error,omitzero":`) and lowercased Go field names in YAML. Numbers stay exact: `json.Number` is converted per renderer (`convertNumbers()` with `yamlNumber()`/`toonNumber()`, `formatJSONNumber()` for pretty) to a native `int64`/`float64` only when that value re-encodes to the identical JSON lexeme (`nativeNumber()`); otherwise the lexeme is kept verbatim — a tagged `!!int`/`!!float` scalar node for yaml, a string for TOON (which has no wider numeric type), the literal for pretty — so bigint/decimal fields never lose digits. TOON's own encoder goes through `float64`, so native integers above 2^53 round there (a gotoon property, unchanged). `--include-headers` output (`outputWithHeaders()`) already worked from the marshalled JSON. Covered by `TestEventStreamUnionEventsRenderWireShape` and `TestEventStreamNumbersKeepPrecision` in `tests/primary/eventstreaming_test.go.stmpl`. -Streaming `--output-format table` applies the same single-object renderer to each event, with a blank line between events. An event wrapper containing only a nested `data` value now renders a `DATA` compact JSON cell; it previously had no displayable table fields and fell back to pretty output. JSON, YAML, TOON, and pretty streaming formats keep their existing rendering paths. Covered by `TestTableStreamingUnionDataCells` in `tests/primary/table_additional_test.go.stmpl`. +Streaming `--output-format table` applies the same single-object renderer to each event, with a blank line between events. An event wrapper containing only a nested `data` value renders a `data` compact JSON cell beneath a `FIELD`/`VALUE` header; it previously had no displayable table fields and fell back to pretty output. JSON, YAML, TOON, and pretty streaming formats keep their existing rendering paths. Covered by `TestTableStreamingUnionDataCells` in `tests/primary/table_additional_test.go.stmpl`. 3. **jq filtering** (`--jq`): Applies a [gojq](https://github.com/itchyny/gojq) expression to the JSON output. Overrides `--output-format` since the result is always JSON — except under `--raw-output` (jq `-r` semantics: string results are written as raw text plus a newline, unquoted, unescaped, never colorized; non-string results stay JSON), so `--jq '.data' --raw-output | base64 -d > image.png` works. The flag's default is the `cli.jqRawOutput` gen.yaml key (default `false`); it threads through `outputJqResults` (single results, `--include-headers`, artifacts) and `outputOneItem` (streams, `--all` pagination). diff --git a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index 0474cf1a8..71d858c94 100644 --- a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl @@ -1321,7 +1321,6 @@ func applyJqToTyped(out io.Writer, content interface{}, jqExpr string, colorize, // For slices/arrays of structs, each struct becomes a row with struct fields as columns. // For single structs, outputs a vertical key-value table. Slice-of-struct fields // (such as the items of a list response) render as titled sub-tables. -// Single objects include complex fields as JSON cells or indexed section details. func printTable(out io.Writer, content any) error { v := reflect.ValueOf(content) for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { @@ -1387,7 +1386,7 @@ func printTableRows(out io.Writer, v reflect.Value) error { // Header headers := make([]string, len(cols)) for i, c := range cols { - headers[i] = strings.ToUpper(c.name) + headers[i] = safeTableText(strings.ToUpper(c.name)) } fmt.Fprintln(tw, strings.Join(headers, "\t")) @@ -1405,7 +1404,7 @@ func printTableRows(out io.Writer, v reflect.Value) error { } vals := make([]string, len(cols)) for j, c := range cols { - vals[j] = formatTableCell(row.Field(c.index)) + vals[j] = safeTableText(formatTableCell(row.Field(c.index))) } fmt.Fprintln(tw, strings.Join(vals, "\t")) } @@ -1414,6 +1413,13 @@ func printTableRows(out io.Writer, v reflect.Value) error { func printTableSingle(out io.Writer, v reflect.Value) error { t := v.Type() + if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) { + data, err := tableJSON(v) + if err != nil { + return fmt.Errorf("table object: %w", err) + } + return printSingleJSONTable(out, data) + } sections := hasTableSections(t) var buf bytes.Buffer var tw *tabwriter.Writer @@ -1439,15 +1445,9 @@ func printTableSingle(out io.Writer, v reflect.Value) error { } startBlock() fmt.Fprintln(&buf, safeTableText(strings.ToUpper(tableFieldName(f)))) - var section bytes.Buffer - if err := printSingleTableRows(§ion, value); err != nil { + if err := printSingleTableRows(&buf, value); err != nil { return fmt.Errorf("table field %s: %w", tableFieldName(f), err) } - for _, line := range strings.SplitAfter(section.String(), "\n") { - if line != "" { - buf.WriteString(" " + line) - } - } continue } if nilSingleTableValue(value) && (sections || !isTableScalar(f.Type)) { @@ -1460,8 +1460,9 @@ func printTableSingle(out io.Writer, v reflect.Value) error { if tw == nil { startBlock() tw = newTabWriter(&buf) + fmt.Fprintln(tw, "FIELD\tVALUE") } - fmt.Fprintf(tw, "%s\t%s\n", safeTableText(strings.ToUpper(tableFieldName(f))), cell) + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(tableFieldName(f)), cell) } if tw != nil { if err := tw.Flush(); err != nil { @@ -1481,9 +1482,9 @@ func singleTableField(f reflect.StructField) bool { func singleTableColumns(t reflect.Type) []tableColumn { var cols []tableColumn - for _, c := range collectTableColumns(t) { - if singleTableField(t.Field(c.index)) { - cols = append(cols, c) + for i := 0; i < t.NumField(); i++ { + if f := t.Field(i); singleTableField(f) { + cols = append(cols, tableColumn{name: tableFieldName(f), index: i}) } } return cols @@ -1491,8 +1492,7 @@ func singleTableColumns(t reflect.Type) []tableColumn { func isTableSection(t reflect.Type) bool { t = derefType(t) - marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) { + if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) { return false } if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { @@ -1531,8 +1531,7 @@ func complexTableValue(v reflect.Value) bool { if v.Type() == timeType { return false } - marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - if v.Type().Implements(marshaler) || reflect.PointerTo(v.Type()).Implements(marshaler) { + if v.Type().Implements(jsonMarshalerType) || reflect.PointerTo(v.Type()).Implements(jsonMarshalerType) { return true } switch v.Kind() { @@ -1582,8 +1581,7 @@ func tableJSON(v reflect.Value) (string, error) { for item.Kind() == reflect.Interface && !item.IsNil() { item = item.Elem() } - marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - if item.Kind() == reflect.Struct || (item.Kind() != reflect.Ptr && reflect.PointerTo(item.Type()).Implements(marshaler)) { + if item.Kind() == reflect.Struct || (item.Kind() != reflect.Ptr && reflect.PointerTo(item.Type()).Implements(jsonMarshalerType)) { copy := reflect.New(item.Type()) copy.Elem().Set(item) items[i] = copy.Interface() @@ -1606,7 +1604,11 @@ func marshalTableJSON(value any) (string, error) { func singleTableCell(v reflect.Value) (string, error) { if complexTableValue(v) { - return tableJSON(v) + data, err := tableJSON(v) + if err != nil { + return "", err + } + return tableJSONCell(data) } return safeTableText(formatTableCell(v)), nil } @@ -1638,19 +1640,9 @@ func safeTableText(s string) string { return b.String() } -func singleTableRowNeedsDetail(v reflect.Value) bool { - for i := 0; i < v.NumField(); i++ { - f := v.Type().Field(i) - if singleTableField(f) && !nilSingleTableValue(v.Field(i)) && complexTableValue(v.Field(i)) { - return true - } - if f.IsExported() && strings.Split(f.Tag.Get("json"), ",")[0] == "-" && !v.Field(i).IsZero() { - if _, ok := reflect.New(v.Type()).Interface().(json.Marshaler); ok { - return true - } - } - } - return false +type singleTableRow struct { + data string + cells map[string]string } func printSingleTableRows(out io.Writer, rows reflect.Value) error { @@ -1661,53 +1653,206 @@ func printSingleTableRows(out io.Writer, rows reflect.Value) error { } rows = rows.Elem() } - if rows.Len() == 0 { + var dataRows []string + for i := 0; i < rows.Len(); i++ { + row := rows.Index(i) + if nilSingleTableValue(row) { + continue + } + data, err := tableJSON(row) + if err != nil { + return fmt.Errorf("row %d: %w", i+1, err) + } + dataRows = append(dataRows, data) + } + return printJSONTableRows(out, dataRows) +} + +func printJSONTableRows(out io.Writer, dataRows []string) error { + var columns []string + seen := map[string]bool{} + var rendered []singleTableRow + valueColumn := false + for i, data := range dataRows { + row := singleTableRow{data: data} + if strings.HasPrefix(data, "{") { + names, cells, err := singleTableObjectCells(data) + if err != nil { + return fmt.Errorf("row %d: %w", i+1, err) + } + row.cells = cells + for _, name := range names { + if !seen[name] { + columns = append(columns, name) + seen[name] = true + } + } + } else { + valueColumn = true + } + rendered = append(rendered, row) + } + if len(rendered) == 0 { fmt.Fprintln(out, "(empty)") return nil } - cols := singleTableColumns(derefType(rows.Type().Elem())) tw := newTabWriter(out) - headers := make([]string, len(cols)) - for i, c := range cols { - headers[i] = safeTableText(strings.ToUpper(c.name)) + if valueColumn || len(columns) == 0 { + fmt.Fprintln(tw, "VALUE") + for _, row := range rendered { + fmt.Fprintln(tw, row.data) + } + return tw.Flush() + } + headers := make([]string, len(columns)) + for i, name := range columns { + headers[i] = safeTableText(strings.ToUpper(name)) } fmt.Fprintln(tw, strings.Join(headers, "\t")) - var details bytes.Buffer - for i := 0; i < rows.Len(); i++ { - original := rows.Index(i) - row := original - for row.Kind() == reflect.Ptr && !row.IsNil() { - row = row.Elem() + for _, row := range rendered { + values := make([]string, len(columns)) + for i, name := range columns { + values[i] = row.cells[name] } - if row.Kind() != reflect.Struct { - continue + fmt.Fprintln(tw, strings.Join(values, "\t")) + } + return tw.Flush() +} + +type tableJSONField struct { + name string + data json.RawMessage +} + +func tableObjectFields(data string) ([]tableJSONField, error) { + decoder := json.NewDecoder(strings.NewReader(data)) + if _, err := decoder.Token(); err != nil { + return nil, err + } + var fields []tableJSONField + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return nil, err } - vals := make([]string, len(cols)) - for j, c := range cols { - cell, err := singleTableCell(row.Field(c.index)) - if err != nil { - return fmt.Errorf("row %d field %s: %w", i+1, c.name, err) - } - vals[j] = cell + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return nil, err } - fmt.Fprintln(tw, strings.Join(vals, "\t")) - if singleTableRowNeedsDetail(row) { - data, err := tableJSON(original) - if err != nil { - return fmt.Errorf("row %d: %w", i+1, err) + fields = append(fields, tableJSONField{name: token.(string), data: raw}) + } + return fields, nil +} + +func tableJSONCell(data string) (string, error) { + if strings.HasPrefix(data, "\"") { + var value string + if err := json.Unmarshal([]byte(data), &value); err != nil { + return "", err + } + return safeTableText(value), nil + } + return safeTableJSON(data), nil +} + +func singleTableObjectCells(data string) ([]string, map[string]string, error) { + fields, err := tableObjectFields(data) + if err != nil { + return nil, nil, err + } + var columns []string + cells := map[string]string{} + for _, field := range fields { + cell, err := tableJSONCell(string(field.data)) + if err != nil { + return nil, nil, err + } + if _, exists := cells[field.name]; !exists { + columns = append(columns, field.name) + } + cells[field.name] = cell + } + return columns, cells, nil +} + +func printSingleJSONTable(out io.Writer, data string) error { + if !strings.HasPrefix(data, "{") { + cell, err := tableJSONCell(data) + if err != nil { + return err + } + _, err = fmt.Fprintf(out, "VALUE\n%s\n", cell) + return err + } + fields, err := tableObjectFields(data) + if err != nil { + return err + } + var buf bytes.Buffer + var tw *tabwriter.Writer + blocks := 0 + for _, field := range fields { + if rows, ok := tableJSONSection(field.data); ok { + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + tw = nil + } + if blocks > 0 { + fmt.Fprintln(&buf) } - if data != "null" { - fmt.Fprintf(&details, "[%d] %s\n", i+1, data) + blocks++ + fmt.Fprintln(&buf, safeTableText(strings.ToUpper(field.name))) + if err := printJSONTableRows(&buf, rows); err != nil { + return fmt.Errorf("table field %s: %w", field.name, err) } + continue + } + cell, err := tableJSONCell(string(field.data)) + if err != nil { + return fmt.Errorf("table field %s: %w", field.name, err) + } + if tw == nil { + if blocks > 0 { + fmt.Fprintln(&buf) + } + blocks++ + tw = newTabWriter(&buf) + fmt.Fprintln(tw, "FIELD\tVALUE") } + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(field.name), cell) } - if err := tw.Flush(); err != nil { - return err + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + } + if blocks == 0 { + fmt.Fprintln(&buf, "(empty)") } - _, err := out.Write(details.Bytes()) + _, err = out.Write(buf.Bytes()) return err } +func tableJSONSection(data json.RawMessage) ([]string, bool) { + if len(data) == 0 || data[0] != '[' { + return nil, false + } + var values []json.RawMessage + if json.Unmarshal(data, &values) != nil || len(values) == 0 { + return nil, false + } + rows := make([]string, len(values)) + for i, value := range values { + if len(value) == 0 || (value[0] != '{' && string(value) != "null") { + return nil, false + } + rows[i] = string(value) + } + return rows, true +} + // printTableMap renders a map as a two-column key-value table. func printTableMap(out io.Writer, v reflect.Value) error { tw := newTabWriter(out) @@ -1717,7 +1862,7 @@ func printTableMap(out io.Writer, v reflect.Value) error { return fmt.Sprint(keys[i].Interface()) < fmt.Sprint(keys[j].Interface()) }) for _, k := range keys { - fmt.Fprintf(tw, "%v\t%s\n", k.Interface(), formatTableCell(v.MapIndex(k))) + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(fmt.Sprint(k.Interface())), safeTableText(formatTableCell(v.MapIndex(k)))) } return tw.Flush() } @@ -1755,6 +1900,7 @@ func tableFieldName(f reflect.StructField) string { } var timeType = reflect.TypeOf(time.Time{}) +var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() func derefType(t reflect.Type) reflect.Type { for t.Kind() == reflect.Ptr { diff --git a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl index ce81f1311..d5708d745 100644 --- a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl @@ -86,7 +86,7 @@ func TestPrintTableSingleContainerMarshaler(t *testing.T) { t.Run(name, func(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, object) - if err != nil || buf.String() != "ENTRIES {\"text\":\"hello\"}\n" { + if err != nil || buf.String() != "FIELD VALUE\nentries {\"text\":\"hello\"}\n" { t.Fatalf("container marshaler bypassed: got %q, error %v", buf.String(), err) } }) @@ -114,7 +114,7 @@ func (invalidTableScalar) MarshalJSON() ([]byte, error) { func TestPrintTableSingleScalarMarshalers(t *testing.T) { number := tableScalarNumber(7) for name, tc := range map[string]struct { value any; want string }{ - "string value": {tableScalarText("raw"), `"wire:raw"`}, + "string value": {tableScalarText("raw"), `wire:raw`}, "integer value pointer receiver": {number, `{"amount":107}`}, "integer pointer": {&number, `{"amount":107}`}, "typed slice": {[]tableScalarNumber{number}, `[{"amount":107}]`}, @@ -123,10 +123,10 @@ func TestPrintTableSingleScalarMarshalers(t *testing.T) { t.Run(name, func(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, struct { Value any `json:"wire_value"` }{tc.value}) - if err != nil || buf.String() != "WIRE_VALUE " + tc.want + "\n" { + if err != nil || buf.String() != "FIELD VALUE\nwire_value " + tc.want + "\n" { t.Fatalf("scalar marshaler bypassed: got %q, error %v", buf.String(), err) } - if !json.Valid([]byte(strings.TrimPrefix(strings.TrimSuffix(buf.String(), "\n"), "WIRE_VALUE "))) { + if tc.want != "wire:raw" && !json.Valid([]byte(strings.TrimPrefix(strings.TrimSuffix(buf.String(), "\n"), "FIELD VALUE\nwire_value "))) { t.Fatalf("invalid scalar JSON cell: %q", buf.String()) } }) @@ -152,7 +152,7 @@ func TestPrintTableScalarMarshalerCompatibility(t *testing.T) { if err := printTable(&buf, row); err != nil { t.Fatal(err) } - want := "VALUE \"wire:raw\"\nCREATED 2024-05-01T12:30:00.000000123Z\nUPDATED 2024-05-01T12:30:00.000000123Z\n" + want := "FIELD VALUE\nvalue wire:raw\ncreated 2024-05-01T12:30:00.000000123Z\nupdated 2024-05-01T12:30:00.000000123Z\n" if buf.String() != want { t.Fatalf("single-object scalar/time mismatch: got %q want %q", buf.String(), want) } @@ -182,18 +182,18 @@ func TestPrintTableSingleNestedValues(t *testing.T) { t.Run(name, func(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, struct { Value any `json:"wire_value"` }{value}) - if err != nil || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || !strings.HasPrefix(buf.String(), "WIRE_VALUE ") { + if err != nil || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || !strings.HasPrefix(buf.String(), "FIELD VALUE\nwire_value ") { t.Fatalf("unexpected table: %q, error: %v", buf.String(), err) } }) } } -func TestPrintTableSingleSectionDetails(t *testing.T) { +func TestPrintTableSingleSectionColumns(t *testing.T) { rows := []*nestedTableEntry{nil, {Kind: "note", Payload: map[string]any{"text": "hello"}, Hidden: "secret"}} var buf bytes.Buffer err := printTable(&buf, struct { Entries []*nestedTableEntry `json:"entries"` }{rows}) - want := "ENTRIES\n KIND\n note\n [2] {\"kind\":\"note\",\"payload\":{\"text\":\"hello\"}}\n" + want := "ENTRIES\nKIND PAYLOAD\nnote {\"text\":\"hello\"}\n" if err != nil || buf.String() != want { t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) } @@ -206,7 +206,7 @@ func TestPrintTableSingleSectionDetails(t *testing.T) { } } -func TestPrintTableSingleUnionDetails(t *testing.T) { +func TestPrintTableSingleUnionColumns(t *testing.T) { for name, row := range map[string]nestedTableUnion{ "known": {Kind: "note", Variant: &nestedTableEntry{Kind: "note", Payload: map[string]any{"text": "hello"}}}, "unknown": {Kind: "future", UnknownRaw: json.RawMessage(`{"kind":"future","text":"hello"}`)}, @@ -214,7 +214,7 @@ func TestPrintTableSingleUnionDetails(t *testing.T) { t.Run(name, func(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{row}}) - if err != nil || !strings.Contains(buf.String(), "[1] {") || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || strings.Contains(buf.String(), "UNKNOWNRAW") { + if err != nil || strings.Contains(buf.String(), "[1]") || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || strings.Contains(buf.String(), "UNKNOWNRAW") { t.Fatalf("unexpected union table %q, error %v", buf.String(), err) } }) @@ -244,13 +244,13 @@ func TestPrintTableSingleControls(t *testing.T) { } var decoded map[string]any if err := json.Unmarshal([]byte(line[start:]), &decoded); err != nil { - t.Fatalf("invalid JSON detail %q: %v", line, err) + t.Fatalf("invalid JSON cell %q: %v", line, err) } if text, ok := decoded["text"]; ok && text != value { t.Fatalf("control text changed: %q", text) } if payload, ok := decoded["payload"].(map[string]any); ok && payload["text"] != value { - t.Fatalf("detail control text changed: %q", payload["text"]) + t.Fatalf("cell control text changed: %q", payload["text"]) } } } @@ -271,7 +271,7 @@ func TestPrintTableSingleEmptyAndHidden(t *testing.T) { EmptySlice []map[string]string `json:"empty_slice"` Hidden []nestedTableEntry `json:"-"` }{EmptyMap: map[string]string{}, EmptySlice: []map[string]string{}, Hidden: []nestedTableEntry{ {Kind: "secret"}}}) - want := "ZERO 0\nFALSE false\nEMPTY \nNIL_SCALAR \nEMPTY_MAP {}\nEMPTY_SLICE []\n" + want := "FIELD VALUE\nzero 0\nfalse false\nempty \nnil_scalar \nempty_map {}\nempty_slice []\n" if err != nil || buf.String() != want { t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) } @@ -280,15 +280,15 @@ func TestPrintTableSingleEmptyAndHidden(t *testing.T) { func TestPrintTableSingleHiddenOnlyRows(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, struct { Entries []struct { Hidden string `json:"-"` } `json:"entries"` }{Entries: []struct { Hidden string `json:"-"` }{ {"secret"}}}) - if err != nil || buf.String() != "ENTRIES [{}]\n" { + if err != nil || buf.String() != "FIELD VALUE\nentries [{}]\n" { t.Fatalf("unexpected hidden-only table %q, error %v", buf.String(), err) } } -func TestPrintTableSingleNullUnionDetail(t *testing.T) { +func TestPrintTableSingleNullUnionCell(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{ {Kind: "unknown", UnknownRaw: json.RawMessage(`null`)}}}) - if err != nil || buf.String() != "ENTRIES\n KIND\n unknown\n" { + if err != nil || buf.String() != "ENTRIES\nVALUE\nnull\n" { t.Fatalf("unexpected null detail %q, error %v", buf.String(), err) } } @@ -309,6 +309,128 @@ func TestPrintTableSingleMarshalError(t *testing.T) { } } + +func TestPrintTableSingleSectionWireColumns(t *testing.T) { + rows := []nestedTableUnion{ + {Kind: "internal", UnknownRaw: json.RawMessage(`{"kind":"note","payload":{"text":"hello"},"count":9007199254740993}`)}, + {Kind: "internal", UnknownRaw: json.RawMessage(`{"kind":"metric","tags":["a","b"],"count":18446744073709551615}`)}, + } + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []nestedTableUnion `json:"entries"` + }{rows}) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") + if len(lines) != 4 || !reflect.DeepEqual(strings.Fields(lines[1]), []string{"KIND", "PAYLOAD", "COUNT", "TAGS"}) { + t.Fatalf("unexpected columns: %q", buf.String()) + } + for _, value := range []string{`{"text":"hello"}`, `9007199254740993`, `18446744073709551615`, `["a","b"]`} { + if !strings.Contains(buf.String(), value) { + t.Fatalf("missing %q in %q", value, buf.String()) + } + } + if strings.Contains(buf.String(), "internal") || strings.Contains(buf.String(), "[1]") { + t.Fatalf("unexpected internal fields or detail rows: %q", buf.String()) + } +} + +func TestPrintTableSingleSectionNestedOnly(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []struct { + Payload map[string]string `json:"payload"` + } `json:"entries"` + }{ + Entries: []struct { + Payload map[string]string `json:"payload"` + }{ {map[string]string{"text": "hello"}}}, + }) + if want := "ENTRIES\nPAYLOAD\n{\"text\":\"hello\"}\n"; err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } +} + +func TestPrintTableSingleSectionNonObjectValues(t *testing.T) { + for _, payloads := range [][]string{ + {`null`}, {`{}`}, {`"hello"`}, {`[1,2]`}, {`{"value":"object"}`, `7`, `null`}, + } { + rows := make([]nestedTableUnion, len(payloads)) + for i, payload := range payloads { + rows[i] = nestedTableUnion{Kind: "internal", UnknownRaw: json.RawMessage(payload)} + } + var buf bytes.Buffer + if err := printTable(&buf, struct { + Entries []nestedTableUnion `json:"entries"` + }{rows}); err != nil { + t.Fatal(err) + } + want := "ENTRIES\nVALUE\n" + strings.Join(payloads, "\n") + "\n" + if buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } + } +} + +func TestPrintTableSingleSectionScalarMarshalers(t *testing.T) { + type row struct { + Label tableScalarText `json:"label"` + Amount tableScalarNumber `json:"amount"` + Created time.Time `json:"created"` + Hidden string `json:"-"` + } + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []row `json:"entries"` + }{[]row{ {"raw", 7, time.Date(2024, 5, 1, 12, 30, 0, 123, time.UTC), "secret"}}}) + want := "ENTRIES\nLABEL AMOUNT CREATED\nwire:raw {\"amount\":107} 2024-05-01T12:30:00.000000123Z\n" + if err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } +} + + +func TestPrintTableSingleRootWireFields(t *testing.T) { + value := nestedTableUnion{Kind: "internal", UnknownRaw: json.RawMessage(`{"allergies":"none","owner":"demo","entries":[{"kind":"note","payload":{"text":"hello"}}]}`)} + var buf bytes.Buffer + if err := printTable(&buf, value); err != nil { + t.Fatal(err) + } + want := "FIELD VALUE\nallergies none\nowner demo\n\nENTRIES\nKIND PAYLOAD\nnote {\"text\":\"hello\"}\n" + if buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } +} + +func TestPrintTableRowAndMapControls(t *testing.T) { + value := "line\ncolumn\ttab\rreturn\x1bescape\x7fdelete\u0085next" + for name, input := range map[string]any{ + "rows": []struct { + Name string `json:"name"` + }{ {value}}, + "map": map[string]string{value: value}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + if err := printTable(&buf, input); err != nil { + t.Fatal(err) + } + if strings.Count(buf.String(), "\n") != 2 { + t.Fatalf("broken table rows: %q", buf.String()) + } + for _, r := range buf.String() { + if r != '\n' && (r < 32 || (r >= 127 && r <= 159)) { + t.Fatalf("unescaped control %U in %q", r, buf.String()) + } + } + if !strings.Contains(buf.String(), `line\ncolumn\ttab\rreturn\x1bescape\u007fdelete\u0085next`) { + t.Fatalf("missing escaped value: %q", buf.String()) + } + }) + } +} + {{- if hasCliAsyncIntents}} func TestAwaitAsyncDeadlineKeepsPollError(t *testing.T) { diff --git a/templates/templates/cli/cli_harness_test.go.stmpl b/templates/templates/cli/cli_harness_test.go.stmpl index 203a1d176..6622cbed4 100644 --- a/templates/templates/cli/cli_harness_test.go.stmpl +++ b/templates/templates/cli/cli_harness_test.go.stmpl @@ -1045,8 +1045,6 @@ func tableLines(lines ...string) string { var tableFixtureCreated = time.Date(2024, 5, 1, 12, 30, 0, 0, time.UTC) -// TestTableOutputRendersListItems verifies that a list response renders its -// items as an indented sub-table followed by its set scalar fields. func TestTableOutputRendersListItems(t *testing.T) { alpha, beta, token := "alpha", "beta", "page-2" list := &tableFixtureList{ @@ -1059,20 +1057,22 @@ func TestTableOutputRendersListItems(t *testing.T) { assert.Equal(t, tableLines( "ITEMS", - " NAME CREATED TAGS", - " alpha 2024-05-01T12:30:00Z a, b", - " beta 2024-05-01T12:30:00Z ", + "NAME CREATED TAGS", + `alpha 2024-05-01T12:30:00Z ["a","b"]`, + "beta 2024-05-01T12:30:00Z ", "", - "NEXTPAGETOKEN page-2", + "FIELD VALUE", + "nextPageToken page-2", ), renderTable(t, list)) t.Run("empty page token is kept", func(t *testing.T) { empty := "" assert.Equal(t, tableLines( "ITEMS", - " (empty)", + "(empty)", "", - "NEXTPAGETOKEN ", + "FIELD VALUE", + "nextPageToken ", ), renderTable(t, &tableFixtureList{NextPageToken: &empty})) }) @@ -1080,9 +1080,9 @@ func TestTableOutputRendersListItems(t *testing.T) { list.NextPageToken = nil assert.Equal(t, tableLines( "ITEMS", - " NAME CREATED TAGS", - " alpha 2024-05-01T12:30:00Z a, b", - " beta 2024-05-01T12:30:00Z ", + "NAME CREATED TAGS", + `alpha 2024-05-01T12:30:00Z ["a","b"]`, + "beta 2024-05-01T12:30:00Z ", ), renderTable(t, list)) }) @@ -1090,13 +1090,13 @@ func TestTableOutputRendersListItems(t *testing.T) { item := tableFixtureItem{Name: &beta, Created: tableFixtureCreated} assert.Equal(t, tableLines( "ITEMS", - " NAME CREATED TAGS", - " beta 2024-05-01T12:30:00Z ", + "NAME CREATED", + "beta 2024-05-01T12:30:00Z", ), renderTable(t, &tableFixtureList{Items: []*tableFixtureItem{nil, &item}})) }) t.Run("empty page", func(t *testing.T) { - assert.Equal(t, tableLines("ITEMS", " (empty)"), renderTable(t, &tableFixtureList{})) + assert.Equal(t, tableLines("ITEMS", "(empty)"), renderTable(t, &tableFixtureList{})) }) } @@ -1113,13 +1113,14 @@ func TestTableOutputRendersResourceSections(t *testing.T) { }, } assert.Equal(t, tableLines( - "ID o1", - "NAME sam", - `PARENT {"created":"0001-01-01T00:00:00Z"}`, + "FIELD VALUE", + "id o1", + "name sam", + `parent {"created":"0001-01-01T00:00:00Z"}`, "", "MEMBERS", - " NAME CREATED TAGS", - " rex 2024-05-01T12:30:00Z ", + "NAME CREATED", + "rex 2024-05-01T12:30:00Z", ), renderTable(t, resource)) } @@ -1128,9 +1129,10 @@ func TestTableOutputRendersResourceSections(t *testing.T) { func TestTableOutputRendersSingleStruct(t *testing.T) { name := "alpha" assert.Equal(t, tableLines( - "NAME alpha", - "CREATED 2024-05-01T12:30:00Z", - "TAGS ", + "FIELD VALUE", + "name alpha", + "created 2024-05-01T12:30:00Z", + "tags ", ), renderTable(t, &tableFixtureItem{Name: &name, Created: tableFixtureCreated})) } diff --git a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl index da5b572f2..6bbdc074d 100644 --- a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl +++ b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl @@ -7,27 +7,30 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "{{getGolangPackage}}/internal/sdk/models/components" + "{{getGolangPackage}}/internal/sdk/types" ) func TestTableNestedResourceOutput(t *testing.T) { type entry struct { - Kind string `json:"kind"` + Kind string `json:"kind"` Payload map[string]string `json:"payload"` } object := struct { - ID string `json:"id"` + ID string `json:"id"` Metadata map[string]int `json:"metadata"` - Entries []entry `json:"entries"` + Entries []entry `json:"entries"` }{"record-1", map[string]int{"count": 2}, []entry{ {"note", map[string]string{"text": "hello\nworld\tvalue"}}}} got := renderTable(t, object) assert.Equal(t, tableLines( - "ID record-1", - `METADATA {"count":2}`, + "FIELD VALUE", + "id record-1", + `metadata {"count":2}`, "", "ENTRIES", - " KIND", - " note", - ` [1] {"kind":"note","payload":{"text":"hello\nworld\tvalue"}}`, + "KIND PAYLOAD", + `note {"text":"hello\nworld\tvalue"}`, ), got) assert.Equal(t, tableLines("KIND", "note"), renderTable(t, object.Entries)) } @@ -73,12 +76,13 @@ func TestTableStreamingUnionDataCells(t *testing.T) { err := h.RunWithServer(server.URL, []string{"eventstreams", "discriminated-events", "--output-format", "table"}) require.NoError(t, err) lines := strings.Split(strings.TrimSuffix(h.GetStdout(), "\n"), "\n") - require.Len(t, lines, 3) - assert.Empty(t, lines[1]) + require.Len(t, lines, 5) + assert.Empty(t, lines[2]) for i, text := range []string{"Hello\n\t\x7f", "world"} { - line := lines[i*2] - require.True(t, strings.HasPrefix(line, "DATA "), "expected a table cell, got %q", line) - data := strings.TrimPrefix(line, "DATA ") + assert.Equal(t, "FIELD VALUE", lines[i*3]) + line := lines[i*3+1] + require.True(t, strings.HasPrefix(line, "data "), "expected a table cell, got %q", line) + data := strings.TrimPrefix(line, "data ") require.True(t, json.Valid([]byte(data)), "invalid JSON table cell: %s", data) var event map[string]any require.NoError(t, json.Unmarshal([]byte(data), &event)) @@ -87,3 +91,71 @@ func TestTableStreamingUnionDataCells(t *testing.T) { assert.NotContains(t, h.GetStdout(), "DiscriminatedDeltaEvent") assert.NotContains(t, h.GetStdout(), "UnknownRaw") } + +func TestTableGeneratedUnionSectionColumns(t *testing.T) { + var entry components.StronglyTypedOneOfObject + require.NoError(t, json.Unmarshal([]byte(deepObjectWithTypeJSON), &entry)) + unknown := components.CreateStronglyTypedOneOfObjectUnknown(json.RawMessage(`{"type":"future","payload":{"text":"hello"},"count":9007199254740993}`)) + got := renderTable(t, struct { + Entries []components.StronglyTypedOneOfObject `json:"entries"` + }{[]components.StronglyTypedOneOfObject{entry, unknown}}) + lines := strings.Split(strings.TrimSuffix(got, "\n"), "\n") + require.Len(t, lines, 4) + assert.Equal(t, "ENTRIES", lines[0]) + columns := strings.Fields(lines[1]) + for _, column := range []string{"TYPE", "OBJ", "PAYLOAD", "COUNT"} { + assert.Contains(t, columns, column) + } + assert.Contains(t, lines[2], "deepObjectWithType") + assert.Contains(t, lines[2], `"str":"test"`) + assert.Contains(t, lines[3], "future") + assert.Contains(t, lines[3], `{"text":"hello"}`) + assert.Contains(t, lines[3], "9007199254740993") + assert.NotContains(t, got, "DeepObjectWithType") + assert.NotContains(t, got, "UnknownRaw") + assert.NotContains(t, got, "[1]") +} + +func TestTableGeneratedAdditionalProperties(t *testing.T) { + object := components.ObjWithStringAdditionalProperties{ + NormalField: "known", + AdditionalProperties: map[string]string{"owner": "demo", "note": "line\nvalue"}, + } + got := renderTable(t, object) + assert.Contains(t, got, "normalField") + assert.Contains(t, got, "owner") + assert.Contains(t, got, "demo") + assert.Contains(t, got, `line\nvalue`) + assert.NotContains(t, got, "AdditionalProperties") + require.Len(t, strings.Split(strings.TrimSuffix(got, "\n"), "\n"), 4) +} + +func TestTableGeneratedVehicleWireColumns(t *testing.T) { + vehicles := []components.Vehicle{ + components.CreateVehicleCar(components.Car{}), + components.CreateVehicleUnknown(json.RawMessage(`{"vehicleType":"future","payload":{"text":"hello"}}`)), + } + got := renderTable(t, struct { + Vehicles []components.Vehicle `json:"vehicles"` + }{vehicles}) + lines := strings.Split(strings.TrimSuffix(got, "\n"), "\n") + require.Len(t, lines, 4) + columns := strings.Fields(lines[1]) + assert.Contains(t, columns, "VEHICLETYPE") + assert.Contains(t, columns, "WHEELSTYPE") + assert.Contains(t, columns, "PAYLOAD") + assert.NotContains(t, columns, "TYPE") + assert.NotContains(t, got, "UNKNOWN") + assert.Contains(t, lines[2], "car") + assert.Contains(t, lines[2], "four") + assert.Contains(t, lines[3], `{"text":"hello"}`) +} + +func TestTableGeneratedDateCell(t *testing.T) { + value, err := types.NewDateFromString("2024-05-01") + require.NoError(t, err) + got := renderTable(t, struct { + Published *types.Date `json:"published"` + }{value}) + assert.Equal(t, "FIELD VALUE\npublished 2024-05-01\n", got) +} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index d30da92bd..54713b7ab 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -274,9 +274,9 @@ trackedFiles: internal/output/jq.go: last_write_checksum: sha1:4ec4f6429c1d7eaa780a9e33147ba8087b678169 internal/output/output.go: - last_write_checksum: sha1:d442083d5a69be429a2b1706be3e22432c3a2c61 + last_write_checksum: sha1:3a70d1a85981bd265ade83fec6620bf1acbbfad6 internal/output/output_test.go: - last_write_checksum: sha1:89704ca27395633ffa64add2d630e5f92f227a5a + last_write_checksum: sha1:ccfa6d0a2141384ccec096c6931e428a0ce3c32f internal/output/outputitems.go: last_write_checksum: sha1:d327eee8d9a395821fef201257e033bae50cda51 internal/output/paginated.go: @@ -656,7 +656,7 @@ trackedFiles: tests/async_test.go: last_write_checksum: sha1:fb5dafb085f988d482359c61cdb2b800528ceed0 tests/cli_harness_test.go: - last_write_checksum: sha1:228abfbf690b0df6828853bf11a2e4bb2920283e + last_write_checksum: sha1:f9897cd826a343a3c7d3a43c9c7b086348f108ce tests/common_helpers_test.go: last_write_checksum: sha1:02f189ac10117b746a3691b30c87fecf518292fa tests/conflicts_test.go: diff --git a/zSDKs/sdk-cli/internal/output/output.go b/zSDKs/sdk-cli/internal/output/output.go index 7b52b167c..b18b7a206 100644 --- a/zSDKs/sdk-cli/internal/output/output.go +++ b/zSDKs/sdk-cli/internal/output/output.go @@ -1303,7 +1303,6 @@ func applyJqToTyped(out io.Writer, content interface{}, jqExpr string, colorize, // For slices/arrays of structs, each struct becomes a row with struct fields as columns. // For single structs, outputs a vertical key-value table. Slice-of-struct fields // (such as the items of a list response) render as titled sub-tables. -// Single objects include complex fields as JSON cells or indexed section details. func printTable(out io.Writer, content any) error { v := reflect.ValueOf(content) for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { @@ -1369,7 +1368,7 @@ func printTableRows(out io.Writer, v reflect.Value) error { // Header headers := make([]string, len(cols)) for i, c := range cols { - headers[i] = strings.ToUpper(c.name) + headers[i] = safeTableText(strings.ToUpper(c.name)) } fmt.Fprintln(tw, strings.Join(headers, "\t")) @@ -1387,7 +1386,7 @@ func printTableRows(out io.Writer, v reflect.Value) error { } vals := make([]string, len(cols)) for j, c := range cols { - vals[j] = formatTableCell(row.Field(c.index)) + vals[j] = safeTableText(formatTableCell(row.Field(c.index))) } fmt.Fprintln(tw, strings.Join(vals, "\t")) } @@ -1396,6 +1395,13 @@ func printTableRows(out io.Writer, v reflect.Value) error { func printTableSingle(out io.Writer, v reflect.Value) error { t := v.Type() + if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) { + data, err := tableJSON(v) + if err != nil { + return fmt.Errorf("table object: %w", err) + } + return printSingleJSONTable(out, data) + } sections := hasTableSections(t) var buf bytes.Buffer var tw *tabwriter.Writer @@ -1421,15 +1427,9 @@ func printTableSingle(out io.Writer, v reflect.Value) error { } startBlock() fmt.Fprintln(&buf, safeTableText(strings.ToUpper(tableFieldName(f)))) - var section bytes.Buffer - if err := printSingleTableRows(§ion, value); err != nil { + if err := printSingleTableRows(&buf, value); err != nil { return fmt.Errorf("table field %s: %w", tableFieldName(f), err) } - for _, line := range strings.SplitAfter(section.String(), "\n") { - if line != "" { - buf.WriteString(" " + line) - } - } continue } if nilSingleTableValue(value) && (sections || !isTableScalar(f.Type)) { @@ -1442,8 +1442,9 @@ func printTableSingle(out io.Writer, v reflect.Value) error { if tw == nil { startBlock() tw = newTabWriter(&buf) + fmt.Fprintln(tw, "FIELD\tVALUE") } - fmt.Fprintf(tw, "%s\t%s\n", safeTableText(strings.ToUpper(tableFieldName(f))), cell) + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(tableFieldName(f)), cell) } if tw != nil { if err := tw.Flush(); err != nil { @@ -1463,9 +1464,9 @@ func singleTableField(f reflect.StructField) bool { func singleTableColumns(t reflect.Type) []tableColumn { var cols []tableColumn - for _, c := range collectTableColumns(t) { - if singleTableField(t.Field(c.index)) { - cols = append(cols, c) + for i := 0; i < t.NumField(); i++ { + if f := t.Field(i); singleTableField(f) { + cols = append(cols, tableColumn{name: tableFieldName(f), index: i}) } } return cols @@ -1473,8 +1474,7 @@ func singleTableColumns(t reflect.Type) []tableColumn { func isTableSection(t reflect.Type) bool { t = derefType(t) - marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) { + if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) { return false } if t.Kind() != reflect.Slice && t.Kind() != reflect.Array { @@ -1513,8 +1513,7 @@ func complexTableValue(v reflect.Value) bool { if v.Type() == timeType { return false } - marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - if v.Type().Implements(marshaler) || reflect.PointerTo(v.Type()).Implements(marshaler) { + if v.Type().Implements(jsonMarshalerType) || reflect.PointerTo(v.Type()).Implements(jsonMarshalerType) { return true } switch v.Kind() { @@ -1564,8 +1563,7 @@ func tableJSON(v reflect.Value) (string, error) { for item.Kind() == reflect.Interface && !item.IsNil() { item = item.Elem() } - marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - if item.Kind() == reflect.Struct || (item.Kind() != reflect.Ptr && reflect.PointerTo(item.Type()).Implements(marshaler)) { + if item.Kind() == reflect.Struct || (item.Kind() != reflect.Ptr && reflect.PointerTo(item.Type()).Implements(jsonMarshalerType)) { copy := reflect.New(item.Type()) copy.Elem().Set(item) items[i] = copy.Interface() @@ -1588,7 +1586,11 @@ func marshalTableJSON(value any) (string, error) { func singleTableCell(v reflect.Value) (string, error) { if complexTableValue(v) { - return tableJSON(v) + data, err := tableJSON(v) + if err != nil { + return "", err + } + return tableJSONCell(data) } return safeTableText(formatTableCell(v)), nil } @@ -1620,19 +1622,9 @@ func safeTableText(s string) string { return b.String() } -func singleTableRowNeedsDetail(v reflect.Value) bool { - for i := 0; i < v.NumField(); i++ { - f := v.Type().Field(i) - if singleTableField(f) && !nilSingleTableValue(v.Field(i)) && complexTableValue(v.Field(i)) { - return true - } - if f.IsExported() && strings.Split(f.Tag.Get("json"), ",")[0] == "-" && !v.Field(i).IsZero() { - if _, ok := reflect.New(v.Type()).Interface().(json.Marshaler); ok { - return true - } - } - } - return false +type singleTableRow struct { + data string + cells map[string]string } func printSingleTableRows(out io.Writer, rows reflect.Value) error { @@ -1643,53 +1635,206 @@ func printSingleTableRows(out io.Writer, rows reflect.Value) error { } rows = rows.Elem() } - if rows.Len() == 0 { + var dataRows []string + for i := 0; i < rows.Len(); i++ { + row := rows.Index(i) + if nilSingleTableValue(row) { + continue + } + data, err := tableJSON(row) + if err != nil { + return fmt.Errorf("row %d: %w", i+1, err) + } + dataRows = append(dataRows, data) + } + return printJSONTableRows(out, dataRows) +} + +func printJSONTableRows(out io.Writer, dataRows []string) error { + var columns []string + seen := map[string]bool{} + var rendered []singleTableRow + valueColumn := false + for i, data := range dataRows { + row := singleTableRow{data: data} + if strings.HasPrefix(data, "{") { + names, cells, err := singleTableObjectCells(data) + if err != nil { + return fmt.Errorf("row %d: %w", i+1, err) + } + row.cells = cells + for _, name := range names { + if !seen[name] { + columns = append(columns, name) + seen[name] = true + } + } + } else { + valueColumn = true + } + rendered = append(rendered, row) + } + if len(rendered) == 0 { fmt.Fprintln(out, "(empty)") return nil } - cols := singleTableColumns(derefType(rows.Type().Elem())) tw := newTabWriter(out) - headers := make([]string, len(cols)) - for i, c := range cols { - headers[i] = safeTableText(strings.ToUpper(c.name)) + if valueColumn || len(columns) == 0 { + fmt.Fprintln(tw, "VALUE") + for _, row := range rendered { + fmt.Fprintln(tw, row.data) + } + return tw.Flush() + } + headers := make([]string, len(columns)) + for i, name := range columns { + headers[i] = safeTableText(strings.ToUpper(name)) } fmt.Fprintln(tw, strings.Join(headers, "\t")) - var details bytes.Buffer - for i := 0; i < rows.Len(); i++ { - original := rows.Index(i) - row := original - for row.Kind() == reflect.Ptr && !row.IsNil() { - row = row.Elem() + for _, row := range rendered { + values := make([]string, len(columns)) + for i, name := range columns { + values[i] = row.cells[name] } - if row.Kind() != reflect.Struct { - continue + fmt.Fprintln(tw, strings.Join(values, "\t")) + } + return tw.Flush() +} + +type tableJSONField struct { + name string + data json.RawMessage +} + +func tableObjectFields(data string) ([]tableJSONField, error) { + decoder := json.NewDecoder(strings.NewReader(data)) + if _, err := decoder.Token(); err != nil { + return nil, err + } + var fields []tableJSONField + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return nil, err } - vals := make([]string, len(cols)) - for j, c := range cols { - cell, err := singleTableCell(row.Field(c.index)) - if err != nil { - return fmt.Errorf("row %d field %s: %w", i+1, c.name, err) - } - vals[j] = cell + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return nil, err } - fmt.Fprintln(tw, strings.Join(vals, "\t")) - if singleTableRowNeedsDetail(row) { - data, err := tableJSON(original) - if err != nil { - return fmt.Errorf("row %d: %w", i+1, err) + fields = append(fields, tableJSONField{name: token.(string), data: raw}) + } + return fields, nil +} + +func tableJSONCell(data string) (string, error) { + if strings.HasPrefix(data, "\"") { + var value string + if err := json.Unmarshal([]byte(data), &value); err != nil { + return "", err + } + return safeTableText(value), nil + } + return safeTableJSON(data), nil +} + +func singleTableObjectCells(data string) ([]string, map[string]string, error) { + fields, err := tableObjectFields(data) + if err != nil { + return nil, nil, err + } + var columns []string + cells := map[string]string{} + for _, field := range fields { + cell, err := tableJSONCell(string(field.data)) + if err != nil { + return nil, nil, err + } + if _, exists := cells[field.name]; !exists { + columns = append(columns, field.name) + } + cells[field.name] = cell + } + return columns, cells, nil +} + +func printSingleJSONTable(out io.Writer, data string) error { + if !strings.HasPrefix(data, "{") { + cell, err := tableJSONCell(data) + if err != nil { + return err + } + _, err = fmt.Fprintf(out, "VALUE\n%s\n", cell) + return err + } + fields, err := tableObjectFields(data) + if err != nil { + return err + } + var buf bytes.Buffer + var tw *tabwriter.Writer + blocks := 0 + for _, field := range fields { + if rows, ok := tableJSONSection(field.data); ok { + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + tw = nil + } + if blocks > 0 { + fmt.Fprintln(&buf) } - if data != "null" { - fmt.Fprintf(&details, "[%d] %s\n", i+1, data) + blocks++ + fmt.Fprintln(&buf, safeTableText(strings.ToUpper(field.name))) + if err := printJSONTableRows(&buf, rows); err != nil { + return fmt.Errorf("table field %s: %w", field.name, err) } + continue + } + cell, err := tableJSONCell(string(field.data)) + if err != nil { + return fmt.Errorf("table field %s: %w", field.name, err) + } + if tw == nil { + if blocks > 0 { + fmt.Fprintln(&buf) + } + blocks++ + tw = newTabWriter(&buf) + fmt.Fprintln(tw, "FIELD\tVALUE") } + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(field.name), cell) } - if err := tw.Flush(); err != nil { - return err + if tw != nil { + if err := tw.Flush(); err != nil { + return err + } + } + if blocks == 0 { + fmt.Fprintln(&buf, "(empty)") } - _, err := out.Write(details.Bytes()) + _, err = out.Write(buf.Bytes()) return err } +func tableJSONSection(data json.RawMessage) ([]string, bool) { + if len(data) == 0 || data[0] != '[' { + return nil, false + } + var values []json.RawMessage + if json.Unmarshal(data, &values) != nil || len(values) == 0 { + return nil, false + } + rows := make([]string, len(values)) + for i, value := range values { + if len(value) == 0 || (value[0] != '{' && string(value) != "null") { + return nil, false + } + rows[i] = string(value) + } + return rows, true +} + // printTableMap renders a map as a two-column key-value table. func printTableMap(out io.Writer, v reflect.Value) error { tw := newTabWriter(out) @@ -1699,7 +1844,7 @@ func printTableMap(out io.Writer, v reflect.Value) error { return fmt.Sprint(keys[i].Interface()) < fmt.Sprint(keys[j].Interface()) }) for _, k := range keys { - fmt.Fprintf(tw, "%v\t%s\n", k.Interface(), formatTableCell(v.MapIndex(k))) + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(fmt.Sprint(k.Interface())), safeTableText(formatTableCell(v.MapIndex(k)))) } return tw.Flush() } @@ -1737,6 +1882,7 @@ func tableFieldName(f reflect.StructField) string { } var timeType = reflect.TypeOf(time.Time{}) +var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() func derefType(t reflect.Type) reflect.Type { for t.Kind() == reflect.Ptr { diff --git a/zSDKs/sdk-cli/internal/output/output_test.go b/zSDKs/sdk-cli/internal/output/output_test.go index 57b755377..ef4a55755 100644 --- a/zSDKs/sdk-cli/internal/output/output_test.go +++ b/zSDKs/sdk-cli/internal/output/output_test.go @@ -89,7 +89,7 @@ func TestPrintTableSingleContainerMarshaler(t *testing.T) { t.Run(name, func(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, object) - if err != nil || buf.String() != "ENTRIES {\"text\":\"hello\"}\n" { + if err != nil || buf.String() != "FIELD VALUE\nentries {\"text\":\"hello\"}\n" { t.Fatalf("container marshaler bypassed: got %q, error %v", buf.String(), err) } }) @@ -120,7 +120,7 @@ func TestPrintTableSingleScalarMarshalers(t *testing.T) { value any want string }{ - "string value": {tableScalarText("raw"), `"wire:raw"`}, + "string value": {tableScalarText("raw"), `wire:raw`}, "integer value pointer receiver": {number, `{"amount":107}`}, "integer pointer": {&number, `{"amount":107}`}, "typed slice": {[]tableScalarNumber{number}, `[{"amount":107}]`}, @@ -131,10 +131,10 @@ func TestPrintTableSingleScalarMarshalers(t *testing.T) { err := printTable(&buf, struct { Value any `json:"wire_value"` }{tc.value}) - if err != nil || buf.String() != "WIRE_VALUE "+tc.want+"\n" { + if err != nil || buf.String() != "FIELD VALUE\nwire_value "+tc.want+"\n" { t.Fatalf("scalar marshaler bypassed: got %q, error %v", buf.String(), err) } - if !json.Valid([]byte(strings.TrimPrefix(strings.TrimSuffix(buf.String(), "\n"), "WIRE_VALUE "))) { + if tc.want != "wire:raw" && !json.Valid([]byte(strings.TrimPrefix(strings.TrimSuffix(buf.String(), "\n"), "FIELD VALUE\nwire_value "))) { t.Fatalf("invalid scalar JSON cell: %q", buf.String()) } }) @@ -162,7 +162,7 @@ func TestPrintTableScalarMarshalerCompatibility(t *testing.T) { if err := printTable(&buf, row); err != nil { t.Fatal(err) } - want := "VALUE \"wire:raw\"\nCREATED 2024-05-01T12:30:00.000000123Z\nUPDATED 2024-05-01T12:30:00.000000123Z\n" + want := "FIELD VALUE\nvalue wire:raw\ncreated 2024-05-01T12:30:00.000000123Z\nupdated 2024-05-01T12:30:00.000000123Z\n" if buf.String() != want { t.Fatalf("single-object scalar/time mismatch: got %q want %q", buf.String(), want) } @@ -198,20 +198,20 @@ func TestPrintTableSingleNestedValues(t *testing.T) { err := printTable(&buf, struct { Value any `json:"wire_value"` }{value}) - if err != nil || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || !strings.HasPrefix(buf.String(), "WIRE_VALUE ") { + if err != nil || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || !strings.HasPrefix(buf.String(), "FIELD VALUE\nwire_value ") { t.Fatalf("unexpected table: %q, error: %v", buf.String(), err) } }) } } -func TestPrintTableSingleSectionDetails(t *testing.T) { +func TestPrintTableSingleSectionColumns(t *testing.T) { rows := []*nestedTableEntry{nil, {Kind: "note", Payload: map[string]any{"text": "hello"}, Hidden: "secret"}} var buf bytes.Buffer err := printTable(&buf, struct { Entries []*nestedTableEntry `json:"entries"` }{rows}) - want := "ENTRIES\n KIND\n note\n [2] {\"kind\":\"note\",\"payload\":{\"text\":\"hello\"}}\n" + want := "ENTRIES\nKIND PAYLOAD\nnote {\"text\":\"hello\"}\n" if err != nil || buf.String() != want { t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) } @@ -224,7 +224,7 @@ func TestPrintTableSingleSectionDetails(t *testing.T) { } } -func TestPrintTableSingleUnionDetails(t *testing.T) { +func TestPrintTableSingleUnionColumns(t *testing.T) { for name, row := range map[string]nestedTableUnion{ "known": {Kind: "note", Variant: &nestedTableEntry{Kind: "note", Payload: map[string]any{"text": "hello"}}}, "unknown": {Kind: "future", UnknownRaw: json.RawMessage(`{"kind":"future","text":"hello"}`)}, @@ -234,7 +234,7 @@ func TestPrintTableSingleUnionDetails(t *testing.T) { err := printTable(&buf, struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{row}}) - if err != nil || !strings.Contains(buf.String(), "[1] {") || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || strings.Contains(buf.String(), "UNKNOWNRAW") { + if err != nil || strings.Contains(buf.String(), "[1]") || !strings.Contains(buf.String(), "hello") || strings.Contains(buf.String(), "variant") || strings.Contains(buf.String(), "UNKNOWNRAW") { t.Fatalf("unexpected union table %q, error %v", buf.String(), err) } }) @@ -264,13 +264,13 @@ func TestPrintTableSingleControls(t *testing.T) { } var decoded map[string]any if err := json.Unmarshal([]byte(line[start:]), &decoded); err != nil { - t.Fatalf("invalid JSON detail %q: %v", line, err) + t.Fatalf("invalid JSON cell %q: %v", line, err) } if text, ok := decoded["text"]; ok && text != value { t.Fatalf("control text changed: %q", text) } if payload, ok := decoded["payload"].(map[string]any); ok && payload["text"] != value { - t.Fatalf("detail control text changed: %q", payload["text"]) + t.Fatalf("cell control text changed: %q", payload["text"]) } } } @@ -291,7 +291,7 @@ func TestPrintTableSingleEmptyAndHidden(t *testing.T) { EmptySlice []map[string]string `json:"empty_slice"` Hidden []nestedTableEntry `json:"-"` }{EmptyMap: map[string]string{}, EmptySlice: []map[string]string{}, Hidden: []nestedTableEntry{{Kind: "secret"}}}) - want := "ZERO 0\nFALSE false\nEMPTY \nNIL_SCALAR \nEMPTY_MAP {}\nEMPTY_SLICE []\n" + want := "FIELD VALUE\nzero 0\nfalse false\nempty \nnil_scalar \nempty_map {}\nempty_slice []\n" if err != nil || buf.String() != want { t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) } @@ -306,17 +306,17 @@ func TestPrintTableSingleHiddenOnlyRows(t *testing.T) { }{Entries: []struct { Hidden string `json:"-"` }{{"secret"}}}) - if err != nil || buf.String() != "ENTRIES [{}]\n" { + if err != nil || buf.String() != "FIELD VALUE\nentries [{}]\n" { t.Fatalf("unexpected hidden-only table %q, error %v", buf.String(), err) } } -func TestPrintTableSingleNullUnionDetail(t *testing.T) { +func TestPrintTableSingleNullUnionCell(t *testing.T) { var buf bytes.Buffer err := printTable(&buf, struct { Entries []nestedTableUnion `json:"entries"` }{[]nestedTableUnion{{Kind: "unknown", UnknownRaw: json.RawMessage(`null`)}}}) - if err != nil || buf.String() != "ENTRIES\n KIND\n unknown\n" { + if err != nil || buf.String() != "ENTRIES\nVALUE\nnull\n" { t.Fatalf("unexpected null detail %q, error %v", buf.String(), err) } } @@ -343,6 +343,126 @@ func TestPrintTableSingleMarshalError(t *testing.T) { } } +func TestPrintTableSingleSectionWireColumns(t *testing.T) { + rows := []nestedTableUnion{ + {Kind: "internal", UnknownRaw: json.RawMessage(`{"kind":"note","payload":{"text":"hello"},"count":9007199254740993}`)}, + {Kind: "internal", UnknownRaw: json.RawMessage(`{"kind":"metric","tags":["a","b"],"count":18446744073709551615}`)}, + } + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []nestedTableUnion `json:"entries"` + }{rows}) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") + if len(lines) != 4 || !reflect.DeepEqual(strings.Fields(lines[1]), []string{"KIND", "PAYLOAD", "COUNT", "TAGS"}) { + t.Fatalf("unexpected columns: %q", buf.String()) + } + for _, value := range []string{`{"text":"hello"}`, `9007199254740993`, `18446744073709551615`, `["a","b"]`} { + if !strings.Contains(buf.String(), value) { + t.Fatalf("missing %q in %q", value, buf.String()) + } + } + if strings.Contains(buf.String(), "internal") || strings.Contains(buf.String(), "[1]") { + t.Fatalf("unexpected internal fields or detail rows: %q", buf.String()) + } +} + +func TestPrintTableSingleSectionNestedOnly(t *testing.T) { + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []struct { + Payload map[string]string `json:"payload"` + } `json:"entries"` + }{ + Entries: []struct { + Payload map[string]string `json:"payload"` + }{{map[string]string{"text": "hello"}}}, + }) + if want := "ENTRIES\nPAYLOAD\n{\"text\":\"hello\"}\n"; err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } +} + +func TestPrintTableSingleSectionNonObjectValues(t *testing.T) { + for _, payloads := range [][]string{ + {`null`}, {`{}`}, {`"hello"`}, {`[1,2]`}, {`{"value":"object"}`, `7`, `null`}, + } { + rows := make([]nestedTableUnion, len(payloads)) + for i, payload := range payloads { + rows[i] = nestedTableUnion{Kind: "internal", UnknownRaw: json.RawMessage(payload)} + } + var buf bytes.Buffer + if err := printTable(&buf, struct { + Entries []nestedTableUnion `json:"entries"` + }{rows}); err != nil { + t.Fatal(err) + } + want := "ENTRIES\nVALUE\n" + strings.Join(payloads, "\n") + "\n" + if buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } + } +} + +func TestPrintTableSingleSectionScalarMarshalers(t *testing.T) { + type row struct { + Label tableScalarText `json:"label"` + Amount tableScalarNumber `json:"amount"` + Created time.Time `json:"created"` + Hidden string `json:"-"` + } + var buf bytes.Buffer + err := printTable(&buf, struct { + Entries []row `json:"entries"` + }{[]row{{"raw", 7, time.Date(2024, 5, 1, 12, 30, 0, 123, time.UTC), "secret"}}}) + want := "ENTRIES\nLABEL AMOUNT CREATED\nwire:raw {\"amount\":107} 2024-05-01T12:30:00.000000123Z\n" + if err != nil || buf.String() != want { + t.Fatalf("got %q, error %v; want %q", buf.String(), err, want) + } +} + +func TestPrintTableSingleRootWireFields(t *testing.T) { + value := nestedTableUnion{Kind: "internal", UnknownRaw: json.RawMessage(`{"allergies":"none","owner":"demo","entries":[{"kind":"note","payload":{"text":"hello"}}]}`)} + var buf bytes.Buffer + if err := printTable(&buf, value); err != nil { + t.Fatal(err) + } + want := "FIELD VALUE\nallergies none\nowner demo\n\nENTRIES\nKIND PAYLOAD\nnote {\"text\":\"hello\"}\n" + if buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } +} + +func TestPrintTableRowAndMapControls(t *testing.T) { + value := "line\ncolumn\ttab\rreturn\x1bescape\x7fdelete\u0085next" + for name, input := range map[string]any{ + "rows": []struct { + Name string `json:"name"` + }{{value}}, + "map": map[string]string{value: value}, + } { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + if err := printTable(&buf, input); err != nil { + t.Fatal(err) + } + if strings.Count(buf.String(), "\n") != 2 { + t.Fatalf("broken table rows: %q", buf.String()) + } + for _, r := range buf.String() { + if r != '\n' && (r < 32 || (r >= 127 && r <= 159)) { + t.Fatalf("unescaped control %U in %q", r, buf.String()) + } + } + if !strings.Contains(buf.String(), `line\ncolumn\ttab\rreturn\x1bescape\u007fdelete\u0085next`) { + t.Fatalf("missing escaped value: %q", buf.String()) + } + }) + } +} + func TestAwaitAsyncDeadlineKeepsPollError(t *testing.T) { cfg := &asyncConfig{Command: "wait", Backoff: 1, States: map[string]string{"pending": "pending"}} timings := asyncTimings{interval: time.Millisecond, maxInterval: time.Millisecond, timeout: 500 * time.Millisecond} diff --git a/zSDKs/sdk-cli/tests/cli_harness_test.go b/zSDKs/sdk-cli/tests/cli_harness_test.go index 64abf7f20..041b3851d 100644 --- a/zSDKs/sdk-cli/tests/cli_harness_test.go +++ b/zSDKs/sdk-cli/tests/cli_harness_test.go @@ -877,8 +877,6 @@ func tableLines(lines ...string) string { var tableFixtureCreated = time.Date(2024, 5, 1, 12, 30, 0, 0, time.UTC) -// TestTableOutputRendersListItems verifies that a list response renders its -// items as an indented sub-table followed by its set scalar fields. func TestTableOutputRendersListItems(t *testing.T) { alpha, beta, token := "alpha", "beta", "page-2" list := &tableFixtureList{ @@ -891,20 +889,22 @@ func TestTableOutputRendersListItems(t *testing.T) { assert.Equal(t, tableLines( "ITEMS", - " NAME CREATED TAGS", - " alpha 2024-05-01T12:30:00Z a, b", - " beta 2024-05-01T12:30:00Z ", + "NAME CREATED TAGS", + `alpha 2024-05-01T12:30:00Z ["a","b"]`, + "beta 2024-05-01T12:30:00Z ", "", - "NEXTPAGETOKEN page-2", + "FIELD VALUE", + "nextPageToken page-2", ), renderTable(t, list)) t.Run("empty page token is kept", func(t *testing.T) { empty := "" assert.Equal(t, tableLines( "ITEMS", - " (empty)", + "(empty)", "", - "NEXTPAGETOKEN ", + "FIELD VALUE", + "nextPageToken ", ), renderTable(t, &tableFixtureList{NextPageToken: &empty})) }) @@ -912,9 +912,9 @@ func TestTableOutputRendersListItems(t *testing.T) { list.NextPageToken = nil assert.Equal(t, tableLines( "ITEMS", - " NAME CREATED TAGS", - " alpha 2024-05-01T12:30:00Z a, b", - " beta 2024-05-01T12:30:00Z ", + "NAME CREATED TAGS", + `alpha 2024-05-01T12:30:00Z ["a","b"]`, + "beta 2024-05-01T12:30:00Z ", ), renderTable(t, list)) }) @@ -922,13 +922,13 @@ func TestTableOutputRendersListItems(t *testing.T) { item := tableFixtureItem{Name: &beta, Created: tableFixtureCreated} assert.Equal(t, tableLines( "ITEMS", - " NAME CREATED TAGS", - " beta 2024-05-01T12:30:00Z ", + "NAME CREATED", + "beta 2024-05-01T12:30:00Z", ), renderTable(t, &tableFixtureList{Items: []*tableFixtureItem{nil, &item}})) }) t.Run("empty page", func(t *testing.T) { - assert.Equal(t, tableLines("ITEMS", " (empty)"), renderTable(t, &tableFixtureList{})) + assert.Equal(t, tableLines("ITEMS", "(empty)"), renderTable(t, &tableFixtureList{})) }) } @@ -945,13 +945,14 @@ func TestTableOutputRendersResourceSections(t *testing.T) { }, } assert.Equal(t, tableLines( - "ID o1", - "NAME sam", - `PARENT {"created":"0001-01-01T00:00:00Z"}`, + "FIELD VALUE", + "id o1", + "name sam", + `parent {"created":"0001-01-01T00:00:00Z"}`, "", "MEMBERS", - " NAME CREATED TAGS", - " rex 2024-05-01T12:30:00Z ", + "NAME CREATED", + "rex 2024-05-01T12:30:00Z", ), renderTable(t, resource)) } @@ -960,9 +961,10 @@ func TestTableOutputRendersResourceSections(t *testing.T) { func TestTableOutputRendersSingleStruct(t *testing.T) { name := "alpha" assert.Equal(t, tableLines( - "NAME alpha", - "CREATED 2024-05-01T12:30:00Z", - "TAGS ", + "FIELD VALUE", + "name alpha", + "created 2024-05-01T12:30:00Z", + "tags ", ), renderTable(t, &tableFixtureItem{Name: &name, Created: tableFixtureCreated})) } From 5c7b42cb97cb448532b83797fcf38901a37a4107 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 19:16:11 +0100 Subject: [PATCH 5/5] fix(cli): preserve primitive array cells --- templates/templates/cli/README.md | 2 +- .../auxiliary/internal/output/output.go.stmpl | 28 +++++++++++++++++-- .../internal/output/output_test.go.stmpl | 22 +++++++++++++++ .../primary/table_additional_test.go.stmpl | 5 ++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +-- zSDKs/sdk-cli/internal/output/output.go | 28 +++++++++++++++++-- zSDKs/sdk-cli/internal/output/output_test.go | 22 +++++++++++++++ 7 files changed, 104 insertions(+), 7 deletions(-) diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 010354d70..e1fc2abe3 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -672,7 +672,7 @@ Three output paths: - `pretty` (default): Aligned key-value pairs with color, nested indentation - `json`: Marshaled from the typed Go struct (note: field order and precision may differ from original API JSON) - `yaml`: Via `gopkg.in/yaml.v3` - - `table`: Aligned columns via `printTable()` — multi-row for slices, `FIELD`/`VALUE` tables for single structs, two-column for maps. Single-object field labels use their JSON names. Nested structs, maps, and complex arrays render as compact JSON cells using custom JSON marshalers; serialized strings such as dates are displayed without JSON quotes. Root custom marshalers define the displayed fields too, including flattened additional properties and omitted fields. Arrays of structs render as titled sub-tables with one row per non-nil entry; columns come from the serialized object's fields in first-seen order across all rows, including fields introduced by later union variants. Nested values occupy compact JSON cells in those columns. Fields absent from a row have blank cells, explicit JSON nulls remain `null`, and fields omitted by the row's JSON serialization do not introduce columns. Custom row marshalers that produce non-object values, or sections with no object keys, use a single `VALUE` column containing each row's complete JSON value. Section scalar arrays also use JSON cells. List wrappers and pagination without a declared results path use these same tables. Direct array tables and `--all` tables that aggregate a declared results array of structs retain their existing columns, with control characters escaped in row cells, column headers, and map keys/values. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values on the reflected object path and preserve explicit nulls from custom root serialization, retain non-nil empty maps/arrays, and use object, field, or row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and per-item pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails. Declared-results `--all` struct aggregation bypasses `outputOneItem()` and uses the existing `PaginatedResult()` `flushRows` fallback + - `table`: Aligned columns via `printTable()` — multi-row for slices, `FIELD`/`VALUE` tables for single structs, two-column for maps. Single-object field labels use their JSON names. Nested structs, maps, and complex arrays render as compact JSON cells using custom JSON marshalers; serialized strings such as dates are displayed without JSON quotes. Root custom marshalers define the displayed fields too, including flattened additional properties and omitted fields. Arrays of structs render as titled sub-tables with one row per non-nil entry; columns come from the serialized object's fields in first-seen order across all rows, including fields introduced by later union variants. Nested values occupy compact JSON cells in those columns. Fields absent from a row have blank cells, explicit JSON nulls remain `null`, and fields omitted by the row's JSON serialization do not introduce columns. Custom row marshalers that produce non-object values, or sections with no object keys, use a single `VALUE` column containing each row's complete JSON value. Section scalar arrays use JSON cells; top-level primitive arrays retain comma-separated cells, including on custom-marshaled objects. List wrappers and pagination without a declared results path use these same tables. Direct array tables and `--all` tables that aggregate a declared results array of structs retain their existing columns, with control characters escaped in row cells, column headers, and map keys/values. Single-object tables exclude `json:"-"` fields, escape control characters, omit nil nested values on the reflected object path and preserve explicit nulls from custom root serialization, retain non-nil empty maps/arrays, and use object, field, or row context for serialization errors. `Result()` buffers a single struct's table output and propagates those errors before writing the object; per-item streaming and per-item pagination retain the existing `outputOneItem()` fallback to pretty output when table rendering fails. Declared-results `--all` struct aggregation bypasses `outputOneItem()` and uses the existing `PaginatedResult()` `flushRows` fallback - `toon`: [Token-Oriented Object Notation](https://github.com/toon-format/spec) via `github.com/alpkeskin/gotoon` — compact, line-oriented, 30–60% fewer tokens than JSON. gotoon omits the final newline, so `encodeTOON()` terminates non-empty output with one, matching JSON and YAML and giving per-item streaming/pagination output its blank-line separator `pretty`, `yaml` and `toon` never reflect over the typed Go structs directly. `normalizeForOutput()` (`output.go.stmpl`) marshals the value once through the SDK's `utils.MarshalJSON` and decodes it back with `json.Decoder.UseNumber`, and the renderers work from that wire shape (`marshalYAML()`, `encodeTOON()`, `prettyPrint()`, shared by `Result()` and the per-item streaming/pagination path in `outputitems.go.stmpl`). Reflecting encoders would otherwise leak generated union internals — wrapper keys such as `ErrorEvent:`, `null` sibling variants, `Type`/`UnknownRaw` — plus raw struct-tag options as keys (`"error,omitzero":`) and lowercased Go field names in YAML. Numbers stay exact: `json.Number` is converted per renderer (`convertNumbers()` with `yamlNumber()`/`toonNumber()`, `formatJSONNumber()` for pretty) to a native `int64`/`float64` only when that value re-encodes to the identical JSON lexeme (`nativeNumber()`); otherwise the lexeme is kept verbatim — a tagged `!!int`/`!!float` scalar node for yaml, a string for TOON (which has no wider numeric type), the literal for pretty — so bigint/decimal fields never lose digits. TOON's own encoder goes through `float64`, so native integers above 2^53 round there (a gotoon property, unchanged). `--include-headers` output (`outputWithHeaders()`) already worked from the marshalled JSON. Covered by `TestEventStreamUnionEventsRenderWireShape` and `TestEventStreamNumbersKeepPrecision` in `tests/primary/eventstreaming_test.go.stmpl`. diff --git a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index 71d858c94..c3a0fcf1d 100644 --- a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl @@ -1809,7 +1809,7 @@ func printSingleJSONTable(out io.Writer, data string) error { } continue } - cell, err := tableJSONCell(string(field.data)) + cell, err := singleJSONTableCell(field.data) if err != nil { return fmt.Errorf("table field %s: %w", field.name, err) } @@ -1835,6 +1835,28 @@ func printSingleJSONTable(out io.Writer, data string) error { return err } +func singleJSONTableCell(data json.RawMessage) (string, error) { + if len(data) == 0 || data[0] != '[' { + return tableJSONCell(string(data)) + } + var values []json.RawMessage + if err := json.Unmarshal(data, &values); err != nil { + return "", err + } + cells := make([]string, len(values)) + for i, value := range values { + if value[0] == '{' || value[0] == '[' { + return tableJSONCell(string(data)) + } + cell, err := tableJSONCell(string(value)) + if err != nil { + return "", err + } + cells[i] = cell + } + return strings.Join(cells, ", "), nil +} + func tableJSONSection(data json.RawMessage) ([]string, bool) { if len(data) == 0 || data[0] != '[' { return nil, false @@ -1844,13 +1866,15 @@ func tableJSONSection(data json.RawMessage) ([]string, bool) { return nil, false } rows := make([]string, len(values)) + hasObject := false for i, value := range values { if len(value) == 0 || (value[0] != '{' && string(value) != "null") { return nil, false } + hasObject = hasObject || value[0] == '{' rows[i] = string(value) } - return rows, true + return rows, hasObject } // printTableMap renders a map as a two-column key-value table. diff --git a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl index d5708d745..2337db650 100644 --- a/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output_test.go.stmpl @@ -431,6 +431,28 @@ func TestPrintTableRowAndMapControls(t *testing.T) { } } +func TestPrintTableSingleRootScalarArrays(t *testing.T) { + t.Run("nullable primitive array", func(t *testing.T) { + var buf bytes.Buffer + value := nestedTableUnion{UnknownRaw: json.RawMessage(`{"nullable":[null]}`)} + if err := printTable(&buf, value); err != nil { + t.Fatal(err) + } + if want := "FIELD VALUE\nnullable null\n"; buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } + }) + value := nestedTableUnion{UnknownRaw: json.RawMessage(`{"names":["one","two"],"counts":[1,9007199254740993],"flags":[true,false],"empty":[],"nested":[["one"]],"escaped":["line\nvalue","tab\tvalue"]}`)} + var buf bytes.Buffer + if err := printTable(&buf, value); err != nil { + t.Fatal(err) + } + want := "FIELD VALUE\nnames one, two\ncounts 1, 9007199254740993\nflags true, false\nempty \nnested [[\"one\"]]\nescaped line\\nvalue, tab\\tvalue\n" + if buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } +} + {{- if hasCliAsyncIntents}} func TestAwaitAsyncDeadlineKeepsPollError(t *testing.T) { diff --git a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl index 6bbdc074d..2017636c3 100644 --- a/templates/templates/cli/tests/primary/table_additional_test.go.stmpl +++ b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl @@ -159,3 +159,8 @@ func TestTableGeneratedDateCell(t *testing.T) { }{value}) assert.Equal(t, "FIELD VALUE\npublished 2024-05-01\n", got) } + +func TestTableGeneratedScalarArrayCells(t *testing.T) { + object := components.ObjWithAnyAdditionalProperties{AdditionalProperties: map[string]any{"names": []string{"one", "two"}}} + assert.Equal(t, "FIELD VALUE\nnames one, two\n", renderTable(t, object)) +} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 54713b7ab..aa865c0a5 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -274,9 +274,9 @@ trackedFiles: internal/output/jq.go: last_write_checksum: sha1:4ec4f6429c1d7eaa780a9e33147ba8087b678169 internal/output/output.go: - last_write_checksum: sha1:3a70d1a85981bd265ade83fec6620bf1acbbfad6 + last_write_checksum: sha1:d0b725d381b6eba3f90f06b98cd0a396e62d35de internal/output/output_test.go: - last_write_checksum: sha1:ccfa6d0a2141384ccec096c6931e428a0ce3c32f + last_write_checksum: sha1:f7c80180b57d5f61a5575903b1818c8f37a5ef27 internal/output/outputitems.go: last_write_checksum: sha1:d327eee8d9a395821fef201257e033bae50cda51 internal/output/paginated.go: diff --git a/zSDKs/sdk-cli/internal/output/output.go b/zSDKs/sdk-cli/internal/output/output.go index b18b7a206..cc75b9f9a 100644 --- a/zSDKs/sdk-cli/internal/output/output.go +++ b/zSDKs/sdk-cli/internal/output/output.go @@ -1791,7 +1791,7 @@ func printSingleJSONTable(out io.Writer, data string) error { } continue } - cell, err := tableJSONCell(string(field.data)) + cell, err := singleJSONTableCell(field.data) if err != nil { return fmt.Errorf("table field %s: %w", field.name, err) } @@ -1817,6 +1817,28 @@ func printSingleJSONTable(out io.Writer, data string) error { return err } +func singleJSONTableCell(data json.RawMessage) (string, error) { + if len(data) == 0 || data[0] != '[' { + return tableJSONCell(string(data)) + } + var values []json.RawMessage + if err := json.Unmarshal(data, &values); err != nil { + return "", err + } + cells := make([]string, len(values)) + for i, value := range values { + if value[0] == '{' || value[0] == '[' { + return tableJSONCell(string(data)) + } + cell, err := tableJSONCell(string(value)) + if err != nil { + return "", err + } + cells[i] = cell + } + return strings.Join(cells, ", "), nil +} + func tableJSONSection(data json.RawMessage) ([]string, bool) { if len(data) == 0 || data[0] != '[' { return nil, false @@ -1826,13 +1848,15 @@ func tableJSONSection(data json.RawMessage) ([]string, bool) { return nil, false } rows := make([]string, len(values)) + hasObject := false for i, value := range values { if len(value) == 0 || (value[0] != '{' && string(value) != "null") { return nil, false } + hasObject = hasObject || value[0] == '{' rows[i] = string(value) } - return rows, true + return rows, hasObject } // printTableMap renders a map as a two-column key-value table. diff --git a/zSDKs/sdk-cli/internal/output/output_test.go b/zSDKs/sdk-cli/internal/output/output_test.go index ef4a55755..493b2b56e 100644 --- a/zSDKs/sdk-cli/internal/output/output_test.go +++ b/zSDKs/sdk-cli/internal/output/output_test.go @@ -463,6 +463,28 @@ func TestPrintTableRowAndMapControls(t *testing.T) { } } +func TestPrintTableSingleRootScalarArrays(t *testing.T) { + t.Run("nullable primitive array", func(t *testing.T) { + var buf bytes.Buffer + value := nestedTableUnion{UnknownRaw: json.RawMessage(`{"nullable":[null]}`)} + if err := printTable(&buf, value); err != nil { + t.Fatal(err) + } + if want := "FIELD VALUE\nnullable null\n"; buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } + }) + value := nestedTableUnion{UnknownRaw: json.RawMessage(`{"names":["one","two"],"counts":[1,9007199254740993],"flags":[true,false],"empty":[],"nested":[["one"]],"escaped":["line\nvalue","tab\tvalue"]}`)} + var buf bytes.Buffer + if err := printTable(&buf, value); err != nil { + t.Fatal(err) + } + want := "FIELD VALUE\nnames one, two\ncounts 1, 9007199254740993\nflags true, false\nempty \nnested [[\"one\"]]\nescaped line\\nvalue, tab\\tvalue\n" + if buf.String() != want { + t.Fatalf("got %q; want %q", buf.String(), want) + } +} + func TestAwaitAsyncDeadlineKeepsPollError(t *testing.T) { cfg := &asyncConfig{Command: "wait", Backoff: 1, States: map[string]string{"pending": "pending"}} timings := asyncTimings{interval: time.Millisecond, maxInterval: time.Millisecond, timeout: 500 * time.Millisecond}