philiprehberger-json_path 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fea7dff060c513be1aa323e746e021e9381bf71754234bcf15a116b9d3932ebd
4
+ data.tar.gz: e7ee0c5e2dd6aad12e45d8745e82d6736bf8ccf7877bef194b6584654288de0c
5
+ SHA512:
6
+ metadata.gz: a26904d252804d051fd691ae7f9a24cbd68f884c1806498f2ccf254676efbdf053980eb21d8b3e694010054f53a3e4a8818c09c973a847bed4878dd55029a72f
7
+ data.tar.gz: 7593e2eb7d1a2489804ab010aca5c2269e210e0b6502245cc14b75b9bf63ac20300a28a5fad8bea2aee89c50148f764d3fc2f1367f1e22b61b01a7c2b5984382
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-22
11
+
12
+ ### Added
13
+ - Initial release
14
+ - JSONPath expression parsing and evaluation
15
+ - Dot notation and bracket notation for key access
16
+ - Array indexing with positive and negative indices
17
+ - Wildcard operator for arrays and hashes
18
+ - Array slicing with start:end syntax
19
+ - Filter expressions with comparison operators
20
+ - Existence filter for checking key presence
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 philiprehberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # philiprehberger-json_path
2
+
3
+ [![Tests](https://github.com/philiprehberger/rb-json-path/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-json-path/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-json_path.svg)](https://rubygems.org/gems/philiprehberger-json_path)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-json-path)](LICENSE)
6
+
7
+ JSONPath expression evaluator for querying nested data structures. Supports dot notation, array indexing, wildcards, slices, and filter expressions.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby >= 3.1
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem 'philiprehberger-json_path'
19
+ ```
20
+
21
+ Or install directly:
22
+
23
+ ```bash
24
+ gem install philiprehberger-json_path
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ruby
30
+ require 'philiprehberger/json_path'
31
+
32
+ data = {
33
+ 'store' => {
34
+ 'books' => [
35
+ { 'title' => 'Ruby', 'price' => 30 },
36
+ { 'title' => 'Python', 'price' => 25 },
37
+ { 'title' => 'Go', 'price' => 20 }
38
+ ]
39
+ }
40
+ }
41
+
42
+ Philiprehberger::JsonPath.query(data, '$.store.books[*].title')
43
+ # => ["Ruby", "Python", "Go"]
44
+
45
+ Philiprehberger::JsonPath.first(data, '$.store.books[0].title')
46
+ # => "Ruby"
47
+
48
+ Philiprehberger::JsonPath.exists?(data, '$.store.books')
49
+ # => true
50
+ ```
51
+
52
+ ### Array Indexing and Slicing
53
+
54
+ ```ruby
55
+ Philiprehberger::JsonPath.query(data, '$.store.books[0]')
56
+ # => [{"title"=>"Ruby", "price"=>30}]
57
+
58
+ Philiprehberger::JsonPath.query(data, '$.store.books[-1].title')
59
+ # => ["Go"]
60
+
61
+ Philiprehberger::JsonPath.query(data, '$.store.books[0:2].title')
62
+ # => ["Ruby", "Python"]
63
+ ```
64
+
65
+ ### Filter Expressions
66
+
67
+ ```ruby
68
+ Philiprehberger::JsonPath.query(data, '$.store.books[?(@.price>22)].title')
69
+ # => ["Ruby", "Python"]
70
+
71
+ Philiprehberger::JsonPath.query(data, "$.store.books[?(@.title=='Go')].price")
72
+ # => [20]
73
+ ```
74
+
75
+ ### Supported Syntax
76
+
77
+ | Syntax | Description |
78
+ |--------|-------------|
79
+ | `$` | Root element |
80
+ | `.key` | Dot notation for object keys |
81
+ | `['key']` | Bracket notation for object keys |
82
+ | `[n]` | Array index (supports negative) |
83
+ | `[*]` | Wildcard (all elements) |
84
+ | `[start:end]` | Array slice |
85
+ | `[?(@.key>val)]` | Filter expression |
86
+ | `[?(@.key)]` | Existence filter |
87
+
88
+ ## API
89
+
90
+ | Method | Description |
91
+ |--------|-------------|
92
+ | `JsonPath.query(data, path)` | Return all matches as an array |
93
+ | `JsonPath.first(data, path)` | Return the first match or nil |
94
+ | `JsonPath.exists?(data, path)` | Check if any match exists |
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ bundle install
100
+ bundle exec rspec # Run tests
101
+ bundle exec rubocop # Check code style
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module JsonPath
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'json_path/version'
4
+
5
+ module Philiprehberger
6
+ module JsonPath
7
+ class Error < StandardError; end
8
+
9
+ # Query data with a JSONPath expression and return all matches
10
+ #
11
+ # @param data [Hash, Array] the data structure to query
12
+ # @param path [String] JSONPath expression
13
+ # @return [Array] all matching values
14
+ def self.query(data, path)
15
+ tokens = tokenize(path)
16
+ evaluate(data, tokens)
17
+ end
18
+
19
+ # Query data and return the first match
20
+ #
21
+ # @param data [Hash, Array] the data structure to query
22
+ # @param path [String] JSONPath expression
23
+ # @return [Object, nil] the first matching value or nil
24
+ def self.first(data, path)
25
+ query(data, path).first
26
+ end
27
+
28
+ # Check if a JSONPath expression matches anything
29
+ #
30
+ # @param data [Hash, Array] the data structure to query
31
+ # @param path [String] JSONPath expression
32
+ # @return [Boolean] true if at least one match exists
33
+ def self.exists?(data, path)
34
+ !query(data, path).empty?
35
+ end
36
+
37
+ class << self
38
+ private
39
+
40
+ def tokenize(path)
41
+ raise Error, 'Path must start with $' unless path.to_s.start_with?('$')
42
+
43
+ remaining = path[1..]
44
+ tokens = []
45
+
46
+ 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
86
+ end
87
+
88
+ tokens
89
+ end
90
+
91
+ def parse_filter_value(str)
92
+ case str
93
+ when /\A'(.*)'\z/ then Regexp.last_match(1)
94
+ when /\A"(.*)"\z/ then Regexp.last_match(1)
95
+ when /\Atrue\z/i then true
96
+ when /\Afalse\z/i then false
97
+ when /\Anil\z/i, /\Anull\z/i then nil
98
+ when /\A-?\d+\z/ then str.to_i
99
+ when /\A-?\d+\.\d+\z/ then str.to_f
100
+ else str
101
+ end
102
+ end
103
+
104
+ def evaluate(data, tokens)
105
+ results = [data]
106
+
107
+ tokens.each do |token|
108
+ results = results.flat_map { |node| apply_token(node, token) }
109
+ end
110
+
111
+ results
112
+ end
113
+
114
+ def apply_token(node, token)
115
+ case token[:type]
116
+ when :key
117
+ apply_key(node, token[:value])
118
+ when :index
119
+ apply_index(node, token[:value])
120
+ when :wildcard
121
+ apply_wildcard(node)
122
+ when :slice
123
+ apply_slice(node, token[:start], token[:end])
124
+ when :filter
125
+ apply_filter(node, token[:key], token[:op], token[:value])
126
+ when :filter_exists
127
+ apply_filter_exists(node, token[:key])
128
+ else
129
+ []
130
+ end
131
+ end
132
+
133
+ def apply_key(node, key)
134
+ case node
135
+ when Hash
136
+ sym_key = key.to_sym
137
+ if node.key?(key)
138
+ [node[key]]
139
+ elsif node.key?(sym_key)
140
+ [node[sym_key]]
141
+ else
142
+ []
143
+ end
144
+ else
145
+ []
146
+ end
147
+ end
148
+
149
+ def apply_index(node, index)
150
+ return [] unless node.is_a?(Array)
151
+ return [] if index >= node.length || index < -node.length
152
+
153
+ [node[index]]
154
+ end
155
+
156
+ def apply_wildcard(node)
157
+ case node
158
+ when Array then node
159
+ when Hash then node.values
160
+ else []
161
+ end
162
+ end
163
+
164
+ def apply_slice(node, start_idx, end_idx)
165
+ return [] unless node.is_a?(Array)
166
+
167
+ end_idx = node.length if end_idx.nil?
168
+ node[start_idx...end_idx] || []
169
+ end
170
+
171
+ def apply_filter(node, key, op, value)
172
+ return [] unless node.is_a?(Array)
173
+
174
+ node.select do |item|
175
+ next false unless item.is_a?(Hash)
176
+
177
+ actual = item[key] || item[key.to_sym]
178
+ next false if actual.nil? && !item.key?(key) && !item.key?(key.to_sym)
179
+
180
+ compare(actual, op, value)
181
+ end
182
+ end
183
+
184
+ def apply_filter_exists(node, key)
185
+ return [] unless node.is_a?(Array)
186
+
187
+ node.select do |item|
188
+ next false unless item.is_a?(Hash)
189
+
190
+ item.key?(key) || item.key?(key.to_sym)
191
+ end
192
+ end
193
+
194
+ def compare(actual, op, value)
195
+ case op
196
+ when '==' then actual == value
197
+ when '!=' then actual != value
198
+ when '>' then actual.is_a?(Numeric) && value.is_a?(Numeric) && actual > value
199
+ when '>=' then actual.is_a?(Numeric) && value.is_a?(Numeric) && actual >= value
200
+ when '<' then actual.is_a?(Numeric) && value.is_a?(Numeric) && actual < value
201
+ when '<=' then actual.is_a?(Numeric) && value.is_a?(Numeric) && actual <= value
202
+ else false
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: philiprehberger-json_path
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - philiprehberger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-22 00:00:00.000000000 Z
12
+ dependencies: []
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.
16
+ email:
17
+ - philiprehberger@users.noreply.github.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE
24
+ - README.md
25
+ - lib/philiprehberger/json_path.rb
26
+ - lib/philiprehberger/json_path/version.rb
27
+ homepage: https://github.com/philiprehberger/rb-json-path
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/philiprehberger/rb-json-path
32
+ source_code_uri: https://github.com/philiprehberger/rb-json-path
33
+ changelog_uri: https://github.com/philiprehberger/rb-json-path/blob/main/CHANGELOG.md
34
+ rubygems_mfa_required: 'true'
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.1'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.5.22
51
+ signing_key:
52
+ specification_version: 4
53
+ summary: JSONPath expression evaluator for querying nested data structures
54
+ test_files: []