philiprehberger-json_path 0.2.0 → 0.3.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: a5f622166e4beba5e485f24b63a4286e28b6745c120a1271cd3e663cfe87150f
4
+ data.tar.gz: 71dd674e321a36c295037a9672ab8c92f0eb87e6709a9ec7bbc98299abc14343
5
5
  SHA512:
6
- metadata.gz: b8f52edcf91f65be006493f1e9c60b03cbf6fbe83e935602622d96f07f729a560d02d49532976f72e5ba6c292a4c5f063298373cba732e158e2003ef5b2ad0a0
7
- data.tar.gz: 027f7f078a753000cf26c1783b045e58dbf65100c8de3ca8496196d7f48c61ddf5e14e5904dc5aa5ea1981510780742301cb985ed9e73f698bcfd8e9147c2fa0
6
+ metadata.gz: ff4e5a102c7c9ab0d59add298fbd4b9b2d4f9feb2d88274cdebdbb7358cdcb97aeccd99c62e6d5e38990902a1840224e4c042cb3e03c5751cb21a27f14a95410
7
+ data.tar.gz: 1bb735639d90eb6827d01b6b5a37ad5b9c63d9dcece71dfd45ef124bae7cca27de0dab19335184e0351e3f86c1905603f2f39aa8b48325183542dfa60adac16e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-17
11
+
12
+ ### Added
13
+ - `JsonPath.paths(data, expression)` returns the canonical JSONPath strings of every match (complement to `.values`)
14
+
10
15
  ## [0.2.0] - 2026-04-03
11
16
 
12
17
  ### Added
data/README.md CHANGED
@@ -55,6 +55,21 @@ Philiprehberger::JsonPath.exists?(data, '$.store.books')
55
55
  # => true
56
56
  ```
57
57
 
58
+ ### Matched paths
59
+
60
+ 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.
61
+
62
+ ```ruby
63
+ Philiprehberger::JsonPath.paths(data, '$.store.books[*].title')
64
+ # => ["$.store.books[0].title", "$.store.books[1].title", "$.store.books[2].title"]
65
+
66
+ Philiprehberger::JsonPath.paths(data, '$.store.books[?(@.price>22)]')
67
+ # => ["$.store.books[0]", "$.store.books[1]"]
68
+
69
+ Philiprehberger::JsonPath.paths(data, '$.missing')
70
+ # => []
71
+ ```
72
+
58
73
  ### Recursive Descent
59
74
 
60
75
  ```ruby
@@ -136,6 +151,7 @@ Philiprehberger::JsonPath.query(data, '$.groups[?(@.members.length > 0)].name')
136
151
  | `JsonPath.first(data, path)` | Return the first match or nil |
137
152
  | `JsonPath.count(data, path)` | Return the number of matches |
138
153
  | `JsonPath.exists?(data, path)` | Check if any match exists |
154
+ | `JsonPath.paths(data, path)` | Return canonical JSONPath strings for each match |
139
155
 
140
156
  ## Development
141
157
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module JsonPath
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -52,6 +52,20 @@ module Philiprehberger
52
52
  !query(data, path).empty?
53
53
  end
54
54
 
55
+ # Return the canonical JSONPath strings for every match of an expression
56
+ #
57
+ # Each returned path is an unambiguous JSONPath that, when re-evaluated,
58
+ # resolves to the single element it identifies. Paths are returned in
59
+ # document order. Returns an empty array when no matches exist.
60
+ #
61
+ # @param data [Hash, Array] the data structure to query
62
+ # @param path [String] JSONPath expression
63
+ # @return [Array<String>] canonical JSONPath strings for each match
64
+ def self.paths(data, path)
65
+ tokens = tokenize(path)
66
+ evaluate_with_paths(data, tokens).map { |_node, p| p }
67
+ end
68
+
55
69
  class << self
56
70
  private
57
71
 
@@ -281,6 +295,163 @@ module Philiprehberger
281
295
  end
282
296
  end
283
297
 
