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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,11 +401,20 @@ The query language supports:
- Array indexing & slicing `foo.items[1:2].name` (both ends inclusive: `[1:2]` returns items at indexes 1 and 2)
- Including negative indexes `foo.items[-1].name`
- Array filtering via [mexpr](https://github.com/danielgtaylor/mexpr) `foo.items[name.lower startsWith d]`
- Array construction `[foo.id, foo.name]`
- Object property selection `foo.{created, names: items.name}`
- Recursive search `foo..name`
- Stopping processing with a pipe `|`
- Flattening nested arrays `[]`

Square brackets are context-sensitive in queries. At the beginning of a query or
object field value, a `[` expression constructs an array only when it contains a
top-level comma, so `[id, name]` returns a two-item array with those query
results. Single-element and empty array construction are not supported. A `[`
after an existing path indexes, slices, or filters that value, as in `items[0]`,
`items[:2]`, or `items[status == active]`. An empty bracket expression, `[]`,
keeps its existing meaning and flattens nested arrays one level.

The query syntax is recursive and looks like this:

<!--
Expand Down Expand Up @@ -489,6 +498,22 @@ $ j <data.json -q 'users[friends contains b].{id, age}'
"id": 2
}
]

# Construct a shell-friendly array of selected values
$ j <data.json -q '[users[0].id, users[0].friends[0]]'
[
1,
"a"
]

