philiprehberger-json_path 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +45 -1
- data/lib/philiprehberger/json_path/version.rb +1 -1
- data/lib/philiprehberger/json_path.rb +129 -39
- metadata +8 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd62ae147160109624e6b6d186c60479cfca265cbe8c66f40755863a532abc1e
|
|
4
|
+
data.tar.gz: 2cac68f58e4ea420d84429d660730a07de443d71d8560b025c82a5166239ccb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8f52edcf91f65be006493f1e9c60b03cbf6fbe83e935602622d96f07f729a560d02d49532976f72e5ba6c292a4c5f063298373cba732e158e2003ef5b2ad0a0
|
|
7
|
+
data.tar.gz: 027f7f078a753000cf26c1783b045e58dbf65100c8de3ca8496196d7f48c61ddf5e14e5904dc5aa5ea1981510780742301cb985ed9e73f698bcfd8e9147c2fa0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-04-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Recursive descent operator `..` for matching keys at any nesting depth
|
|
14
|
+
- `JsonPath.count(data, path)` method to return number of matches
|
|
15
|
+
- `JsonPath.values(data, path)` method as an alias for `query`
|
|
16
|
+
- Negation filter `!` support: `$[?(!@.key)]` matches items without a key
|
|
17
|
+
- Filter comparison with `@.key.length` for arrays and strings
|
|
18
|
+
|
|
19
|
+
## [0.1.7] - 2026-03-31
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Add GitHub issue templates, dependabot config, and PR template
|
|
23
|
+
|
|
10
24
|
## [0.1.6] - 2026-03-31
|
|
11
25
|
|
|
12
26
|
### Changed
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-json_path)
|
|
5
5
|
[](https://github.com/philiprehberger/rb-json-path/commits/main)
|
|
6
6
|
|
|
7
|
-
JSONPath expression evaluator with dot notation, wildcards, slices, and
|
|
7
|
+
JSONPath expression evaluator with dot notation, wildcards, slices, filters, and recursive descent
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -42,13 +42,29 @@ data = {
|
|
|
42
42
|
Philiprehberger::JsonPath.query(data, '$.store.books[*].title')
|
|
43
43
|
# => ["Ruby", "Python", "Go"]
|
|
44
44
|
|
|
45
|
+
Philiprehberger::JsonPath.values(data, '$.store.books[*].title')
|
|
46
|
+
# => ["Ruby", "Python", "Go"] (alias for query)
|
|
47
|
+
|
|
45
48
|
Philiprehberger::JsonPath.first(data, '$.store.books[0].title')
|
|
46
49
|
# => "Ruby"
|
|
47
50
|
|
|
51
|
+
Philiprehberger::JsonPath.count(data, '$.store.books[*]')
|
|
52
|
+
# => 3
|
|
53
|
+
|
|
48
54
|
Philiprehberger::JsonPath.exists?(data, '$.store.books')
|
|
49
55
|
# => true
|
|
50
56
|
```
|
|
51
57
|
|
|
58
|
+
### Recursive Descent
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Philiprehberger::JsonPath.query(data, '$..price')
|
|
62
|
+
# => [30, 25, 20]
|
|
63
|
+
|
|
64
|
+
Philiprehberger::JsonPath.query(data, '$..title')
|
|
65
|
+
# => ["Ruby", "Python", "Go"]
|
|
66
|
+
```
|
|
67
|
+
|
|
52
68
|
### Array Indexing and Slicing
|
|
53
69
|
|
|
54
70
|
```ruby
|
|
@@ -72,6 +88,29 @@ Philiprehberger::JsonPath.query(data, "$.store.books[?(@.title=='Go')].price")
|
|
|
72
88
|
# => [20]
|
|
73
89
|
```
|
|
74
90
|
|
|
91
|
+
### Negation Filters
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
data = { 'items' => [{ 'name' => 'a', 'hidden' => true }, { 'name' => 'b' }] }
|
|
95
|
+
|
|
96
|
+
Philiprehberger::JsonPath.query(data, '$.items[?(!@.hidden)].name')
|
|
97
|
+
# => ["b"]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Length Comparisons
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
data = {
|
|
104
|
+
'groups' => [
|
|
105
|
+
{ 'name' => 'team1', 'members' => ['Alice', 'Bob'] },
|
|
106
|
+
{ 'name' => 'team2', 'members' => [] }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Philiprehberger::JsonPath.query(data, '$.groups[?(@.members.length > 0)].name')
|
|
111
|
+
# => ["team1"]
|
|
112
|
+
```
|
|
113
|
+
|
|
75
114
|
### Supported Syntax
|
|
76
115
|
|
|
77
116
|
| Syntax | Description |
|
|
@@ -82,15 +121,20 @@ Philiprehberger::JsonPath.query(data, "$.store.books[?(@.title=='Go')].price")
|
|
|
82
121
|
| `[n]` | Array index (supports negative) |
|
|
83
122
|
| `[*]` | Wildcard (all elements) |
|
|
84
123
|
| `[start:end]` | Array slice |
|
|
124
|
+
| `..key` | Recursive descent (match key at any depth) |
|
|
85
125
|
| `[?(@.key>val)]` | Filter expression |
|
|
86
126
|
| `[?(@.key)]` | Existence filter |
|
|
127
|
+
| `[?(!@.key)]` | Negation filter |
|
|
128
|
+
| `[?(@.key.length>n)]` | Length comparison filter |
|
|
87
129
|
|
|
88
130
|
## API
|
|
89
131
|
|
|
90
132
|
| Method | Description |
|
|
91
133
|
|--------|-------------|
|
|
92
134
|
| `JsonPath.query(data, path)` | Return all matches as an array |
|
|
135
|
+
| `JsonPath.values(data, path)` | Alias for `query` |
|
|
93
136
|
| `JsonPath.first(data, path)` | Return the first match or nil |
|
|
137
|
+
| `JsonPath.count(data, path)` | Return the number of matches |
|
|
94
138
|
| `JsonPath.exists?(data, path)` | Check if any match exists |
|
|
95
139
|
|
|
96
140
|
## Development
|
|
@@ -16,6 +16,15 @@ module Philiprehberger
|
|
|
16
16
|
evaluate(data, tokens)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Alias for query (more discoverable name)
|
|
20
|
+
#
|
|
21
|
+
# @param data [Hash, Array] the data structure to query
|
|
22
|
+
# @param path [String] JSONPath expression
|
|
23
|
+
# @return [Array] all matching values
|
|
24
|
+
def self.values(data, path)
|
|
25
|
+
query(data, path)
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
# Query data and return the first match
|
|
20
29
|
#
|
|
21
30
|
# @param data [Hash, Array] the data structure to query
|
|
@@ -25,6 +34,15 @@ module Philiprehberger
|
|
|
25
34
|
query(data, path).first
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
# Return the number of matches for a JSONPath expression
|
|
38
|
+
#
|
|
39
|
+
# @param data [Hash, Array] the data structure to query
|
|
40
|
+
# @param path [String] JSONPath expression
|
|
41
|
+
# @return [Integer] number of matches
|
|
42
|
+
def self.count(data, path)
|
|
43
|
+
query(data, path).size
|
|
44
|
+
end
|
|
45
|
+
|
|
28
46
|
# Check if a JSONPath expression matches anything
|
|
29
47
|
#
|
|
30
48
|
# @param data [Hash, Array] the data structure to query
|
|
@@ -37,6 +55,22 @@ module Philiprehberger
|
|
|
37
55
|
class << self
|
|
38
56
|
private
|
|
39
57
|
|
|
58
|
+
TOKEN_PATTERNS = [
|
|
59
|
+
[/\A\.\.(\w+)/, :recursive_pat],
|
|
60
|
+
[/\A\.(\w+)/, :key_pat],
|
|
61
|
+
[/\A\[(\d+)\]/, :index_pat],
|
|
62
|
+
[/\A\[\*\]/, :wildcard_pat],
|
|
63
|
+
[/\A\[(-?\d+):(-?\d+)\]/, :slice_both_pat],
|
|
64
|
+
[/\A\[:(-?\d+)\]/, :slice_end_pat],
|
|
65
|
+
[/\A\[(-?\d+):\]/, :slice_start_pat],
|
|
66
|
+
[/\A\[\?\(!@\.(\w+)\)\]/, :filter_not_exists_pat],
|
|
67
|
+
[/\A\[\?\(@\.([\w.]+)\.length\s*(==|!=|>|>=|<|<=)\s*([^\]]+)\)\]/, :filter_length_pat],
|
|
68
|
+
[/\A\[\?\(@\.(\w+)\s*(==|!=|>|>=|<|<=)\s*([^\]]+)\)\]/, :filter_pat],
|
|
69
|
+
[/\A\[\?\(@\.(\w+)\)\]/, :filter_exists_pat],
|
|
70
|
+
[/\A\['([^']+)'\]/, :bracket_single_pat],
|
|
71
|
+
[/\A\["([^"]+)"\]/, :bracket_double_pat]
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
40
74
|
def tokenize(path)
|
|
41
75
|
raise Error, 'Path must start with $' unless path.to_s.start_with?('$')
|
|
42
76
|
|
|
@@ -44,50 +78,44 @@ module Philiprehberger
|
|
|
44
78
|
tokens = []
|
|
45
79
|
|
|
46
80
|
until remaining.empty?
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
tokens << { type: :index, value: Regexp.last_match(1).to_i }
|
|
53
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
54
|
-
when /\A\[\*\]/
|
|
55
|
-
tokens << { type: :wildcard }
|
|
56
|
-
remaining = remaining[3..]
|
|
57
|
-
when /\A\[(-?\d+):(-?\d+)\]/
|
|
58
|
-
tokens << { type: :slice, start: Regexp.last_match(1).to_i, end: Regexp.last_match(2).to_i }
|
|
59
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
60
|
-
when /\A\[:(-?\d+)\]/
|
|
61
|
-
tokens << { type: :slice, start: 0, end: Regexp.last_match(1).to_i }
|
|
62
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
63
|
-
when /\A\[(-?\d+):\]/
|
|
64
|
-
tokens << { type: :slice, start: Regexp.last_match(1).to_i, end: nil }
|
|
65
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
66
|
-
when /\A\[\?\(@\.(\w+)\s*(==|!=|>|>=|<|<=)\s*([^\]]+)\)\]/
|
|
67
|
-
tokens << {
|
|
68
|
-
type: :filter,
|
|
69
|
-
key: Regexp.last_match(1),
|
|
70
|
-
op: Regexp.last_match(2),
|
|
71
|
-
value: parse_filter_value(Regexp.last_match(3).strip)
|
|
72
|
-
}
|
|
73
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
74
|
-
when /\A\[\?\(@\.(\w+)\)\]/
|
|
75
|
-
tokens << { type: :filter_exists, key: Regexp.last_match(1) }
|
|
76
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
77
|
-
when /\A\['([^']+)'\]/
|
|
78
|
-
tokens << { type: :key, value: Regexp.last_match(1) }
|
|
79
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
80
|
-
when /\A\["([^"]+)"\]/
|
|
81
|
-
tokens << { type: :key, value: Regexp.last_match(1) }
|
|
82
|
-
remaining = remaining[Regexp.last_match(0).length..]
|
|
83
|
-
else
|
|
84
|
-
raise Error, "Unexpected token at: #{remaining}"
|
|
85
|
-
end
|
|
81
|
+
token, consumed = match_token(remaining)
|
|
82
|
+
raise Error, "Unexpected token at: #{remaining}" unless token
|
|
83
|
+
|
|
84
|
+
tokens << token
|
|
85
|
+
remaining = remaining[consumed..]
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
tokens
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def match_token(remaining)
|
|
92
|
+
TOKEN_PATTERNS.each do |pattern, kind|
|
|
93
|
+
m = pattern.match(remaining)
|
|
94
|
+
next unless m
|
|
95
|
+
|
|
96
|
+
return build_token(kind, m), m[0].length
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_token(kind, m)
|
|
102
|
+
case kind
|
|
103
|
+
when :recursive_pat then { type: :recursive, value: m[1] }
|
|
104
|
+
when :key_pat then { type: :key, value: m[1] }
|
|
105
|
+
when :index_pat then { type: :index, value: m[1].to_i }
|
|
106
|
+
when :wildcard_pat then { type: :wildcard }
|
|
107
|
+
when :slice_both_pat then { type: :slice, start: m[1].to_i, end: m[2].to_i }
|
|
108
|
+
when :slice_end_pat then { type: :slice, start: 0, end: m[1].to_i }
|
|
109
|
+
when :slice_start_pat then { type: :slice, start: m[1].to_i, end: nil }
|
|
110
|
+
when :filter_not_exists_pat then { type: :filter_not_exists, key: m[1] }
|
|
111
|
+
when :filter_length_pat then { type: :filter_length, key_path: m[1], op: m[2], value: parse_filter_value(m[3].strip) }
|
|
112
|
+
when :filter_pat then { type: :filter, key: m[1], op: m[2], value: parse_filter_value(m[3].strip) }
|
|
113
|
+
when :filter_exists_pat then { type: :filter_exists, key: m[1] }
|
|
114
|
+
when :bracket_single_pat then { type: :key, value: m[1] }
|
|
115
|
+
when :bracket_double_pat then { type: :key, value: m[1] }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
91
119
|
def parse_filter_value(str)
|
|
92
120
|
case str
|
|
93
121
|
when /\A'(.*)'\z/ then Regexp.last_match(1)
|
|
@@ -125,6 +153,12 @@ module Philiprehberger
|
|
|
125
153
|
apply_filter(node, token[:key], token[:op], token[:value])
|
|
126
154
|
when :filter_exists
|
|
127
155
|
apply_filter_exists(node, token[:key])
|
|
156
|
+
when :filter_not_exists
|
|
157
|
+
apply_filter_not_exists(node, token[:key])
|
|
158
|
+
when :filter_length
|
|
159
|
+
apply_filter_length(node, token[:key_path], token[:op], token[:value])
|
|
160
|
+
when :recursive
|
|
161
|
+
apply_recursive(node, token[:value])
|
|
128
162
|
else
|
|
129
163
|
[]
|
|
130
164
|
end
|
|
@@ -191,6 +225,62 @@ module Philiprehberger
|
|
|
191
225
|
end
|
|
192
226
|
end
|
|
193
227
|
|
|
228
|
+
def apply_filter_not_exists(node, key)
|
|
229
|
+
return [] unless node.is_a?(Array)
|
|
230
|
+
|
|
231
|
+
node.reject do |item|
|
|
232
|
+
next false unless item.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
item.key?(key) || item.key?(key.to_sym)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def apply_filter_length(node, key_path, op, value)
|
|
239
|
+
return [] unless node.is_a?(Array)
|
|
240
|
+
|
|
241
|
+
node.select do |item|
|
|
242
|
+
next false unless item.is_a?(Hash)
|
|
243
|
+
|
|
244
|
+
resolved = resolve_key_path(item, key_path)
|
|
245
|
+
next false if resolved.nil?
|
|
246
|
+
|
|
247
|
+
length = resolved.respond_to?(:length) ? resolved.length : nil
|
|
248
|
+
next false if length.nil?
|
|
249
|
+
|
|
250
|
+
compare(length, op, value)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def resolve_key_path(node, key_path)
|
|
255
|
+
keys = key_path.split('.')
|
|
256
|
+
current = node
|
|
257
|
+
keys.each do |key|
|
|
258
|
+
return nil unless current.is_a?(Hash)
|
|
259
|
+
|
|
260
|
+
current = current[key] || current[key.to_sym]
|
|
261
|
+
return nil if current.nil?
|
|
262
|
+
end
|
|
263
|
+
current
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def apply_recursive(node, key)
|
|
267
|
+
results = []
|
|
268
|
+
collect_recursive(node, key, results)
|
|
269
|
+
results
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def collect_recursive(node, key, results)
|
|
273
|
+
case node
|
|
274
|
+
when Hash
|
|
275
|
+
node.each do |k, v|
|
|
276
|
+
results << v if k.to_s == key
|
|
277
|
+
collect_recursive(v, key, results)
|
|
278
|
+
end
|
|
279
|
+
when Array
|
|
280
|
+
node.each { |item| collect_recursive(item, key, results) }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
194
284
|
def compare(actual, op, value)
|
|
195
285
|
case op
|
|
196
286
|
when '==' then actual == value
|
metadata
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-json_path
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- philiprehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-04-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Evaluate JSONPath expressions against Ruby hashes and arrays. Supports
|
|
14
|
-
dot notation, array indexing, wildcards, slices,
|
|
15
|
-
nested data.
|
|
14
|
+
dot notation, array indexing, wildcards, slices, filter expressions, recursive descent,
|
|
15
|
+
and length comparisons for querying nested data.
|
|
16
16
|
email:
|
|
17
17
|
- philiprehberger@users.noreply.github.com
|
|
18
18
|
executables: []
|
|
@@ -24,11 +24,11 @@ files:
|
|
|
24
24
|
- README.md
|
|
25
25
|
- lib/philiprehberger/json_path.rb
|
|
26
26
|
- lib/philiprehberger/json_path/version.rb
|
|
27
|
-
homepage: https://
|
|
27
|
+
homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-json_path
|
|
28
28
|
licenses:
|
|
29
29
|
- MIT
|
|
30
30
|
metadata:
|
|
31
|
-
homepage_uri: https://
|
|
31
|
+
homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-json_path
|
|
32
32
|
source_code_uri: https://github.com/philiprehberger/rb-json-path
|
|
33
33
|
changelog_uri: https://github.com/philiprehberger/rb-json-path/blob/main/CHANGELOG.md
|
|
34
34
|
bug_tracker_uri: https://github.com/philiprehberger/rb-json-path/issues
|
|
@@ -51,5 +51,6 @@ requirements: []
|
|
|
51
51
|
rubygems_version: 3.5.22
|
|
52
52
|
signing_key:
|
|
53
53
|
specification_version: 4
|
|
54
|
-
summary: JSONPath expression evaluator with dot notation, wildcards, slices,
|
|
54
|
+
summary: JSONPath expression evaluator with dot notation, wildcards, slices, filters,
|
|
55
|
+
and recursive descent
|
|
55
56
|
test_files: []
|