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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78a07561ecdca899e8715c239ebaaa0e7ed9892a557445bcceb03b3cbb7f61a6
4
- data.tar.gz: 3e418c526ccb1fff468d02485c7b76a071de30b84059c1205b9478e0d683c235
3
+ metadata.gz: cd62ae147160109624e6b6d186c60479cfca265cbe8c66f40755863a532abc1e
4
+ data.tar.gz: 2cac68f58e4ea420d84429d660730a07de443d71d8560b025c82a5166239ccb2
5
5
  SHA512:
6
- metadata.gz: 40bd26003e395e6ca24f52cf469df2004096cecb5113365b65f5fab8153614bd8c0fa62acd49f11bfbc9da9971b56b6ba8aa839536796336a24ba0a47ea90b4d
7
- data.tar.gz: 1694b53c1f359b143eeb54a336c3e1612044408a7ebd9fb4d4b036ca6829777ace260a44c23f86d1b5ebfbade802a8be4508a5116aee0bc863dc805457945e92
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
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-json_path.svg)](https://rubygems.org/gems/philiprehberger-json_path)
5
5
  [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-json-path)](https://github.com/philiprehberger/rb-json-path/commits/main)
6
6
 
7
- JSONPath expression evaluator with dot notation, wildcards, slices, and filters
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module JsonPath
5
- VERSION = '0.1.6'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -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
- case remaining
48
- when /\A\.(\w+)/
49
- tokens << { type: :key, value: Regexp.last_match(1) }
50
- remaining = remaining[Regexp.last_match(0).length..]
51
- when /\A\[(\d+)\]/
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.1.6
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-03-31 00:00:00.000000000 Z
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, and filter expressions for querying
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://github.com/philiprehberger/rb-json-path
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://github.com/philiprehberger/rb-json-path
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, and filters
54
+ summary: JSONPath expression evaluator with dot notation, wildcards, slices, filters,
55
+ and recursive descent
55
56
  test_files: []