Skip to content

Commit

Permalink
Add NodeList and LocatedNodeList
Browse files Browse the repository at this point in the history
Add `LocatedNodeList` as the return value of `SelectLocated`. It aliases
`[]*spec.LocatedNode` and offers a number of methods for working with
located nodes, including sorting, deduplication, and iterators over its
nodes and paths.

To complement `SelectLocated`'s new return value, change `Select` to
return `NodeList` instead of `[]any`. It simply aliases `[]any`, but can
offer additional methods. For now, there is just `All`, which returns an
iterator over all its nodes.

These new collection objects reflect how RFC 9535 talks about "node
lists", and `SelectLocated`, in particular, demonstrates how to use
located nodes for deduplication, as described by the spec.

Sorting of located nodes requires comparison of `NormalizedPath`s, so
add the `Compare` method for that purpose. It sorts indexes before
names, and otherwise compares indexes and names as one might expect.

The use of iterator return values from the nod lists depends on Go 1.23,
so require that version.

Add more examples for these new collections, and tweak the existing
examples to use their `All` iterators.
  • Loading branch information
theory committed Dec 28, 2024
1 parent d812b0c commit 21836ff
Show file tree
Hide file tree
Showing 8 changed files with 688 additions and 42 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
strategy:
matrix:
os: [[🐧, Ubuntu], [🍎, macOS], [🪟, Windows]]
go: ["1.23", "1.22"]
go: ["1.23"]
name: ${{ matrix.os[0] }} Test Go ${{ matrix.go }} on ${{ matrix.os[1] }}
runs-on: ${{ matrix.os[1] }}-latest
steps:
Expand Down
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,33 @@ All notable changes to this project will be documented in this file. It uses the
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
"Semantic Versioning 2.0.0"

## [v0.3.0 ] — Unreleased
## [v0.3.0] — Unreleased

### ⚡ Improvements

* Added `SelectLocated`. It works just like `Select`, but returns a slice of
* Added `SelectLocated`. It works just like `Select`, but returns
`LocatedNode`s that pair the selected nodes with [RFC 9535-defined]
`NormalizedPath`s that uniquely identify their locations within the JSON
query argument.
* Added `LocatedNodeList`, the return value from `SelectLocated`. It
contains methods for working with the selected nodes, including iterators
for its nodes & `NormalizedPath`s, deduplication, sorting, and cloning.
* Added `Compare` to `NormalizedPath`, which enables the sorting of
`LocatedNodeList`s.

### 📔 Notes

* Requires Go 1.23 to take advantage of its iterator support.
* Changed the return value of `Select` from `[]any` to `NodeList`, which is
an alias for `[]any`. Done to pair with `LocatedNodeList`, the return
value of `SelectLocated`. Features an `All` method, which returns an
iterator over all the nodes in the list. It may gain additional methods in
the future.

### 📚 Documentation

* Added `Select` and `SelectLocated` examples to the Go docs.
* Added `Select`, `SelectLocated`, `NodeList`, and `LocatedNodeList`
examples to the Go docs.

[v0.3.0]: https://github.com/theory/jsonpath/compare/v0.2.1...v0.3.0
[RFC 9535-defined]: https://www.rfc-editor.org/rfc/rfc9535#section-2.7
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/theory/jsonpath

go 1.22
go 1.23

require github.com/stretchr/testify v1.10.0

Expand Down
99 changes: 97 additions & 2 deletions path.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
package jsonpath

import (
"iter"
"slices"

"github.com/theory/jsonpath/parser"
"github.com/theory/jsonpath/registry"
"github.com/theory/jsonpath/spec"
Expand Down Expand Up @@ -45,7 +48,7 @@ func (p *Path) Query() *spec.PathQuery {
}

// Select returns the values that JSONPath query p selects from input.
func (p *Path) Select(input any) []any {
func (p *Path) Select(input any) NodeList {
return p.q.Select(nil, input)
}

Expand All @@ -55,7 +58,7 @@ func (p *Path) Select(input any) []any {
// normalized path for each value, you probably want to use [Path.Select].
//
// [normalized paths]: https://www.rfc-editor.org/rfc/rfc9535#section-2.7
func (p *Path) SelectLocated(input any) []*spec.LocatedNode {
func (p *Path) SelectLocated(input any) LocatedNodeList {
return p.q.SelectLocated(nil, input, spec.NormalizedPath{})
}

Expand Down Expand Up @@ -108,3 +111,95 @@ func (c *Parser) MustParse(path string) *Path {
}
return New(q)
}

// NodeList is a list of nodes selected by a JSONPath query. Each node
// represents a single JSON value selected from the JSON query argument.
// Returned by [Path.Select].
type NodeList []any

// All returns an iterator over all the nodes in list.
//
// Range over list itself to get indexes and node values.
func (list NodeList) All() iter.Seq[any] {
return func(yield func(any) bool) {
for _, v := range list {
if !yield(v) {
return
}
}
}
}

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

// All returns an iterator over all the nodes in list.
//
// Range over list itself to get indexes and node values.
func (list LocatedNodeList) All() iter.Seq[*spec.LocatedNode] {
return func(yield func(*spec.LocatedNode) bool) {
for _, v := range list {
if !yield(v) {
return
}
}
}
}

// Nodes returns an iterator over all the nodes in list. This is effectively
// the same data a returned by [Path.Select].
func (list LocatedNodeList) Nodes() iter.Seq[any] {
return func(yield func(any) bool) {
for _, v := range list {
if !yield(v.Node) {
return
}
}
}
}

// Paths returns an iterator over all the normalized paths in list.
func (list LocatedNodeList) Paths() iter.Seq[spec.NormalizedPath] {
return func(yield func(spec.NormalizedPath) bool) {
for _, v := range list {
if !yield(v.Path) {
return
}
}
}
}

// Deduplicate deduplicates the nodes in list based on their normalized paths,
// modifying the contents of list. It returns the modified list, which may
// have a smaller length, and zeroes the elements between the new length and
// the original length.
func (list LocatedNodeList) Deduplicate() LocatedNodeList {
if len(list) <= 1 {
return list
}

seen := map[string]struct{}{}
uniq := list[:0]
for _, n := range list {
p := n.Path.String()
if _, x := seen[p]; !x {
seen[p] = struct{}{}
uniq = append(uniq, n)
}
}
clear(list[len(uniq):]) // zero/nil out the obsolete elements, for GC
return slices.Clip(uniq)
}

// Sort sorts list by the normalized path of each node.
func (list LocatedNodeList) Sort() {
slices.SortFunc(list, func(a, b *spec.LocatedNode) int {
return a.Path.Compare(b.Path)
})
}

// Clone returns a shallow copy of list.
func (list LocatedNodeList) Clone() LocatedNodeList {
return append(make(LocatedNodeList, 0, len(list)), list...)
}
130 changes: 118 additions & 12 deletions path_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ func Example() {

// Select values from unmarshaled JSON input.
store := bookstore()
result := p.Select(store)
nodes := p.Select(store)

// Show the result.
items, err := json.Marshal(result)
// Show the selected values.
items, err := json.Marshal(nodes)
if err != nil {
log.Fatal(err)
}
Expand All @@ -44,11 +44,11 @@ func ExamplePath_Select() {

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$.apps.*")
result := p.Select(menu)
nodes := p.Select(menu)

// Show the result.
for _, val := range result {
fmt.Printf("%v\n", val)
// Show the selected values.
for node := range nodes.All() {
fmt.Printf("%v\n", node)
}
// Unordered output:
// 19.99
Expand All @@ -66,10 +66,10 @@ func ExamplePath_SelectLocated() {

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$.apps.*")
result := p.SelectLocated(menu)
nodes := p.SelectLocated(menu)

// Show the result.
for _, node := range result {
// Show the selected nodes.
for node := range nodes.All() {
fmt.Printf("%v: %v\n", node.Path, node.Node)
}

Expand All @@ -78,6 +78,112 @@ func ExamplePath_SelectLocated() {
// $['apps']['salsa']: 5.99
}

func ExampleLocatedNodeList() {
// Load some JSON.
menu := map[string]any{
"apps": map[string]any{
"guacamole": 19.99,
"salsa": 5.99,
},
}

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse(`$.apps["salsa", "guacamole"]`)
nodes := p.SelectLocated(menu)

// Show the nodes.
fmt.Println("Nodes:")
for n := range nodes.Nodes() {
fmt.Printf(" %v\n", n)
}

// Show the paths.
fmt.Println("\nPaths:")
for p := range nodes.Paths() {
fmt.Printf(" %v\n", p)
}

// Output:
// Nodes:
// 5.99
// 19.99
//
// Paths:
// $['apps']['salsa']
// $['apps']['guacamole']
}

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

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$.colors[0, 1, 1, 0]")
nodes := p.SelectLocated(pallet)
fmt.Printf("Items: %v\n", len(nodes))

// Deduplicate
nodes = nodes.Deduplicate()
fmt.Printf("Items: %v\n", len(nodes))

// Output:
// Items: 4
// Items: 2
}

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

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$.colors[2, 0, 1]")
nodes := p.SelectLocated(pallet)

// Show selected.
fmt.Println("Selected:")
for _, node := range nodes {
fmt.Printf(" %v: %v\n", node.Path, node.Node)
}

// Sort by normalized paths and show selected again.
nodes.Sort()
fmt.Println("\nSorted:")
for _, node := range nodes {
fmt.Printf(" %v: %v\n", node.Path, node.Node)
}

// Output:
// Selected:
// $['colors'][2]: green
// $['colors'][0]: red
// $['colors'][1]: blue
//
// Sorted:
// $['colors'][0]: red
// $['colors'][1]: blue
// $['colors'][2]: green
}

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

// Parse a JSONPath and select from the input.
p := jsonpath.MustParse("$[2, 0, 1, 0, 1]")
nodes := p.SelectLocated(items)

// Clone the selected nodes then deduplicate.
orig := nodes.Clone()
nodes = nodes.Deduplicate()

// Cloned nodes have the original count.
fmt.Printf("Unique Count: %v\nOriginal Count: %v\n", len(nodes), len(orig))

// Output:
// Unique Count: 3
// Original Count: 5
}

// Use the Parser to parse a collection of paths.
func ExampleParser() {
// Create a new parser using the default function registry.
Expand Down Expand Up @@ -156,8 +262,8 @@ func ExampleParser_functionExtension() {
[]any{6, 7, 8, 9},
[]any{4, 8, 12},
}
result := path.Select(input)
fmt.Printf("%v\n", result)
nodes := path.Select(input)
fmt.Printf("%v\n", nodes)
// Output: [[6 7 8 9]]
}

Expand Down
Loading

0 comments on commit 21836ff

Please sign in to comment.