298
+ SAFE_KEY = /\A[A-Za-z_][A-Za-z0-9_]*\z/
299
+
300
+ def evaluate_with_paths(data, tokens)
301
+ results = [[data, '$']]
302
+
303
+ tokens.each do |token|
304
+ results = results.flat_map { |node, path| apply_token_with_paths(node, path, token) }
305
+ end
306
+
307
+ results
308
+ end
309
+
310
+ def apply_token_with_paths(node, path, token)
311
+ case token[:type]
312
+ when :key
313
+ apply_key_with_paths(node, path, token[:value])
314
+ when :index
315
+ apply_index_with_paths(node, path, token[:value])
316
+ when :wildcard
317
+ apply_wildcard_with_paths(node, path)
318
+ when :slice
319
+ apply_slice_with_paths(node, path, token[:start], token[:end])
320
+ when :filter
321
+ apply_filter_with_paths(node, path, token[:key], token[:op], token[:value])
322
+ when :filter_exists
323
+ apply_filter_exists_with_paths(node, path, token[:key])
324
+ when :filter_not_exists
325
+ apply_filter_not_exists_with_paths(node, path, token[:key])
326
+ when :filter_length
327
+ apply_filter_length_with_paths(node, path, token[:key_path], token[:op], token[:value])
328
+ when :recursive
329
+ apply_recursive_with_paths(node, path, token[:value])
330
+ else
331
+ []
332
+ end
333
+ end
334
+
335
+ def key_segment(key)
336
+ str = key.to_s
337
+ SAFE_KEY.match?(str) ? ".#{str}" : "['#{str.gsub("'", "\\\\'")}']"
338
+ end
339
+
340
+ def apply_key_with_paths(node, path, key)
341
+ return [] unless node.is_a?(Hash)
342
+
343
+ sym_key = key.to_sym
344
+ if node.key?(key)
345
+ [[node[key], path + key_segment(key)]]
346
+ elsif node.key?(sym_key)
347
+ [[node[sym_key], path + key_segment(key)]]
348
+ else
349
+ []
350
+ end
351
+ end
352
+
353
+ def apply_index_with_paths(node, path, index)
354
+ return [] unless node.is_a?(Array)
355
+ return [] if index >= node.length || index < -node.length
356
+
357
+ normalized = index.negative? ? node.length + index : index
358
+ [[node[index], "#{path}[#{normalized}]"]]
359
+ end
360
+
361
+ def apply_wildcard_with_paths(node, path)
362
+ case node
363
+ when Array
364
+ node.each_with_index.map { |v, i| [v, "#{path}[#{i}]"] }
365
+ when Hash
366
+ node.map { |k, v| [v, path + key_segment(k)] }
367
+ else
368
+ []
369
+ end
370
+ end
371
+
372
+ def apply_slice_with_paths(node, path, start_idx, end_idx)
373
+ return [] unless node.is_a?(Array)
374
+
375
+ end_idx = node.length if end_idx.nil?
376
+ range = (start_idx...end_idx)
377
+ indices = range.to_a.select { |i| i >= 0 && i < node.length }
378
+ indices.map { |i| [node[i], "#{path}[#{i}]"] }
379
+ end
380
+
381
+ def apply_filter_with_paths(node, path, key, op, value)
382
+ return [] unless node.is_a?(Array)
383
+
384
+ matches = node.each_with_index.select do |item, _i|
385
+ next false unless item.is_a?(Hash)
386
+
387
+ actual = item[key] || item[key.to_sym]
388
+ next false if actual.nil? && !item.key?(key) && !item.key?(key.to_sym)
389
+
390
+ compare(actual, op, value)
391
+ end
392
+ matches.map { |item, i| [item, "#{path}[#{i}]"] }
393
+ end
394
+
395
+ def apply_filter_exists_with_paths(node, path, key)
396
+ return [] unless node.is_a?(Array)
397
+
398
+ matches = node.each_with_index.select do |item, _i|
399
+ next false unless item.is_a?(Hash)
400
+
401
+ item.key?(key) || item.key?(key.to_sym)
402
+ end
403
+ matches.map { |item, i| [item, "#{path}[#{i}]"] }
404
+ end
405
+
406
+ def apply_filter_not_exists_with_paths(node, path, key)
407
+ return [] unless node.is_a?(Array)
408
+
409
+ matches = node.each_with_index.reject do |item, _i|
410
+ next false unless item.is_a?(Hash)
411
+
412
+ item.key?(key) || item.key?(key.to_sym)
413
+ end
414
+ matches.map { |item, i| [item, "#{path}[#{i}]"] }
415
+ end
416
+
417
+ def apply_filter_length_with_paths(node, path, key_path, op, value)
418
+ return [] unless node.is_a?(Array)
419
+
420
+ matches = node.each_with_index.select do |item, _i|
421
+ next false unless item.is_a?(Hash)
422
+
423
+ resolved = resolve_key_path(item, key_path)
424
+ next false if resolved.nil?
425
+
426
+ length = resolved.respond_to?(:length) ? resolved.length : nil
427
+ next false if length.nil?
428
+
429
+ compare(length, op, value)
430
+ end
431
+ matches.map { |item, i| [item, "#{path}[#{i}]"] }
432
+ end
433
+
434
+ def apply_recursive_with_paths(node, path, key)
435
+ results = []
436
+ collect_recursive_with_paths(node, path, key, results)
437
+ results
438
+ end
439
+
440
+ def collect_recursive_with_paths(node, path, key, results)
441
+ case node
442
+ when Hash
443
+ node.each do |k, v|
444
+ child_path = path + key_segment(k)
445
+ results << [v, child_path] if k.to_s == key
446
+ collect_recursive_with_paths(v, child_path, key, results)
447
+ end
448
+ when Array
449
+ node.each_with_index do |item, i|
450
+ collect_recursive_with_paths(item, "#{path}[#{i}]", key, results)
451
+ end
452
+ end
453
+ end
454
+
284
455
  def compare(actual, op, value)
285
456
  case op
286
457
  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.3.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-18 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,