# Array construction also works inside object selection
$ j <data.json -q '{vars: [users[0].id, users[0].friends[0]]}'
{
"vars": [
1,
"a"
]
}
```

> **Note on slice ranges:** Shorthand uses inclusive ranges on both ends, so `[1:2]` returns items at indexes 1 **and** 2. This is an intentional design choice: shorthand is meant to be intuitive for query use, where "items 1 to 2" naturally means both endpoints. Dijkstra's classic argument for exclusive end ranges (empty range, length arithmetic, concatenation) applies to programming with ranges, not to querying. Users familiar with jq or JMESPath (which use exclusive end) need only remember this one difference.
Expand Down
176 changes: 173 additions & 3 deletions get.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ type compiledFilterOp struct {
ast *mexpr.Node
}

type compiledArrayLiteralOp struct {
elements []*compiledQuery
}

type compiledField struct {
key string
query *compiledQuery
Expand Down Expand Up @@ -186,7 +190,7 @@ func compilePath(path string) (*compiledQuery, Error) {
query := &compiledQuery{expression: path}

for d.pos < uint(len(d.expression)) {
segment, err := d.compileSegment()
segment, err := d.compileSegment(len(query.segments) == 0)
if err != nil {
return nil, err
}
Expand All @@ -202,7 +206,7 @@ func compilePath(path string) (*compiledQuery, Error) {
// compileSegment compiles one pipe-delimited segment of a query into a flat
// sequence of executable ops. Filters and field selections recursively compile
// nested query fragments.
func (d *Document) compileSegment() (compiledSegment, Error) {
func (d *Document) compileSegment(allowArrayLiteral bool) (compiledSegment, Error) {
ops := make([]compiledOp, 0, 8)

outer:
Expand All @@ -217,6 +221,20 @@ outer:
ops = append(ops, compiledFlattenOp{})
continue
}
if allowArrayLiteral && len(ops) == 0 {
isArrayLiteral, err := d.bracketHasTopLevelComma()
if err != nil {
return compiledSegment{}, err
}
if isArrayLiteral {
elements, err := d.compileArrayLiteral()
if err != nil {
return compiledSegment{}, err
}
ops = append(ops, compiledArrayLiteralOp{elements: elements})
continue
}
}

isSlice, startIndex, stopIndex, expr, err := d.parsePathIndex()
if err != nil {
Expand Down Expand Up @@ -421,6 +439,21 @@ func (q *compiledQuery) execSegment(segment *compiledSegment, input any, start i
}

return compiledExecResult{value: out, found: true, consumed: true}, nil
case compiledArrayLiteralOp:
if options.DebugLogger != nil {
options.DebugLogger("Getting array literal")
}
out := make([]any, 0, len(op.elements))
for _, element := range op.elements {
value, _, err := element.Exec(result, options)
if err != nil {
return compiledExecResult{}, err
}
out = append(out, value)
}
result = out
found = true
consumed = true
case compiledFieldsOp:
if !isMap(result) {
return compiledExecResult{}, NewError(&q.expression, op.offset, 1, "field selection requires a map, but found %v", result)
Expand Down Expand Up @@ -697,6 +730,143 @@ func (d *Document) parsePathIndex() (bool, int, int, string, Error) {
return false, 0, 0, value, nil
}

func (d *Document) rebaseError(base uint, err Error) Error {
if err == nil {
return nil
}
return NewError(&d.expression, base+err.Offset(), err.Length(), err.Error())
}

func (d *Document) skipQuotedRaw() Error {
start := d.pos - d.lastWidth
for {
r := d.next()
if r == '\\' {
if d.peek() != -1 {
d.next()
}
continue
}
if r == -1 {
return NewError(&d.expression, start, d.pos-start, "Expected quote but found EOF")
}
if r == '"' {
return nil
}
}
}

func (d *Document) parseArrayLiteralElement() (string, rune, uint, Error) {
start := d.pos
open := 0

for {
r := d.next()
switch r {
case -1:
return "", -1, start, NewError(&d.expression, start, d.pos-start, "expected ']' after array literal")
case '\\':
if d.peek() != -1 {
d.next()
}
case '"':
if err := d.skipQuotedRaw(); err != nil {
return "", -1, start, err
}
case '[', '{', '(':
open++
case ']':
if open == 0 {
end := d.pos - d.lastWidth
return strings.TrimSpace(d.expression[start:end]), r, start, nil
}
open--
case '}', ')':
if open == 0 {
return "", -1, d.pos - d.lastWidth, NewError(&d.expression, d.pos-d.lastWidth, 1, "expected ']' after array literal")
}
open--
case ',':
if open == 0 {
end := d.pos - d.lastWidth
return strings.TrimSpace(d.expression[start:end]), r, start, nil
}
}
}
}

func (d *Document) bracketHasTopLevelComma() (bool, Error) {
savedPos := d.pos
savedLastWidth := d.lastWidth
defer func() {
d.pos = savedPos
d.lastWidth = savedLastWidth
}()

open := 0
for {
r := d.next()
switch r {
case -1:
return false, nil
case '\\':
if d.peek() != -1 {
d.next()
}
case '"':
if err := d.skipQuotedRaw(); err != nil {
return false, err
}
case '[', '{', '(':
open++
case ']':
if open == 0 {
return false, nil
}
open--
case '}', ')':
if open > 0 {
open--
}
case ',':
if open == 0 {
return true, nil
}
}
}
}

func (d *Document) compileArrayLiteral() ([]*compiledQuery, Error) {
d.skipWhitespace()
elements := []*compiledQuery{}
for {
d.skipWhitespace()
if d.peek() == ']' {
return nil, d.error(1, "expected array literal element")
}

expr, delimiter, exprStart, err := d.parseArrayLiteralElement()
if err != nil {
return nil, err
}
if expr == "" {
return nil, NewError(&d.expression, exprStart, 1, "expected array literal element")
}

query, err := getCompiledPath(expr)
if err != nil {
return nil, d.rebaseError(exprStart, err)
}
elements = append(elements, query)

if delimiter == ']' {
break
}
}

return elements, nil
}

func stringRuneSlice(s string, startIndex int, stopIndex int) string {
byteStart := 0
byteEnd := len(s)
Expand Down Expand Up @@ -769,7 +939,7 @@ func (d *Document) parseFieldSpecs() ([]fieldSpec, uint, Error) {
}
if r == '[' {
d.buf.WriteRune(r)
if _, _, err := d.parseUntilNoReset(1, '|'); err != nil {
if _, _, err := d.parseUntilNoReset(1); err != nil {
return nil, 0, err
}
continue
Expand Down
Loading
Loading