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
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,30 @@ All notable changes to this project will be documented in this file. It uses the

## [v0.11.1] — Unreleased

### ⚡ Improvements

* Added support for selecting values from any type of slice or string-keyed
map, not just `[]any` or `map[string]any`. Internally it still prefers
`[]any` and `map[string]any`, to optimize for values decoded by
encoding/json, but it now falls back on reflection to detect any other
kind of slice or string-keyed map. Thanks to @ndsboy for the prompt (#26).
* Updated result set creation to allocate more slots for results when the
number of results are unknown, based on the number of selectors or items
to select from, to improve memory efficiency. Encouraged by the recent
[Go blog post] describing the advantages of this pattern.

### ⬆️ Dependency Updates

* Upgraded to `golangci-lint` v2.11.1 and made suggested slice allocation
optimization
* Upgraded to `golangci-lint` v2.11.4 and made suggested slice allocation
optimizations.

### 📚 Documentation

* Fixed some broken Go Doc links.

[v0.11.1]: https://github.com/theory/jsonpath/compare/v0.11.0...v0.11.1
[Go blog post]: https://go.dev/blog/allocation-optimizations
"The Go Blog: Allocating on the Stack"

## [v0.11.0] — 2026-03-02

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ brew-lint-depends:

.PHONY: debian-lint-depends # Install linting tools on Debian
debian-lint-depends:
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b /usr/bin v2.11.1
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b /usr/bin v2.11.4

.PHONY: install-generators # Install Go code generators
install-generators:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ RFC 9535 JSONPath in Go
[![⚖️ MIT]][mit] [![📚 Docs]][docs] [![🗃️ Report Card]][card] [![🛠️ Build Status]][ci] [![📊 Coverage]][cov]

The jsonpath package provides [RFC 9535 JSONPath] functionality in Go.
It operates on any type of slice or string-keyed map.

## Learn More

Expand Down
16 changes: 9 additions & 7 deletions path.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Package jsonpath implements RFC 9535 JSONPath query expressions.
// It operates on any type of slice or string-keyed map.
package jsonpath

import (
Expand Down Expand Up @@ -160,7 +161,7 @@ func (list NodeList) All() iter.Seq[any] {
}

// LocatedNodeList is a list of nodes selected by a JSONPath query, along with
// their [NormalizedPath] locations. Returned by [Path.SelectLocated].
// their [spec.NormalizedPath] locations. Returned by [Path.SelectLocated].
type LocatedNodeList []*spec.LocatedNode

// All returns an iterator over all the nodes in list.
Expand Down Expand Up @@ -188,7 +189,8 @@ func (list LocatedNodeList) Nodes() iter.Seq[any] {
}
}

// Paths returns an iterator over all the [NormalizedPath] values in list.
// Paths returns an iterator over all the [spec.NormalizedPath] values in
// list.
func (list LocatedNodeList) Paths() iter.Seq[spec.NormalizedPath] {
return func(yield func(spec.NormalizedPath) bool) {
for _, v := range list {
Expand All @@ -199,10 +201,10 @@ func (list LocatedNodeList) Paths() iter.Seq[spec.NormalizedPath] {
}
}

// Deduplicate deduplicates the nodes in list based on their [NormalizedPath]
// values, modifying the contents of list. It returns the modified list, which
// may have a shorter length, and zeroes the elements between the new length
// and the original length.
// Deduplicate deduplicates the nodes in list based on their
// [spec.NormalizedPath] values, modifying the contents of list. It returns
// the modified list, which may have a shorter length, and zeroes the elements
// between the new length and the original length.
func (list LocatedNodeList) Deduplicate() LocatedNodeList {
if len(list) <= 1 {
return list
Expand All @@ -221,7 +223,7 @@ func (list LocatedNodeList) Deduplicate() LocatedNodeList {
return slices.Clip(uniq)
}

// Sort sorts list by the [NormalizedPath] of each node.
// Sort sorts list by the [spec.NormalizedPath] of each node.
func (list LocatedNodeList) Sort() {
slices.SortFunc(list, func(a, b *spec.LocatedNode) int {
return a.Path.Compare(b.Path)
Expand Down
12 changes: 6 additions & 6 deletions path_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func ExampleLocatedNodeList() {

func ExampleLocatedNodeList_Deduplicate() {
// Load some JSON.
pallet := map[string]any{"colors": []any{"red", "blue"}}
pallet := map[string]any{"colors": []string{"red", "blue"}}

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$.colors[0, 1, 1, 0]")
Expand All @@ -133,7 +133,7 @@ func ExampleLocatedNodeList_Deduplicate() {

func ExampleLocatedNodeList_Sort() {
// Load some JSON.
pallet := map[string]any{"colors": []any{"red", "blue", "green"}}
pallet := map[string]any{"colors": []string{"red", "blue", "green"}}

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$.colors[2, 0, 1]")
Expand Down Expand Up @@ -166,7 +166,7 @@ func ExampleLocatedNodeList_Sort() {

func ExampleLocatedNodeList_Clone() {
// Load some JSON.
items := []any{1, 2, 3, 4, 5}
items := []int{1, 2, 3, 4, 5}

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$[2, 0, 1, 0, 1]")
Expand Down Expand Up @@ -259,9 +259,9 @@ func ExampleWithRegistry() {

// Do any of these arrays start with 6?
input := []any{
[]any{1, 2, 3, 4, 5},
[]any{6, 7, 8, 9},
[]any{4, 8, 12},
[]int{1, 2, 3, 4, 5},
[]int{6, 7, 8, 9},
[]int{4, 8, 12},
}
nodes := path.Select(input)
fmt.Printf("%v\n", nodes)
Expand Down
18 changes: 15 additions & 3 deletions registry/funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package registry
import (
"errors"
"fmt"
"reflect"
"regexp"
"regexp/syntax"
"unicode/utf8"
Expand Down Expand Up @@ -32,8 +33,8 @@ func checkLengthArgs(args []spec.FuncExprArg) error {
// - if jv[0] is nil, the result is nil
// - If jv[0] is a string, the result is the number of Unicode scalar values
// in the string.
// - If jv[0] is a []any, the result is the number of elements in the slice.
// - If jv[0] is an map[string]any, the result is the number of members in
// - If jv[0] is a slice, the result is the number of elements in the slice.
// - If jv[0] is a string-keyed map, the result is the number of members in
// the map.
// - For any other value, the result is nil.
func lengthFunc(jv []spec.PathValue) spec.PathValue {
Expand All @@ -50,7 +51,18 @@ func lengthFunc(jv []spec.PathValue) spec.PathValue {
case map[string]any:
return spec.Value(len(v))
default:
return nil
val := reflect.ValueOf(v)
switch val.Kind() {
case reflect.Slice:
return spec.Value(val.Len())
case reflect.Map:
if val.Type().Key().Kind() == reflect.String {
return spec.Value(val.Len())
}
return nil
default:
return nil
}
}
}

Expand Down
25 changes: 25 additions & 0 deletions registry/funcs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ func TestLengthFunc(t *testing.T) {
vals: []spec.PathValue{spec.LogicalFalse},
err: "cannot convert LogicalType to ValueType",
},
{
test: "int_array",
vals: []spec.PathValue{spec.Value([]int{1, 2, 3, 4, 5})},
exp: 5,
},
{
test: "string_array",
vals: []spec.PathValue{spec.Value([]string{"x", "y", "z"})},
exp: 3,
},
{
test: "int_object",
vals: []spec.PathValue{spec.Value(map[string]int{"x": 1, "y": 0, "z": 2})},
exp: 3,
},
{
test: "string_object",
vals: []spec.PathValue{spec.Value(map[string]string{"x": "x", "y": "y"})},
exp: 2,
},
{
test: "int_keyed_object",
vals: []spec.PathValue{spec.Value(map[int]string{1: "x", 2: "c"})},
exp: -1,
},
} {
t.Run(tc.test, func(t *testing.T) {
t.Parallel()
Expand Down
2 changes: 1 addition & 1 deletion registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func New() *Registry {
}
}

// ErrRegister errors are returned by [Register].
// ErrRegister errors are returned by [Registry.Register].
var ErrRegister = errors.New("register")

// Register registers a function extension. The parameters are:
Expand Down
20 changes: 10 additions & 10 deletions spec/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ type PathValue interface {
// NodesType defines a node list (a list of JSON values) for a function
// expression parameters or results, as defined by [RFC 9535 Section 2.4.1].
// It can also be used in filter expressions. The underlying types should be
// string, integer, float, [json.Number], nil, true, false, []any, or
// map[string]any. Interfaces implemented:
// string, integer, float, [json.Number], nil, true, false, slice, or
// string-keyed map. Interfaces implemented:
//
// - [PathValue]
// - [fmt.Stringer]
Expand All @@ -57,7 +57,7 @@ type NodesType []any

// Nodes creates a NodesType that contains val, all of which should be the Go
// equivalent of the JSON data types: string, integer, float, [json.Number],
// nil, true, false, []any, or map[string]any.
// nil, true, false, slice, or string-keyed map.
func Nodes(val ...any) NodesType {
return NodesType(val)
}
Expand All @@ -83,7 +83,7 @@ func NodesFrom(value PathValue) NodesType {
case *ValueType:
return NodesType([]any{v.any})
case nil:
return NodesType([]any{})
return NodesType(make([]any, 0))
case LogicalType:
panic("cannot convert LogicalType to NodesType")
default:
Expand Down Expand Up @@ -137,8 +137,8 @@ func (LogicalType) FuncType() FuncType { return FuncLogical }
// LogicalFrom converts value to a [LogicalType] and panics if it cannot. Use
// in [github.com/theory/jsonpath/registry.Registry.Register] [Evaluator]
// functions. Avoid the panic by returning an error from the accompanying
// [Validator] function when [FuncExprArg.ConvertsToLogical] returns false for
// the [FuncExprArg] that returns value.
// [Validator] function when [FuncExprArg.ConvertsTo] returns false for the
// [FuncExprArg] that returns value.
//
// Converts each implementation of [PathValue] as follows:
// - [LogicalType]: returns value
Expand Down Expand Up @@ -170,7 +170,7 @@ func (lt LogicalType) writeTo(buf *strings.Builder) {
// ValueType encapsulates a JSON value for a function expression parameter or
// result, as defined by [RFC 9535 Section 2.4.1]. It can also be used as in
// filter expression. The underlying value should be a string, integer,
// [json.Number], float, nil, true, false, []any, or map[string]any. A nil
// [json.Number], float, nil, true, false, slice, or string-keyed map. A nil
// ValueType pointer indicates no value. Interfaces implemented:
//
// - [PathValue]
Expand All @@ -184,7 +184,7 @@ type ValueType struct {

// Value returns a new [ValueType] for val, which must be the Go equivalent of
// a JSON data type: string, integer, float, [json.Number], nil, true, false,
// []any, or map[string]any.
// slice, or string-keyed map.
func Value(val any) *ValueType {
return &ValueType{val}
}
Expand All @@ -201,8 +201,8 @@ func (*ValueType) FuncType() FuncType { return FuncValue }
// ValueFrom converts value to a [ValueType] and panics if it cannot. Use in
// [github.com/theory/jsonpath/registry.Registry.Register] [Evaluator]
// functions. Avoid the panic by returning an error from the accompanying
// [Validator] function when [FuncExprArg.ConvertsToValue] returns false for
// the [FuncExprArg] that returns value.
// [Validator] function when [FuncExprArg.ConvertsTo] returns false for the
// [FuncExprArg] that returns value.
//
// Converts each implementation of [PathValue] as follows:
// - [ValueType]: returns value
Expand Down
64 changes: 64 additions & 0 deletions spec/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,34 @@ func TestQueryObject(t *testing.T) {
exp: []any{},
loc: []*LocatedNode{},
},
{
test: "string_map",
resType: FuncValue,
input: map[string]string{"x": "hi", "y": "y"},
segs: []*Segment{Child(Name("x"))},
exp: []any{"hi"},
loc: []*LocatedNode{
{Path: Normalized(Name("x")), Node: "hi"},
},
},
{
test: "int_map",
resType: FuncValue,
input: map[string]int{"x": 42, "y": 99},
segs: []*Segment{Child(Name("x"))},
exp: []any{42},
loc: []*LocatedNode{
{Path: Normalized(Name("x")), Node: 42},
},
},
{
test: "int_keyed_map",
resType: FuncValue,
input: map[int]string{42: "hi", 99: "y"},
segs: []*Segment{Child(Name("42"))},
exp: []any{},
loc: []*LocatedNode{},
},
} {
t.Run(tc.test, func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -631,6 +659,22 @@ func TestQueryArray(t *testing.T) {
{Path: Normalized(Index(0), Name("x"), Index(1)), Node: 2},
},
},
{
test: "string_slice_index",
resType: FuncValue,
segs: []*Segment{Child(Index(0))},
input: []string{"x", "y"},
exp: []any{"x"},
loc: []*LocatedNode{{Path: Normalized(Index(0)), Node: "x"}},
},
{
test: "int_slice_index",
resType: FuncValue,
segs: []*Segment{Child(Index(1))},
input: []int{0, 42},
exp: []any{42},
loc: []*LocatedNode{{Path: Normalized(Index(1)), Node: 42}},
},
} {
t.Run(tc.test, func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -1101,6 +1145,26 @@ func TestQuerySlice(t *testing.T) {
{Path: Normalized(Index(0), Index(1)), Node: 42},
},
},
{
test: "string_slice",
segs: []*Segment{Child(Slice())},
input: []string{"x", "y"},
exp: []any{"x", "y"},
loc: []*LocatedNode{
{Path: Normalized(Index(0)), Node: "x"},
{Path: Normalized(Index(1)), Node: "y"},
},
},
{
test: "int_slice",
segs: []*Segment{Child(Slice())},
input: []int{42, 99},
exp: []any{42, 99},
loc: []*LocatedNode{
{Path: Normalized(Index(0)), Node: 42},
{Path: Normalized(Index(1)), Node: 99},
},
},
} {
t.Run(tc.test, func(t *testing.T) {
t.Parallel()
Expand Down
Loading
Loading