philiprehberger-json_path 0.2.0 → 0.4.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 +10 -0
- data/README.md +21 -0
- data/lib/philiprehberger/json_path/version.rb +1 -1
- data/lib/philiprehberger/json_path.rb +180 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d1f6e3df479eeb12b69af816742069bd38c31953c3b1611add83bc3aac4434a
|
|
4
|
+
data.tar.gz: 69b8a9a12b7a95fcf12ac173c6559d9e002a9d8d085c05b28716eb9948262fe5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c603bf6cd3c2a58f248b1ca535ebcd63d28f3d1cdc269ff73f7e2889f161316c0a1c3a710c7e250c5529d090458fc42ffeac1cb2a43020fb81d037b500548a04
|
|
7
|
+
data.tar.gz: 12a21c296a86fe78d87ffd71ad9bba9b81200d60c8ffb1f3959e77f56f7312d31fda4d04fc00e3baa37103dee548237d0827810d8abe672b148b696d86f9c9e4
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-04-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `JsonPath.last(data, path)` — last matching value; symmetric with existing `.first`; returns `nil` on no match
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-04-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `JsonPath.paths(data, expression)` returns the canonical JSONPath strings of every match (complement to `.values`)
|
|
19
|
+
|
|
10
20
|
## [0.2.0] - 2026-04-03
|
|
11
21
|
|
|
12
22
|
### Added
|
data/README.md
CHANGED
|
@@ -48,6 +48,10 @@ Philiprehberger::JsonPath.values(data, '$.store.books[*].title')
|
|
|
48
48
|
Philiprehberger::JsonPath.first(data, '$.store.books[0].title')
|
|
49
49
|
# => "Ruby"
|
|
50
50
|
|
|
51
|
+
items = { "items" => [{ "id" => 1 }, { "id" => 2 }, { "id" => 3 }] }
|
|
52
|
+
Philiprehberger::JsonPath.first(items, "$.items[*].id") # => 1
|
|
53
|
+
Philiprehberger::JsonPath.last(items, "$.items[*].id") # => 3
|
|
54
|
+
|
|
51
55
|
Philiprehberger::JsonPath.count(data, '$.store.books[*]')
|
|
52
56
|
# => 3
|
|
53
57
|
|
|
@@ -55,6 +59,21 @@ Philiprehberger::JsonPath.exists?(data, '$.store.books')
|
|
|
55
59
|
# => true
|
|
56
60
|
```
|
|
57
61
|
|
|
62
|
+
### Matched paths
|
|
63
|
+
|
|
64
|
+
Use `paths` to get canonical JSONPath strings for every match, rather than the values themselves. Each returned path re-evaluates back to the single element it identifies, making it handy for diffing, patching, or reporting.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Philiprehberger::JsonPath.paths(data, '$.store.books[*].title')
|
|
68
|
+
# => ["$.store.books[0].title", "$.store.books[1].title", "$.store.books[2].title"]
|
|
69
|
+
|
|
70
|
+
Philiprehberger::JsonPath.paths(data, '$.store.books[?(@.price>22)]')
|
|
71
|
+
# => ["$.store.books[0]", "$.store.books[1]"]
|
|
72
|
+
|
|
73
|
+
Philiprehberger::JsonPath.paths(data, '$.missing')
|
|
74
|
+
# => []
|
|
75
|
+
```
|
|
76
|
+
|
|
58
77
|
### Recursive Descent
|
|
59
78
|
|
|
60
79
|
```ruby
|
|
@@ -134,8 +153,10 @@ Philiprehberger::JsonPath.query(data, '$.groups[?(@.members.length > 0)].name')
|
|
|
134
153
|
| `JsonPath.query(data, path)` | Return all matches as an array |
|
|
135
154
|
| `JsonPath.values(data, path)` | Alias for `query` |
|
|
136
155
|
| `JsonPath.first(data, path)` | Return the first match or nil |
|
|
156
|
+
| `JsonPath.last(data, path)` | Return the last match or nil |
|
|
137
157
|
| `JsonPath.count(data, path)` | Return the number of matches |
|
|
138
158
|
| `JsonPath.exists?(data, path)` | Check if any match exists |
|
|
159
|
+
| `JsonPath.paths(data, path)` | Return canonical JSONPath strings for each match |
|
|
139
160
|
|
|
140
161
|
## Development
|
|
141
162
|
|
|
@@ -34,6 +34,15 @@ module Philiprehberger
|
|
|
34
34
|
query(data, path).first
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Query data and return the last match
|
|
38
|
+
#
|
|
39
|
+
# @param data [Hash, Array] the data structure to query
|
|
40
|
+
# @param path [String] JSONPath expression
|
|
41
|
+
# @return [Object, nil] the last matching value or nil
|
|
42
|
+
def self.last(data, path)
|
|
43
|
+
query(data, path).last
|
|
44
|
+
end
|
|
45
|
+
|
|
37
46
|
# Return the number of matches for a JSONPath expression
|
|
38
47
|
#
|
|
39
48
|
# @param data [Hash, Array] the data structure to query
|
|
@@ -52,6 +61,20 @@ module Philiprehberger
|
|
|
52
61
|
!query(data, path).empty?
|
|
53
62
|
end
|
|
54
63
|
|
|
64
|
+
# Return the canonical JSONPath strings for every match of an expression
|
|
65
|
+
#
|
|
66
|
+
# Each returned path is an unambiguous JSONPath that, when re-evaluated,
|
|
67
|
+
# resolves to the single element it identifies. Paths are returned in
|
|
68
|
+
# document order. Returns an empty array when no matches exist.
|
|
69
|
+
#
|
|
70
|
+
# @param data [Hash, Array] the data structure to query
|
|
71
|
+
# @param path [String] JSONPath expression
|
|
72
|
+
# @return [Array<String>] canonical JSONPath strings for each match
|
|
73
|
+
def self.paths(data, path)
|
|
74
|
+
tokens = tokenize(path)
|
|
75
|
+
evaluate_with_paths(data, tokens).map { |_node, p| p }
|
|
76
|
+
end
|
|
77
|
+
|
|
55
78
|
class << self
|
|
56
79
|
private
|
|
57
80
|
|
|
@@ -281,6 +304,163 @@ module Philiprehberger
|
|
|
281
304
|
end
|
|
282
305
|
end
|
|
283
306
|
|
|
307
|
+
SAFE_KEY = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
308
|
+
|
|
309
|
+
def evaluate_with_paths(data, tokens)
|
|
310
|
+
results = [[data, '$']]
|
|
311
|
+
|
|
312
|
+
tokens.each do |token|
|
|
313
|
+
results = results.flat_map { |node, path| apply_token_with_paths(node, path, token) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
results
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def apply_token_with_paths(node, path, token)
|
|
320
|
+
case token[:type]
|
|
321
|
+
when :key
|
|
322
|
+
apply_key_with_paths(node, path, token[:value])
|
|
323
|
+
when :index
|
|
324
|
+
apply_index_with_paths(node, path, token[:value])
|
|
325
|
+
when :wildcard
|
|
326
|
+
apply_wildcard_with_paths(node, path)
|
|
327
|
+
when :slice
|
|
328
|
+
apply_slice_with_paths(node, path, token[:start], token[:end])
|
|
329
|
+
when :filter
|
|
330
|
+
apply_filter_with_paths(node, path, token[:key], token[:op], token[:value])
|
|
331
|
+
when :filter_exists
|
|
332
|
+
apply_filter_exists_with_paths(node, path, token[:key])
|
|
333
|
+
when :filter_not_exists
|
|
334
|
+
apply_filter_not_exists_with_paths(node, path, token[:key])
|
|
335
|
+
when :filter_length
|
|
336
|
+
apply_filter_length_with_paths(node, path, token[:key_path], token[:op], token[:value])
|
|
337
|
+
when :recursive
|
|
338
|
+
apply_recursive_with_paths(node, path, token[:value])
|
|
339
|
+
else
|
|
340
|
+
[]
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def key_segment(key)
|
|
345
|
+
str = key.to_s
|
|
346
|
+
SAFE_KEY.match?(str) ? ".#{str}" : "['#{str.gsub("'", "\\\\'")}']"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def apply_key_with_paths(node, path, key)
|
|
350
|
+
return [] unless node.is_a?(Hash)
|
|
351
|
+
|
|
352
|
+
sym_key = key.to_sym
|
|
353
|
+
if node.key?(key)
|
|
354
|
+
[[node[key], path + key_segment(key)]]
|
|
355
|
+
elsif node.key?(sym_key)
|
|
356
|
+
[[node[sym_key], path + key_segment(key)]]
|
|
357
|
+
else
|
|
358
|
+
[]
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def apply_index_with_paths(node, path, index)
|
|
363
|
+
return [] unless node.is_a?(Array)
|
|
364
|
+
return [] if index >= node.length || index < -node.length
|
|
365
|
+
|
|
366
|
+
normalized = index.negative? ? node.length + index : index
|
|
367
|
+
[[node[index], "#{path}[#{normalized}]"]]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def apply_wildcard_with_paths(node, path)
|
|
371
|
+
case node
|
|
372
|
+
when Array
|
|
373
|
+
node.each_with_index.map { |v, i| [v, "#{path}[#{i}]"] }
|
|
374
|
+
when Hash
|
|
375
|
+
node.map { |k, v| [v, path + key_segment(k)] }
|
|
376
|
+
else
|
|
377
|
+
[]
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def apply_slice_with_paths(node, path, start_idx, end_idx)
|
|
382
|
+
return [] unless node.is_a?(Array)
|
|
383
|
+
|
|
384
|
+
end_idx = node.length if end_idx.nil?
|
|
385
|
+
range = (start_idx...end_idx)
|
|
386
|
+
indices = range.to_a.select { |i| i >= 0 && i < node.length }
|
|
387
|
+
indices.map { |i| [node[i], "#{path}[#{i}]"] }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def apply_filter_with_paths(node, path, key, op, value)
|
|
391
|
+
return [] unless node.is_a?(Array)
|
|
392
|
+
|
|
393
|
+
matches = node.each_with_index.select do |item, _i|
|
|
394
|
+
next false unless item.is_a?(Hash)
|
|
395
|
+
|
|
396
|
+
actual = item[key] || item[key.to_sym]
|
|
397
|
+
next false if actual.nil? && !item.key?(key) && !item.key?(key.to_sym)
|
|
398
|
+
|
|
399
|
+
compare(actual, op, value)
|
|
400
|
+
end
|
|
401
|
+
matches.map { |item, i| [item, "#{path}[#{i}]"] }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def apply_filter_exists_with_paths(node, path, key)
|
|
405
|
+
return [] unless node.is_a?(Array)
|
|
406
|
+
|
|
407
|
+
matches = node.each_with_index.select do |item, _i|
|
|
408
|
+
next false unless item.is_a?(Hash)
|
|
409
|
+
|
|
410
|
+
item.key?(key) || item.key?(key.to_sym)
|
|
411
|
+
end
|
|
412
|
+
matches.map { |item, i| [item, "#{path}[#{i}]"] }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def apply_filter_not_exists_with_paths(node, path, key)
|
|
416
|
+
return [] unless node.is_a?(Array)
|
|
417
|
+
|
|
418
|
+
matches = node.each_with_index.reject do |item, _i|
|
|
419
|
+
next false unless item.is_a?(Hash)
|
|
420
|
+
|
|
421
|
+
item.key?(key) || item.key?(key.to_sym)
|
|
422
|
+
end
|
|
423
|
+
matches.map { |item, i| [item, "#{path}[#{i}]"] }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def apply_filter_length_with_paths(node, path, key_path, op, value)
|
|
427
|
+
return [] unless node.is_a?(Array)
|
|
428
|
+
|
|
429
|
+
matches = node.each_with_index.select do |item, _i|
|
|
430
|
+
next false unless item.is_a?(Hash)
|
|
431
|
+
|
|
432
|
+
resolved = resolve_key_path(item, key_path)
|
|
433
|
+
next false if resolved.nil?
|
|
434
|
+
|
|
435
|
+
length = resolved.respond_to?(:length) ? resolved.length : nil
|
|
436
|
+
next false if length.nil?
|
|
437
|
+
|
|
438
|
+
compare(length, op, value)
|
|
439
|
+
end
|
|
440
|
+
matches.map { |item, i| [item, "#{path}[#{i}]"] }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def apply_recursive_with_paths(node, path, key)
|
|
444
|
+
results = []
|
|
445
|
+
collect_recursive_with_paths(node, path, key, results)
|
|
446
|
+
results
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def collect_recursive_with_paths(node, path, key, results)
|
|
450
|
+
case node
|
|
451
|
+
when Hash
|
|
452
|
+
node.each do |k, v|
|
|
453
|
+
child_path = path + key_segment(k)
|
|
454
|
+
results << [v, child_path] if k.to_s == key
|
|
455
|
+
collect_recursive_with_paths(v, child_path, key, results)
|
|
456
|
+
end
|
|
457
|
+
when Array
|
|
458
|
+
node.each_with_index do |item, i|
|
|
459
|
+
collect_recursive_with_paths(item, "#{path}[#{i}]", key, results)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
284
464
|
def compare(actual, op, value)
|
|
285
465
|
case op
|
|
286
466
|
when '==' then actual == value
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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.4.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-04-
|
|
11
|
+
date: 2026-04-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Evaluate JSONPath expressions against Ruby hashes and arrays. Supports
|
|
14
14
|
dot notation, array indexing, wildcards, slices, filter expressions, recursive descent,
|