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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd62ae147160109624e6b6d186c60479cfca265cbe8c66f40755863a532abc1e
4
- data.tar.gz: 2cac68f58e4ea420d84429d660730a07de443d71d8560b025c82a5166239ccb2
3
+ metadata.gz: 9d1f6e3df479eeb12b69af816742069bd38c31953c3b1611add83bc3aac4434a
4
+ data.tar.gz: 69b8a9a12b7a95fcf12ac173c6559d9e002a9d8d085c05b28716eb9948262fe5
5
5
  SHA512:
6
- metadata.gz: b8f52edcf91f65be006493f1e9c60b03cbf6fbe83e935602622d96f07f729a560d02d49532976f72e5ba6c292a4c5f063298373cba732e158e2003ef5b2ad0a0
7
- data.tar.gz: 027f7f078a753000cf26c1783b045e58dbf65100c8de3ca8496196d7f48c61ddf5e14e5904dc5aa5ea1981510780742301cb985ed9e73f698bcfd8e9147c2fa0
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module JsonPath
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -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.2.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-04 00:00:00.000000000 Z
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,