Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 58 additions & 18 deletions cmd/rootline/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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é <tag>"
'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)
}
}

Expand Down
32 changes: 26 additions & 6 deletions cmd/rootline/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading