Skip to content

Commit f9ab9d4

Browse files
authored
Factorize dig logic ; add a few options ; more tests ; fix README (#137)
* Add a few tests to improve coverage and highlight pending behaviors. * Factorize digging logic to a Dig helper module. * Add support for a :use_symbols option. * Add support for objects responding to dig. * Fix digging behavior in presence of explicit null/nil. * Fix & test README claims, document available options. Also includes a library fix when default_path_leaf_to_null is used. * Introduce :allow_send and document it (defaults to true).
1 parent a5983ba commit f9ab9d4

File tree

7 files changed

+449
-58
lines changed

7 files changed

+449
-58
lines changed

README.md

Lines changed: 97 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ This is an implementation of http://goessner.net/articles/JsonPath/.
44

55
## What is JsonPath?
66

7-
JsonPath is a way of addressing elements within a JSON object. Similar to xpath of yore, JsonPath lets you
8-
traverse a json object and manipulate or access it.
7+
JsonPath is a way of addressing elements within a JSON object. Similar to xpath
8+
of yore, JsonPath lets you traverse a json object and manipulate or access it.
99

1010
## Usage
1111

@@ -15,8 +15,8 @@ There is stand-alone usage through the binary `jsonpath`
1515

1616
jsonpath [expression] (file|string)
1717

18-
If you omit the second argument, it will read stdin, assuming one valid JSON object
19-
per line. Expression must be a valid jsonpath expression.
18+
If you omit the second argument, it will read stdin, assuming one valid JSON
19+
object per line. Expression must be a valid jsonpath expression.
2020

2121
### Library
2222

@@ -40,8 +40,8 @@ json = <<-HERE_DOC
4040
HERE_DOC
4141
```
4242

43-
Now that we have a JSON object, let's get all the prices present in the object. We create an object for the path
44-
in the following way.
43+
Now that we have a JSON object, let's get all the prices present in the object.
44+
We create an object for the path in the following way.
4545

4646
```ruby
4747
path = JsonPath.new('$..price')
@@ -54,14 +54,15 @@ path.on(json)
5454
# => [19.95, 8.95, 12.99, 8.99, 22.99]
5555
```
5656

57-
Or on some other object ...
57+
Or reuse it later on some other object (thread safe) ...
5858

5959
```ruby
6060
path.on('{"books":[{"title":"A Tale of Two Somethings","price":18.88}]}')
6161
# => [18.88]
6262
```
6363

64-
You can also just combine this into one mega-call with the convenient `JsonPath.on` method.
64+
You can also just combine this into one mega-call with the convenient
65+
`JsonPath.on` method.
6566

6667
```ruby
6768
JsonPath.on(json, '$..author')
@@ -73,59 +74,114 @@ Of course the full JsonPath syntax is supported, such as array slices
7374
```ruby
7475
JsonPath.new('$..book[::2]').on(json)
7576
# => [
76-
# {"price"=>8.95, "category"=>"reference", "author"=>"Nigel Rees", "title"=>"Sayings of the Century"},
77-
# {"price"=>8.99, "category"=>"fiction", "author"=>"Herman Melville", "title"=>"Moby Dick", "isbn"=>"0-553-21311-3"}
77+
# {"price" => 8.95, "category" => "reference", "title" => "Sayings of the Century", "author" => "Nigel Rees"},
78+
# {"price" => 8.99, "category" => "fiction", "isbn" => "0-553-21311-3", "title" => "Moby Dick", "author" => "Herman Melville","color" => "blue"},
7879
# ]
7980
```
8081

81-
...and evals.
82+
...and evals, including those with conditional operators
8283

8384
```ruby
84-
JsonPath.new('$..price[?(@ < 10)]').on(json)
85+
JsonPath.new("$..price[?(@ < 10)]").on(json)
8586
# => [8.95, 8.99]
87+
88+
JsonPath.new("$..book[?(@['price'] == 8.95 || @['price'] == 8.99)].title").on(json)
89+
# => ["Sayings of the Century", "Moby Dick"]
90+
91+
JsonPath.new("$..book[?(@['price'] == 8.95 && @['price'] == 8.99)].title").on(json)
92+
# => []
8693
```
8794

88-
There is a convenience method, `#first` that gives you the first element for a JSON object and path.
95+
There is a convenience method, `#first` that gives you the first element for a
96+
JSON object and path.
8997

9098
```ruby
91-
JsonPath.new('$..color').first(object)
99+
JsonPath.new('$..color').first(json)
92100
# => "red"
93101
```
94102

95103
As well, we can directly create an `Enumerable` at any time using `#[]`.
96104

97105
```ruby
98-
enum = JsonPath.new('$..color')[object]
106+
enum = JsonPath.new('$..color')[json]
99107
# => #<JsonPath::Enumerable:...>
100108
enum.first
101109
# => "red"
102110
enum.any?{ |c| c == 'red' }
103111
# => true
104112
```
105113

106-
### More examples
114+
For more usage examples and variations on paths, please visit the tests. There
115+
are some more complex ones as well.
107116

108-
For more usage examples and variations on paths, please visit the tests. There are some more complex ones as well.
117+
### Querying ruby data structures
109118

110-
### Conditional Operators Are Also Supported
119+
If you have ruby hashes with symbolized keys as input, you
120+
can use `:use_symbols` to make JsonPath work fine on them too:
111121

112122
```ruby
113-
def test_or_operator
114-
assert_equal [@object['store']['book'][1], @object['store']['book'][3]], JsonPath.new("$..book[?(@['price'] == 13 || @['price'] == 23)]").on(@object)
115-
end
123+
book = { title: "Sayings of the Century" }
116124

117-
def test_and_operator
118-
assert_equal [], JsonPath.new("$..book[?(@['price'] == 13 && @['price'] == 23)]").on(@object)
119-
end
125+
JsonPath.new('$.title').on(book)
126+
# => []
120127

121-
def test_and_operator_with_more_results
122-
assert_equal [@object['store']['book'][1]], JsonPath.new("$..book[?(@['price'] < 23 && @['price'] > 9)]").on(@object)
123-
end
128+
JsonPath.new('$.title', use_symbols: true).on(book)
129+
# => ["Sayings of the Century"]
130+
```
131+
132+
JsonPath also recognizes objects responding to `dig` (introduced
133+
in ruby 2.3), and therefore works out of the box with Struct,
134+
OpenStruct, and other Hash-like structures:
135+
136+
```ruby
137+
book_class = Struct.new(:title)
138+
book = book_class.new("Sayings of the Century")
139+
140+
JsonPath.new('$.title').on(book)
141+
# => ["Sayings of the Century"]
142+
```
143+
144+
JsonPath is able to query pure ruby objects and uses `__send__`
145+
on them. The option is enabled by default in JsonPath 1.x, but
146+
we encourage to enable it explicitly:
147+
148+
```ruby
149+
book_class = Class.new{ attr_accessor :title }
150+
book = book_class.new
151+
book.title = "Sayings of the Century"
152+
153+
JsonPath.new('$.title', allow_send: true).on(book)
154+
# => ["Sayings of the Century"]
155+
```
156+
157+
### Other available options
158+
159+
By default, JsonPath does not return null values on unexisting paths.
160+
This can be changed using the `:default_path_leaf_to_null` option
161+
162+
```ruby
163+
JsonPath.new('$..book[*].isbn').on(json)
164+
# => ["0-553-21311-3", "0-395-19395-8"]
165+
166+
JsonPath.new('$..book[*].isbn', default_path_leaf_to_null: true).on(json)
167+
# => [nil, nil, "0-553-21311-3", "0-395-19395-8"]
168+
```
169+
170+
When JsonPath returns a Hash, you can ask to symbolize its keys
171+
using the `:symbolize_keys` option
172+
173+
```ruby
174+
JsonPath.new('$..book[0]').on(json)
175+
# => [{"category" => "reference", ...}]
176+
177+
JsonPath.new('$..book[0]', symbolize_keys: true).on(json)
178+
# => [{category: "reference", ...}]
124179
```
125180

126181
### Selecting Values
127182

128-
It's possible to select results once a query has been defined after the query. For example given this JSON data:
183+
It's possible to select results once a query has been defined after the query. For
184+
example given this JSON data:
129185

130186
```bash
131187
{
@@ -168,15 +224,10 @@ It's possible to select results once a query has been defined after the query. F
168224
]
169225
```
170226
171-
### Running an individual test
172-
173-
```ruby
174-
ruby -Ilib:../lib test/test_jsonpath.rb --name test_wildcard_on_intermediary_element_v6
175-
```
176-
177227
### Manipulation
178228
179-
If you'd like to do substitution in a json object, you can use `#gsub` or `#gsub!` to modify the object in place.
229+
If you'd like to do substitution in a json object, you can use `#gsub`
230+
or `#gsub!` to modify the object in place.
180231
181232
```ruby
182233
JsonPath.for('{"candy":"lollipop"}').gsub('$..candy') {|v| "big turks" }.to_hash
@@ -188,7 +239,9 @@ The result will be
188239
{'candy' => 'big turks'}
189240
```
190241
191-
If you'd like to remove all nil keys, you can use `#compact` and `#compact!`. To remove all keys under a certain path, use `#delete` or `#delete!`. You can even chain these methods together as follows:
242+
If you'd like to remove all nil keys, you can use `#compact` and `#compact!`.
243+
To remove all keys under a certain path, use `#delete` or `#delete!`. You can
244+
even chain these methods together as follows:
192245

193246
```ruby
194247
json = '{"candy":"lollipop","noncandy":null,"other":"things"}'
@@ -202,4 +255,11 @@ o = JsonPath.for(json).
202255

203256
# Contributions
204257

205-
Please feel free to submit an Issue or a Pull Request any time you feel like you would like to contribute. Thank you!
258+
Please feel free to submit an Issue or a Pull Request any time you feel like
259+
you would like to contribute. Thank you!
260+
261+
## Running an individual test
262+
263+
```ruby
264+
ruby -Ilib:../lib test/test_jsonpath.rb --name test_wildcard_on_intermediary_element_v6
265+
```

lib/jsonpath.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'strscan'
44
require 'multi_json'
55
require 'jsonpath/proxy'
6+
require 'jsonpath/dig'
67
require 'jsonpath/enumerable'
78
require 'jsonpath/version'
89
require 'jsonpath/parser'
@@ -12,10 +13,17 @@
1213
class JsonPath
1314
PATH_ALL = '$..*'
1415

16+
DEFAULT_OPTIONS = {
17+
:default_path_leaf_to_null => false,
18+
:symbolize_keys => false,
19+
:use_symbols => false,
20+
:allow_send => true
21+
}
22+
1523
attr_accessor :path
1624

1725
def initialize(path, opts = {})
18-
@opts = opts
26+
@opts = DEFAULT_OPTIONS.merge(opts)
1927
scanner = StringScanner.new(path.strip)
2028
@path = []
2129
until scanner.eos?

lib/jsonpath/dig.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
class JsonPath
4+
module Dig
5+
6+
# Similar to what Hash#dig or Array#dig
7+
def dig(context, *keys)
8+
keys.inject(context){|memo,k|
9+
dig_one(memo, k)
10+
}
11+
end
12+
13+
# Returns a hash mapping each key from keys
14+
# to its dig value on context.
15+
def dig_as_hash(context, keys)
16+
keys.each_with_object({}) do |k, memo|
17+
memo[k] = dig_one(context, k)
18+
end
19+
end
20+
21+
# Dig the value of k on context.
22+
def dig_one(context, k)
23+
case context
24+
when Hash
25+
context[@options[:use_symbols] ? k.to_sym : k]
26+
when Array
27+
context[k.to_i]
28+
else
29+
if context.respond_to?(:dig)
30+
context.dig(k)
31+
elsif @options[:allow_send]
32+
context.__send__(k)
33+
end
34+
end
35+
end
36+
37+
# Yields the block if context has a diggable
38+
# value for k
39+
def yield_if_diggable(context, k, &blk)
40+
case context
41+
when Array
42+
nil
43+
when Hash
44+
k = @options[:use_symbols] ? k.to_sym : k
45+
return yield if context.key?(k) || @options[:default_path_leaf_to_null]
46+
else
47+
if context.respond_to?(:dig)
48+
digged = dig_one(context, k)
49+
yield if !digged.nil? || @options[:default_path_leaf_to_null]
50+
elsif @options[:allow_send] && context.respond_to?(k.to_s) && !Object.respond_to?(k.to_s)
51+
yield
52+
end
53+
end
54+
end
55+
56+
end
57+
end

lib/jsonpath/enumerable.rb

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
class JsonPath
44
class Enumerable
55
include ::Enumerable
6+
include Dig
67

78
def initialize(path, object, mode, options = {})
89
@path = path.path
@@ -12,12 +13,7 @@ def initialize(path, object, mode, options = {})
1213
end
1314

1415
def each(context = @object, key = nil, pos = 0, &blk)
15-
node =
16-
if key
17-
context.is_a?(Hash) || context.is_a?(Array) ? context[key] : context.__send__(key)
18-
else
19-
context
20-
end
16+
node = key ? dig_one(context, key) : context
2117
@_current_node = node
2218
return yield_value(blk, context, key) if pos == @path.size
2319

@@ -47,11 +43,10 @@ def each(context = @object, key = nil, pos = 0, &blk)
4743
def filter_context(context, keys)
4844
case context
4945
when Hash
50-
# TODO: Change this to `slice(*keys)` when ruby version support is > 2.4
51-
context.select { |k| keys.include?(k) }
46+
dig_as_hash(context, keys)
5247
when Array
5348
context.each_with_object([]) do |c, memo|
54-
memo << c.select { |k| keys.include?(k) }
49+
memo << dig_as_hash(c, keys)
5550
end
5651
end
5752
end
@@ -61,16 +56,14 @@ def handle_wildecard(node, expr, _context, _key, pos, &blk)
6156
case sub_path[0]
6257
when '\'', '"'
6358
k = sub_path[1, sub_path.size - 2]
64-
if node.is_a?(Hash)
65-
node[k] ||= nil if @options[:default_path_leaf_to_null]
66-
each(node, k, pos + 1, &blk) if node.key?(k)
67-
elsif node.respond_to?(k.to_s) && !Object.respond_to?(k.to_s)
59+
yield_if_diggable(node, k) do
6860
each(node, k, pos + 1, &blk)
6961
end
7062
when '?'
7163
handle_question_mark(sub_path, node, pos, &blk)
7264
else
7365
next if node.is_a?(Array) && node.empty?
66+
next if node.nil? # when default_path_leaf_to_null is true
7467

7568
array_args = sub_path.split(':')
7669
if array_args[0] == '*'
@@ -130,7 +123,7 @@ def handle_question_mark(sub_path, node, pos, &blk)
130123
def yield_value(blk, context, key)
131124
case @mode
132125
when nil
133-
blk.call(key ? context[key] : context)
126+
blk.call(key ? dig_one(context, key) : context)
134127
when :compact
135128
if key && context[key].nil?
136129
key.is_a?(Integer) ? context.delete_at(key) : context.delete(key)
@@ -162,12 +155,12 @@ def process_function_or_literal(exp, default = nil)
162155
el == '@' ? '@' : "['#{el}']"
163156
end.join
164157
begin
165-
return JsonPath::Parser.new(@_current_node).parse(exp_to_eval)
158+
return JsonPath::Parser.new(@_current_node, @options).parse(exp_to_eval)
166159
rescue StandardError
167160
return default
168161
end
169162
end
170-
JsonPath::Parser.new(@_current_node).parse(exp)
163+
JsonPath::Parser.new(@_current_node, @options).parse(exp)
171164
end
172165
end
173166
end

0 commit comments

Comments
 (0)