Skip to content

output: JSONWriter.Table stringifies every value, emits an array rather than NDJSON, and uses headings as keys #109

Description

@Ilyes512

Rewritten from the original placeholder. The observation below started as a question about JSON key
naming; researching it showed the key naming cannot be fixed on its own, so the issue now covers the
shape of the method.

The observation

JSONWriter.Table — writer.go:109-127:

func (w *JSONWriter) Table(headers []string, rows [][]string) {
    records := make([]map[string]string, len(rows))

    for i, row := range rows {
        record := make(map[string]string, len(headers))

        for j, header := range headers {
            if j < len(row) {
                record[header] = row[j]
            }
        }

        records[i] = record
    }

    data, _ := json.Marshal(records)
    fmt.Fprintln(w.stdout, string(data))
}

Three separate problems, none of which can be fixed without the others.

1. Every value is a string

The signature is rows [][]string, so the pretty renderer's display strings are the only thing the
JSON writer ever sees. A count arrives as "12", not 12; a boolean as "true"; a timestamp as
whatever the table decided to render it as. jq 'select(.count > 5)' cannot work on any of it, and a
consumer has to know which fields to tonumber back.

2. It emits one JSON array, but the format is documented as NDJSON

overview.md:293
describes -o json as "NDJSON lines" and then, in the same cell, "Table → JSON array to stdout".
Both cannot be true. An array also cannot be parsed until the closing bracket arrives, so a killed or
failed run leaves nothing readable — which is the property NDJSON exists to provide.

3. The header string doubles as the JSON key

The original observation. log_test.go asserts the consequence directly — "Name":"my-tpl",
"Version":"1.0.0" — capitalised keys, because Name and Version are what the pretty table prints
in its header row. One string is doing two jobs for two audiences: a column heading is prose for a
human and may be reworded freely; a JSON key is what a consumer's jq filter matches on. A
multi-word heading yields jq '.["Some heading"]', and rewording a heading silently starts returning
null downstream.

Single-word headings are why this has not bitten yet.

Proposal

Give the two audiences separate inputs, and the key-naming question answers itself: keys come from
json tags on a row struct, headings stay prose.

// Column describes one column of a pretty table: the heading a human reads,
// and how a row renders in that column.
type Column[T any] struct {
    Header string
    Cell   func(T) string
}

func Col[T any](header string, cell func(T) string) Column[T]

// Table writes rows as the command's product: a bordered table for a human,
// one JSON object per row for a machine.
func Table[T any](w Writer, rows []T, cols ...Column[T])

Call site:

type templateRow struct {
    Name    string `json:"name"`
    Version string `json:"version"`
    Updates int    `json:"updates_available"`
}

output.Table(w, rows,
    output.Col("Name", func(r templateRow) string { return r.Name }),
    output.Col("Version", func(r templateRow) string { return r.Version }),
    output.Col("Updates available", func(r templateRow) string { return strconv.Itoa(r.Updates) }),
)

The Writer method underneath becomes WriteTable(TableData) where TableData carries Headers,
Cells and Records: the pretty writer renders the cells, the JSON writer marshals the records
one per line. Going through the generic constructor is also what guarantees every row has exactly one
cell per header — alignment a raw [][]string can silently get wrong today.

Result: {"name":"my-tpl","version":"1.0.0","updates_available":3} — a number stays a number, the
heading Updates available can be reworded without touching updates_available, and the stream is
genuinely NDJSON.

Open questions

  • Are the current JSON keys a contract anyone depends on? They are capitalised
    (Name, Version) and would change to snake_case json tags. -o json is not yet documented as
    stable anywhere I can find, but this is the one genuinely breaking part and should be a deliberate
    call — possibly worth a note in the release notes rather than a compatibility shim.
  • Is the array → NDJSON change breaking in practice? Anything doing specs template list -o json | jq '.[]' would need jq -s '.[]' or just jq .. Same question, same answer required.
  • Should WriteTable stay on the Writer interface at all, or should the generic Table be the only
    public entry point?

Scope

  • internal/util/output/rows.go (new) — Column, Col, TableData, generic Table
  • internal/util/output/writer.goTable(headers, rows)WriteTable(TableData) on both
    implementations; JSON writes one object per line
  • Call sites in internal/cmd — define a row struct with json tags per table
  • internal/util/output/log_test.go — the assertions on "Name" / "Version" keys are the
    current behaviour being pinned; update them
  • docs/content/docs/architecture/overview.md:293 — the row that contradicts itself

Done when

  • specs template list -o json emits one object per line
  • Numeric fields are JSON numbers
  • JSON keys come from json tags, not from column headings
  • Rewording a column heading does not change the JSON

Notes

Related: #113 (stdout/stderr split — a table is a product and stays on stdout under both issues) and
#116 (golden tests), which is worth landing first so the pretty rendering is pinned before the
signature changes underneath it.

Prior art, from the original body: specsnl/labelsync#51 solved the same three problems together. The
argument above stands on this repo's own behaviour; the link is kept only as a worked example.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions