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/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..e1fc2abe3 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 + - `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`. +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). **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/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index 989a1f085..c3a0fcf1d 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. -// Other complex nested fields are skipped. 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,124 +1404,477 @@ 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")) } 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() + 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) } - cols := collectTableColumns(v.Type()) - if len(cols) == 0 { + sections := hasTableSections(t) + var buf bytes.Buffer + var tw *tabwriter.Writer + blocks := 0 + startBlock := func() { + if blocks > 0 { + fmt.Fprintln(&buf) + } + blocks++ + } + 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)))) + if err := printSingleTableRows(&buf, value); err != nil { + return fmt.Errorf("table field %s: %w", tableFieldName(f), err) + } + 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.Fprintln(tw, "FIELD\tVALUE") + } + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(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 i := 0; i < t.NumField(); i++ { + if f := t.Field(i); singleTableField(f) { + cols = append(cols, tableColumn{name: tableFieldName(f), index: i}) + } } - 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) + if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) { + 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 + } + v = v.Elem() + } + if v.Type() == timeType { + return false + } + if v.Type().Implements(jsonMarshalerType) || reflect.PointerTo(v.Type()).Implements(jsonMarshalerType) { + return true + } + switch v.Kind() { + case reflect.Struct: + return true + 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 + } + } + } + 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 isTableScalar(f.Type) { - if isNilTableValue(v.Field(i)) { - continue + 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) + } + } + 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 tw == nil { - startBlock() - tw = newTabWriter(&buf) + 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() + } else { + items[i] = item.Interface() } - fmt.Fprintf(tw, "%s\t%s\n", strings.ToUpper(tableFieldName(f)), formatTableCell(v.Field(i))) - continue } - if !isTableSection(f.Type) { + return marshalTableJSON(items) + } + 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) { + data, err := tableJSON(v) + if err != nil { + return "", err + } + return tableJSONCell(data) + } + 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) + } + } + return b.String() +} + +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() +} + +type singleTableRow struct { + data string + cells map[string]string +} + +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() + } + var dataRows []string + for i := 0; i < rows.Len(); i++ { + row := rows.Index(i) + if nilSingleTableValue(row) { continue } - if tw != nil { - if err := tw.Flush(); err != nil { - return err + 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) } - 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 { + 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 + } + tw := newTabWriter(out) + 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")) + for _, row := range rendered { + values := make([]string, len(columns)) + for i, name := range columns { + values[i] = row.cells[name] + } + 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 + } + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return nil, 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 } - for _, line := range strings.SplitAfter(section.String(), "\n") { - if line != "" { - buf.WriteString(" " + line) + _, 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) + } + 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 := singleJSONTableCell(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 tw != nil { if err := tw.Flush(); err != nil { return err } } - _, err := out.Write(buf.Bytes()) + if blocks == 0 { + fmt.Fprintln(&buf, "(empty)") + } + _, err = out.Write(buf.Bytes()) return err } -func isNilTableValue(v reflect.Value) bool { - for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { - if v.IsNil() { - return true +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)) } - v = v.Elem() + 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 + } + var values []json.RawMessage + if json.Unmarshal(data, &values) != nil || len(values) == 0 { + 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 v.Kind() == reflect.Slice && v.IsNil() + return rows, hasObject } // printTableMap renders a map as a two-column key-value table. @@ -1534,7 +1886,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() } @@ -1572,6 +1924,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 002c3a711..2337db650 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" @@ -9,8 +10,8 @@ import ( "reflect" "strings" "testing" - {{- if hasCliAsyncIntents}} "time" + {{- if hasCliAsyncIntents}} "github.com/spf13/cobra" {{- end}} @@ -38,6 +39,420 @@ 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() != "FIELD VALUE\nentries {\"text\":\"hello\"}\n" { + t.Fatalf("container marshaler bypassed: got %q, error %v", buf.String(), err) + } + }) + } +} + +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() != "FIELD VALUE\nwire_value " + tc.want + "\n" { + t.Fatalf("scalar marshaler bypassed: got %q, error %v", buf.String(), err) + } + 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()) + } + }) + } +} + +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 := "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) + } + 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"}, + "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(), "FIELD VALUE\nwire_value ") { + t.Fatalf("unexpected table: %q, error: %v", buf.String(), err) + } + }) + } +} + +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\nKIND PAYLOAD\nnote {\"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 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"}`)}, + } { + 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 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("cell 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 := "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) + } +} + +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() != "FIELD VALUE\nentries [{}]\n" { + t.Fatalf("unexpected hidden-only table %q, error %v", buf.String(), err) + } +} + +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\nVALUE\nnull\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 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 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/cli_harness_test.go.stmpl b/templates/templates/cli/cli_harness_test.go.stmpl index 99da8bd73..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,12 +1113,14 @@ func TestTableOutputRendersResourceSections(t *testing.T) { }, } assert.Equal(t, tableLines( - "ID o1", - "NAME sam", + "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)) } @@ -1127,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 new file mode 100644 index 000000000..2017636c3 --- /dev/null +++ b/templates/templates/cli/tests/primary/table_additional_test.go.stmpl @@ -0,0 +1,166 @@ +package tests + +import ( + "encoding/json" + "strings" + "testing" + + "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"` + 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( + "FIELD VALUE", + "id record-1", + `metadata {"count":2}`, + "", + "ENTRIES", + "KIND PAYLOAD", + `note {"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") +} + +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, 5) + assert.Empty(t, lines[2]) + for i, text := range []string{"Hello\n\t\x7f", "world"} { + 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)) + assert.Equal(t, map[string]any{"event_type": "delta", "text": text}, event) + } + 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) +} + +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 ba46d959c..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:83b7169a0320aaf7f3462504dca6799f856b55d5 + last_write_checksum: sha1:d0b725d381b6eba3f90f06b98cd0a396e62d35de internal/output/output_test.go: - last_write_checksum: sha1:a3d8e11b6ad5fa2b199decf5dc69771632db3417 + last_write_checksum: sha1:f7c80180b57d5f61a5575903b1818c8f37a5ef27 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: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 53142356a..cc75b9f9a 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. -// Other complex nested fields are skipped. 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,124 +1386,477 @@ 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")) } 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() + 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) } - cols := collectTableColumns(v.Type()) - if len(cols) == 0 { + sections := hasTableSections(t) + var buf bytes.Buffer + var tw *tabwriter.Writer + blocks := 0 + startBlock := func() { + if blocks > 0 { + fmt.Fprintln(&buf) + } + blocks++ + } + 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)))) + if err := printSingleTableRows(&buf, value); err != nil { + return fmt.Errorf("table field %s: %w", tableFieldName(f), err) + } + 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.Fprintln(tw, "FIELD\tVALUE") + } + fmt.Fprintf(tw, "%s\t%s\n", safeTableText(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 i := 0; i < t.NumField(); i++ { + if f := t.Field(i); singleTableField(f) { + cols = append(cols, tableColumn{name: tableFieldName(f), index: i}) + } } - 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) + if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) { + 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 + } + v = v.Elem() + } + if v.Type() == timeType { + return false + } + if v.Type().Implements(jsonMarshalerType) || reflect.PointerTo(v.Type()).Implements(jsonMarshalerType) { + return true + } + switch v.Kind() { + case reflect.Struct: + return true + 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 + } + } + } + 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 isTableScalar(f.Type) { - if isNilTableValue(v.Field(i)) { - continue + 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) + } + } + 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 tw == nil { - startBlock() - tw = newTabWriter(&buf) + 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() + } else { + items[i] = item.Interface() } - fmt.Fprintf(tw, "%s\t%s\n", strings.ToUpper(tableFieldName(f)), formatTableCell(v.Field(i))) - continue } - if !isTableSection(f.Type) { + return marshalTableJSON(items) + } + 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) { + data, err := tableJSON(v) + if err != nil { + return "", err + } + return tableJSONCell(data) + } + 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) + } + } + return b.String() +} + +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() +} + +type singleTableRow struct { + data string + cells map[string]string +} + +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() + } + var dataRows []string + for i := 0; i < rows.Len(); i++ { + row := rows.Index(i) + if nilSingleTableValue(row) { continue } - if tw != nil { - if err := tw.Flush(); err != nil { - return err + 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) } - 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 { + 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 + } + tw := newTabWriter(out) + 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")) + for _, row := range rendered { + values := make([]string, len(columns)) + for i, name := range columns { + values[i] = row.cells[name] + } + 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 + } + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return nil, 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 } - for _, line := range strings.SplitAfter(section.String(), "\n") { - if line != "" { - buf.WriteString(" " + line) + _, 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) + } + 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 := singleJSONTableCell(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 tw != nil { if err := tw.Flush(); err != nil { return err } } - _, err := out.Write(buf.Bytes()) + if blocks == 0 { + fmt.Fprintln(&buf, "(empty)") + } + _, err = out.Write(buf.Bytes()) return err } -func isNilTableValue(v reflect.Value) bool { - for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { - if v.IsNil() { - return true +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)) } - v = v.Elem() + 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 + } + var values []json.RawMessage + if json.Unmarshal(data, &values) != nil || len(values) == 0 { + 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 v.Kind() == reflect.Slice && v.IsNil() + return rows, hasObject } // printTableMap renders a map as a two-column key-value table. @@ -1516,7 +1868,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() } @@ -1554,6 +1906,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 068f8837f..493b2b56e 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,454 @@ 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() != "FIELD VALUE\nentries {\"text\":\"hello\"}\n" { + t.Fatalf("container marshaler bypassed: got %q, error %v", buf.String(), err) + } + }) + } +} + +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() != "FIELD VALUE\nwire_value "+tc.want+"\n" { + t.Fatalf("scalar marshaler bypassed: got %q, error %v", buf.String(), err) + } + 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()) + } + }) + } +} + +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 := "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) + } + 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"}, + "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(), "FIELD VALUE\nwire_value ") { + t.Fatalf("unexpected table: %q, error: %v", buf.String(), err) + } + }) + } +} + +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\nKIND PAYLOAD\nnote {\"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 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"}`)}, + } { + 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 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("cell 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 := "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) + } +} + +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() != "FIELD VALUE\nentries [{}]\n" { + t.Fatalf("unexpected hidden-only table %q, error %v", buf.String(), err) + } +} + +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\nVALUE\nnull\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 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 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} diff --git a/zSDKs/sdk-cli/tests/cli_harness_test.go b/zSDKs/sdk-cli/tests/cli_harness_test.go index a96db167c..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,12 +945,14 @@ func TestTableOutputRendersResourceSections(t *testing.T) { }, } assert.Equal(t, tableLines( - "ID o1", - "NAME sam", + "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)) } @@ -959,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})) }