From aab08d438cdbe209429e173710b7a1613f54f7e8 Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Sat, 5 Sep 2026 11:24:11 -0600 Subject: [PATCH] fix(query): preserve selected field order in JSONL output --- cmd/rootline/commands_test.go | 76 ++++++++++++++++++++++++++--------- cmd/rootline/query.go | 32 ++++++++++++--- 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/cmd/rootline/commands_test.go b/cmd/rootline/commands_test.go index c1323765..d0366ee6 100644 --- a/cmd/rootline/commands_test.go +++ b/cmd/rootline/commands_test.go @@ -1344,28 +1344,68 @@ func TestQueryOutputCSVNoSelect(t *testing.T) { func TestQueryOutputJSONLColumnOrder(t *testing.T) { dir := setupTestDir(t) - out, err := runCmd(t, "query", "--from", dir, "--select", "estado,path", "--output", "jsonl") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - lines := strings.Split(strings.TrimSpace(out), "\n") - if len(lines) == 0 { - t.Fatal("expected JSONL output lines") + for _, tc := range []struct { + selectFields string + want string + }{ + { + selectFields: "path,estado,tipo", + want: "{\"path\":\"doc1.md\",\"estado\":\"Pending\",\"tipo\":\"test\"}\n" + + "{\"path\":\"doc2.md\",\"estado\":\"Completed\",\"tipo\":\"prod\"}\n", + }, + { + selectFields: "tipo,path,estado", + want: "{\"tipo\":\"test\",\"path\":\"doc1.md\",\"estado\":\"Pending\"}\n" + + "{\"tipo\":\"prod\",\"path\":\"doc2.md\",\"estado\":\"Completed\"}\n", + }, + { + selectFields: "tipo,missing,path,tipo,links,estado", + want: "{\"tipo\":\"test\",\"path\":\"doc1.md\",\"estado\":\"Pending\"}\n" + + "{\"tipo\":\"prod\",\"path\":\"doc2.md\",\"estado\":\"Completed\"}\n", + }, + {selectFields: "missing,links", want: "{}\n{}\n"}, + } { + t.Run(tc.selectFields, func(t *testing.T) { + out, err := runCmd(t, "query", dir, "--select", tc.selectFields, "--sort", "path:asc", "-o", "jsonl") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != tc.want { + t.Errorf("JSONL output = %q, want %q", out, tc.want) + } + for _, line := range strings.Split(strings.TrimSuffix(out, "\n"), "\n") { + if !json.Valid([]byte(line)) { + t.Errorf("invalid JSONL line: %q", line) + } + } + }) } +} - // Parse first line and check field order is preserved in the JSON object - var obj map[string]any - if err := json.Unmarshal([]byte(lines[0]), &obj); err != nil { - t.Fatalf("first line not valid JSON: %v", err) +func TestQueryOutputJSONLPreservesValuesAndEscaping(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".stem"), []byte("version: 2\nroot: true\nscope:\n match: '*.md'\nschema: {}\n"), 0o644) + mustWriteFile(t, filepath.Join(dir, "doc.md"), []byte(`--- +text: "line one\n\"quoted\" \\ café " +'quoted"key': value +empty: null +enabled: false +count: 0 +items: [one, two] +object: {nested: true} +--- +# Document +`), 0o644) + out, err := runCmd(t, "query", dir, "--select", `text,quoted"key,empty,enabled,count,items,object,path,missing,links`, "-o", "jsonl") + if err != nil { + t.Fatalf("unexpected error: %v", err) } - - // Both fields should exist in the projected row - if _, ok := obj["estado"]; !ok { - t.Errorf("expected 'estado' field in JSONL object") + want := `{"text":"line one\n\"quoted\" \\ café \u003ctag\u003e","quoted\"key":"value","empty":null,"enabled":false,"count":0,"items":["one","two"],"object":{"nested":true},"path":"doc.md"}` + "\n" + if out != want { + t.Errorf("JSONL output = %q, want %q", out, want) } - if _, ok := obj["path"]; !ok { - t.Errorf("expected 'path' field in JSONL object") + if !json.Valid([]byte(out)) { + t.Errorf("invalid JSONL output: %q", out) } } diff --git a/cmd/rootline/query.go b/cmd/rootline/query.go index c9e858c5..d45a4884 100644 --- a/cmd/rootline/query.go +++ b/cmd/rootline/query.go @@ -216,7 +216,7 @@ func runQuery(cmd *cobra.Command, args []string) error { if querySelect == "" { return fmt.Errorf("jsonl output requires --select flag") } - return outputQueryJSONL(cmd, result) + return outputQueryJSONL(cmd, result, parseSelectFields(querySelect)) } if outputFormat == "csv" { if querySelect == "" { @@ -392,18 +392,38 @@ func projectQueryResult(result any, fields []string) (any, error) { } // outputQueryJSONL outputs a ProjectedQueryResult as JSON Lines (one JSON object per line). -func outputQueryJSONL(cmd *cobra.Command, result any) error { +// Present keys follow --select order instead of encoding/json's map-key order. +func outputQueryJSONL(cmd *cobra.Command, result any, fields []string) error { pqr, ok := result.(*query.ProjectedQueryResult) if !ok { return fmt.Errorf("expected ProjectedQueryResult for jsonl output") } for _, row := range pqr.Rows { - b, err := json.Marshal(row) - if err != nil { - return fmt.Errorf("marshaling row to JSON: %w", err) + var b strings.Builder + b.WriteByte('{') + seen := make(map[string]bool, len(fields)) + for _, field := range fields { + value, present := row[field] + if !present || seen[field] { + continue + } + encodedValue, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling row to JSON: %w", err) + } + // A string key always marshals successfully; preserve JSON escaping. + encodedKey, _ := json.Marshal(field) + if len(seen) > 0 { + b.WriteByte(',') + } + b.Write(encodedKey) + b.WriteByte(':') + b.Write(encodedValue) + seen[field] = true } - if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(b)); err != nil { + b.WriteByte('}') + if _, err := fmt.Fprintln(cmd.OutOrStdout(), b.String()); err != nil { return fmt.Errorf("writing JSONL line: %w", err) } }