iso-jsonpath 1.1.6

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.
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JsonPath
4
+ class Enumerable
5
+ include ::Enumerable
6
+ include Dig
7
+
8
+ def initialize(path, object, mode, options = {})
9
+ @path = path.path
10
+ @object = object
11
+ @mode = mode
12
+ @options = options
13
+ end
14
+
15
+ def each(context = @object, key = nil, pos = 0, &blk)
16
+ node = key ? dig_one(context, key) : context
17
+ @_current_node = node
18
+ return yield_value(blk, context, key) if pos == @path.size
19
+
20
+ case expr = @path[pos]
21
+ when '*', '..', '@'
22
+ each(context, key, pos + 1, &blk)
23
+ when '$'
24
+ if node == @object
25
+ each(context, key, pos + 1, &blk)
26
+ else
27
+ handle_wildcard(node, "['#{expr}']", context, key, pos, &blk)
28
+ end
29
+ when /^\[(.*)\]$/
30
+ handle_wildcard(node, expr, context, key, pos, &blk)
31
+ when /\(.*\)/
32
+ keys = expr.gsub(/[()]/, '').split(',').map(&:strip)
33
+ new_context = filter_context(context, keys)
34
+ yield_value(blk, new_context, key)
35
+ end
36
+
37
+ if pos > 0 && @path[pos - 1] == '..' || (@path[pos - 1] == '*' && @path[pos] != '..')
38
+ case node
39
+ when Hash then node.each { |k, _| each(node, k, pos, &blk) }
40
+ when Array then node.each_with_index { |_, i| each(node, i, pos, &blk) }
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def filter_context(context, keys)
48
+ case context
49
+ when Hash
50
+ dig_as_hash(context, keys)
51
+ when Array
52
+ context.each_with_object([]) do |c, memo|
53
+ memo << dig_as_hash(c, keys)
54
+ end
55
+ end
56
+ end
57
+
58
+ def handle_wildcard(node, expr, _context, _key, pos, &blk)
59
+ expr[1, expr.size - 2].split(',').each do |sub_path|
60
+ case sub_path[0]
61
+ when '\'', '"'
62
+ k = sub_path[1, sub_path.size - 2]
63
+ yield_if_diggable(node, k) do
64
+ each(node, k, pos + 1, &blk)
65
+ end
66
+ when '?'
67
+ handle_question_mark(sub_path, node, pos, &blk)
68
+ else
69
+ next if node.is_a?(Array) && node.empty?
70
+ next if node.nil? # when default_path_leaf_to_null is true
71
+ next if node.size.zero?
72
+
73
+ array_args = sub_path.split(':')
74
+ if array_args[0] == '*'
75
+ start_idx = 0
76
+ end_idx = node.size - 1
77
+ elsif sub_path.count(':') == 0
78
+ start_idx = end_idx = process_function_or_literal(array_args[0], 0)
79
+ next unless start_idx
80
+ next if start_idx >= node.size
81
+ else
82
+ start_idx = process_function_or_literal(array_args[0], 0)
83
+ next unless start_idx
84
+
85
+ end_idx = array_args[1] && ensure_exclusive_end_index(process_function_or_literal(array_args[1], -1)) || -1
86
+ next unless end_idx
87
+ next if start_idx == end_idx && start_idx >= node.size
88
+ end
89
+
90
+ start_idx %= node.size
91
+ end_idx %= node.size
92
+ step = process_function_or_literal(array_args[2], 1)
93
+ next unless step
94
+
95
+ if @mode == :delete
96
+ (start_idx..end_idx).step(step) { |i| node[i] = nil }
97
+ node.compact!
98
+ else
99
+ (start_idx..end_idx).step(step) { |i| each(node, i, pos + 1, &blk) }
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def ensure_exclusive_end_index(value)
106
+ return value unless value.is_a?(Integer) && value > 0
107
+
108
+ value - 1
109
+ end
110
+
111
+ def handle_question_mark(sub_path, node, pos, &blk)
112
+ case node
113
+ when Array
114
+ node.size.times do |index|
115
+ @_current_node = node[index]
116
+ if process_function_or_literal(sub_path[1, sub_path.size - 1])
117
+ each(@_current_node, nil, pos + 1, &blk)
118
+ end
119
+ end
120
+ when Hash
121
+ if process_function_or_literal(sub_path[1, sub_path.size - 1])
122
+ each(@_current_node, nil, pos + 1, &blk)
123
+ end
124
+ else
125
+ yield node if process_function_or_literal(sub_path[1, sub_path.size - 1])
126
+ end
127
+ end
128
+
129
+ def yield_value(blk, context, key)
130
+ case @mode
131
+ when nil
132
+ blk.call(key ? dig_one(context, key) : context)
133
+ when :compact
134
+ if key && context[key].nil?
135
+ key.is_a?(Integer) ? context.delete_at(key) : context.delete(key)
136
+ end
137
+ when :delete
138
+ if key
139
+ key.is_a?(Integer) ? context.delete_at(key) : context.delete(key)
140
+ else
141
+ context.replace({})
142
+ end
143
+ when :substitute
144
+ if key
145
+ context[key] = blk.call(context[key])
146
+ else
147
+ context.replace(blk.call(context[key]))
148
+ end
149
+ end
150
+ end
151
+
152
+ def process_function_or_literal(exp, default = nil)
153
+ return default if exp.nil? || exp.empty?
154
+ return Integer(exp) if exp[0] != '('
155
+ return nil unless @_current_node
156
+
157
+ identifiers = /@?(((?<!\d)\.(?!\d)(\w+))|\['(.*?)'\])+/.match(exp)
158
+ # to filter arrays with known/unknown name.
159
+ if (!identifiers.nil? && !(@_current_node.methods.include?(identifiers[2]&.to_sym) || @_current_node.methods.include?(identifiers[4]&.to_sym)))
160
+ exp_to_eval = exp.dup
161
+ begin
162
+ return JsonPath::Parser.new(@_current_node, @options).parse(exp_to_eval)
163
+ rescue StandardError
164
+ return default
165
+ end
166
+ end
167
+ JsonPath::Parser.new(@_current_node, @options).parse(exp)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ class JsonPath
6
+ # Parser parses and evaluates an expression passed to @_current_node.
7
+ class Parser
8
+ include Dig
9
+
10
+ REGEX = /\A\/(.+)\/([imxnesu]*)\z|\A%r{(.+)}([imxnesu]*)\z/
11
+
12
+ def initialize(node, options)
13
+ @_current_node = node
14
+ @_expr_map = {}
15
+ @options = options
16
+ end
17
+
18
+ # parse will parse an expression in the following way.
19
+ # Split the expression up into an array of legs for && and || operators.
20
+ # Parse this array into a map for which the keys are the parsed legs
21
+ #  of the split. This map is then used to replace the expression with their
22
+ # corresponding boolean or numeric value. This might look something like this:
23
+ # ((false || false) && (false || true))
24
+ #  Once this string is assembled... we proceed to evaluate from left to right.
25
+ #  The above string is broken down like this:
26
+ # (false && (false || true))
27
+ # (false && true)
28
+ #  false
29
+ def parse(exp)
30
+ exps = exp.split(/(&&)|(\|\|)/)
31
+ construct_expression_map(exps)
32
+ @_expr_map.each { |k, v| exp.sub!(k, v.to_s) }
33
+ raise ArgumentError, "unmatched parenthesis in expression: #{exp}" unless check_parenthesis_count(exp)
34
+
35
+ exp = parse_parentheses(exp) while exp.include?('(')
36
+ bool_or_exp(exp)
37
+ end
38
+
39
+ # Construct a map for which the keys are the expressions
40
+ #  and the values are the corresponding parsed results.
41
+ # Exp.:
42
+ # {"(@['author'] =~ /herman|lukyanenko/i)"=>0}
43
+ # {"@['isTrue']"=>true}
44
+ def construct_expression_map(exps)
45
+ exps.each_with_index do |item, _index|
46
+ next if item == '&&' || item == '||'
47
+
48
+ item = item.strip.gsub(/\)*$/, '').gsub(/^\(*/, '')
49
+ @_expr_map[item] = parse_exp(item)
50
+ end
51
+ end
52
+
53
+ # Using a scanner break down the individual expressions and determine if
54
+ # there is a match in the JSON for it or not.
55
+ def parse_exp(exp)
56
+ exp = exp.sub(/@/, '').gsub(/^\(/, '').gsub(/\)$/, '').tr('"', '\'').strip
57
+ exp.scan(/^\[(\d+)\]/) do |i|
58
+ next if i.empty?
59
+
60
+ index = Integer(i[0])
61
+ raise ArgumentError, 'Node does not appear to be an array.' unless @_current_node.is_a?(Array)
62
+ raise ArgumentError, "Index out of bounds for nested array. Index: #{index}" if @_current_node.size < index
63
+
64
+ @_current_node = @_current_node[index]
65
+ # Remove the extra '' and the index.
66
+ exp = exp.gsub(/^\[\d+\]|\[''\]/, '')
67
+ end
68
+ scanner = StringScanner.new(exp)
69
+ elements = []
70
+ until scanner.eos?
71
+ if (t = scanner.scan(/\['[a-zA-Z@&*\/$%^?_ ]+'\]/))
72
+ elements << t.gsub(/^\s*\[\s*'(.*)'\s*\]\s*$/, '\1')
73
+ elsif (t = scanner.scan(/\.[a-zA-Z0-9_]+[?]?/))
74
+ elements << t.gsub(/[\[\]'.]|\s+/, '')
75
+ elsif (t = scanner.scan(/(\s+)?[<>=!\-+][=~]?(\s+)?/))
76
+ operator = t
77
+ elsif (t = scanner.scan(/(\s+)?'?.*'?(\s+)?/))
78
+ # If we encounter a node which does not contain `'` it means
79
+ #  that we are dealing with a boolean type.
80
+ operand =
81
+ if t == 'true'
82
+ true
83
+ elsif t == 'false'
84
+ false
85
+ elsif operator.to_s.strip == '=~'
86
+ parse_regex(t)
87
+ else
88
+ t.gsub(%r{^'|'$}, '').strip
89
+ end
90
+ elsif (t = scanner.scan(/\/\w+\//))
91
+ elsif (t = scanner.scan(/.*/))
92
+ raise "Could not process symbol: #{t}"
93
+ end
94
+ end
95
+
96
+ el = if elements.empty?
97
+ @_current_node
98
+ elsif @_current_node.is_a?(Hash)
99
+ dig(@_current_node, *elements)
100
+ else
101
+ elements.inject(@_current_node, &:__send__)
102
+ end
103
+
104
+ return (el ? true : false) if el.nil? || operator.nil?
105
+
106
+ el = Float(el) rescue el
107
+ operand = Float(operand) rescue operand
108
+
109
+ el.__send__(operator.strip, operand)
110
+ end
111
+
112
+ private
113
+
114
+ # /foo/i -> Regex.new("foo", Regexp::IGNORECASE) without using eval
115
+ # also supports %r{foo}i
116
+ # following https://github.com/seamusabshere/to_regexp/blob/master/lib/to_regexp.rb
117
+ def parse_regex(t)
118
+ t =~ REGEX
119
+ content = $1 || $3
120
+ options = $2 || $4
121
+
122
+ raise ArgumentError, "unsupported regex #{t} use /foo/ style" if !content || !options
123
+
124
+ content = content.gsub '\\/', '/'
125
+
126
+ flags = 0
127
+ flags |= Regexp::IGNORECASE if options.include?('i')
128
+ flags |= Regexp::MULTILINE if options.include?('m')
129
+ flags |= Regexp::EXTENDED if options.include?('x')
130
+
131
+ # 'n' = none, 'e' = EUC, 's' = SJIS, 'u' = UTF-8
132
+ lang = options.scan(/[nes]/).join.downcase # ignores u since that is default and causes a warning
133
+
134
+ args = [content, flags]
135
+ args << lang unless lang.empty? # avoid warning
136
+ Regexp.new(*args)
137
+ end
138
+
139
+ #  This will break down a parenthesis from the left to the right
140
+ #  and replace the given expression with it's returned value.
141
+ # It does this in order to make it easy to eliminate groups
142
+ # one-by-one.
143
+ def parse_parentheses(str)
144
+ opening_index = 0
145
+ closing_index = 0
146
+
147
+ (0..str.length - 1).step(1) do |i|
148
+ opening_index = i if str[i] == '('
149
+ if str[i] == ')'
150
+ closing_index = i
151
+ break
152
+ end
153
+ end
154
+
155
+ to_parse = str[opening_index + 1..closing_index - 1]
156
+
157
+ #  handle cases like (true && true || false && true) in
158
+ # one giant parenthesis.
159
+ top = to_parse.split(/(&&)|(\|\|)/)
160
+ top = top.map(&:strip)
161
+ res = bool_or_exp(top.shift)
162
+ top.each_with_index do |item, index|
163
+ if item == '&&'
164
+ next_value = bool_or_exp(top[index + 1])
165
+ res &&= next_value
166
+ elsif item == '||'
167
+ next_value = bool_or_exp(top[index + 1])
168
+ res ||= next_value
169
+ end
170
+ end
171
+
172
+ #  if we are at the last item, the opening index will be 0
173
+ # and the closing index will be the last index. To avoid
174
+ # off-by-one errors we simply return the result at that point.
175
+ if closing_index + 1 >= str.length && opening_index == 0
176
+ res.to_s
177
+ else
178
+ "#{str[0..opening_index - 1]}#{res}#{str[closing_index + 1..str.length]}"
179
+ end
180
+ end
181
+
182
+ #  This is convoluted and I should probably refactor it somehow.
183
+ #  The map that is created will contain strings since essentially I'm
184
+ # constructing a string like `true || true && false`.
185
+ # With eval the need for this would disappear but never the less, here
186
+ #  it is. The fact is that the results can be either boolean, or a number
187
+ # in case there is only indexing happening like give me the 3rd item... or
188
+ # it also can be nil in case of regexes or things that aren't found.
189
+ # Hence, I have to be clever here to see what kind of variable I need to
190
+ # provide back.
191
+ def bool_or_exp(b)
192
+ if b.to_s == 'true'
193
+ return true
194
+ elsif b.to_s == 'false'
195
+ return false
196
+ elsif b.to_s == ''
197
+ return nil
198
+ end
199
+
200
+ b = Float(b) rescue b
201
+ b
202
+ end
203
+
204
+ # this simply makes sure that we aren't getting into the whole
205
+ #  parenthesis parsing business without knowing that every parenthesis
206
+ # has its pair.
207
+ def check_parenthesis_count(exp)
208
+ return true unless exp.include?('(')
209
+
210
+ depth = 0
211
+ exp.chars.each do |c|
212
+ if c == '('
213
+ depth += 1
214
+ elsif c == ')'
215
+ depth -= 1
216
+ end
217
+ end
218
+ depth == 0
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JsonPath
4
+ class Proxy
5
+ attr_reader :obj
6
+ alias to_hash obj
7
+
8
+ def initialize(obj)
9
+ @obj = obj
10
+ end
11
+
12
+ def gsub(path, replacement = nil, &replacement_block)
13
+ _gsub(_deep_copy, path, replacement ? proc(&method(:replacement)) : replacement_block)
14
+ end
15
+
16
+ def gsub!(path, replacement = nil, &replacement_block)
17
+ _gsub(@obj, path, replacement ? proc(&method(:replacement)) : replacement_block)
18
+ end
19
+
20
+ def delete(path = JsonPath::PATH_ALL)
21
+ _delete(_deep_copy, path)
22
+ end
23
+
24
+ def delete!(path = JsonPath::PATH_ALL)
25
+ _delete(@obj, path)
26
+ end
27
+
28
+ def compact(path = JsonPath::PATH_ALL)
29
+ _compact(_deep_copy, path)
30
+ end
31
+
32
+ def compact!(path = JsonPath::PATH_ALL)
33
+ _compact(@obj, path)
34
+ end
35
+
36
+ private
37
+
38
+ def _deep_copy
39
+ Marshal.load(Marshal.dump(@obj))
40
+ end
41
+
42
+ def _gsub(obj, path, replacement)
43
+ JsonPath.new(path)[obj, :substitute].each(&replacement)
44
+ Proxy.new(obj)
45
+ end
46
+
47
+ def _delete(obj, path)
48
+ JsonPath.new(path)[obj, :delete].each
49
+ obj = _remove(obj)
50
+ Proxy.new(obj)
51
+ end
52
+
53
+ def _remove(obj)
54
+ obj.each do |o|
55
+ if o.is_a?(Hash) || o.is_a?(Array)
56
+ _remove(o)
57
+ o.delete({})
58
+ end
59
+ end
60
+ end
61
+
62
+ def _compact(obj, path)
63
+ JsonPath.new(path)[obj, :compact].each
64
+ Proxy.new(obj)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JsonPath
4
+ VERSION = '1.1.6'
5
+ end
data/lib/jsonpath.rb ADDED
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+ require 'multi_json'
5
+ require 'jsonpath/proxy'
6
+ require 'jsonpath/dig'
7
+ require 'jsonpath/enumerable'
8
+ require 'jsonpath/version'
9
+ require 'jsonpath/parser'
10
+
11
+ # JsonPath: initializes the class with a given JsonPath and parses that path
12
+ # into a token array.
13
+ class JsonPath
14
+ PATH_ALL = '$..*'
15
+ MAX_NESTING_ALLOWED = 100
16
+
17
+ DEFAULT_OPTIONS = {
18
+ :default_path_leaf_to_null => false,
19
+ :symbolize_keys => false,
20
+ :use_symbols => false,
21
+ :allow_send => true,
22
+ :max_nesting => MAX_NESTING_ALLOWED
23
+ }
24
+
25
+ attr_accessor :path
26
+
27
+ def initialize(path, opts = {})
28
+ @opts = DEFAULT_OPTIONS.merge(opts)
29
+ set_max_nesting
30
+ scanner = StringScanner.new(path.strip)
31
+ @path = []
32
+ until scanner.eos?
33
+ if (token = scanner.scan(/\$\B|@\B|\*|\.\./))
34
+ @path << token
35
+ elsif (token = scanner.scan(/[$@\p{Alnum}:{}_ -]+/))
36
+ @path << "['#{token}']"
37
+ elsif (token = scanner.scan(/'(.*?)'/))
38
+ @path << "[#{token}]"
39
+ elsif (token = scanner.scan(/\[/))
40
+ @path << find_matching_brackets(token, scanner)
41
+ elsif (token = scanner.scan(/\]/))
42
+ raise ArgumentError, 'unmatched closing bracket'
43
+ elsif (token = scanner.scan(/\(.*\)/))
44
+ @path << token
45
+ elsif scanner.scan(/\./)
46
+ nil
47
+ elsif (token = scanner.scan(/[><=] \d+/))
48
+ @path.last << token
49
+ elsif (token = scanner.scan(/./))
50
+ @path.last << token
51
+ else
52
+ raise ArgumentError, "character '#{scanner.peek(1)}' not supported in query"
53
+ end
54
+ end
55
+ end
56
+
57
+ def find_matching_brackets(token, scanner)
58
+ count = 1
59
+ until count.zero?
60
+ if (t = scanner.scan(/\[/))
61
+ token << t
62
+ count += 1
63
+ elsif (t = scanner.scan(/\]/))
64
+ token << t
65
+ count -= 1
66
+ elsif (t = scanner.scan(/[^\[\]]+/))
67
+ token << t
68
+ elsif scanner.eos?
69
+ raise ArgumentError, 'unclosed bracket'
70
+ end
71
+ end
72
+ token
73
+ end
74
+
75
+ def join(join_path)
76
+ res = deep_clone
77
+ res.path += JsonPath.new(join_path).path
78
+ res
79
+ end
80
+
81
+ def on(obj_or_str, opts = {})
82
+ a = enum_on(obj_or_str).to_a
83
+ if symbolize_keys?(opts)
84
+ a.map! do |e|
85
+ e.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v; }
86
+ end
87
+ end
88
+ a
89
+ end
90
+
91
+ def self.fetch_all_path(obj)
92
+ all_paths = ['$']
93
+ find_path(obj, '$', all_paths, obj.class == Array)
94
+ return all_paths
95
+ end
96
+
97
+ def self.find_path(obj, root_key, all_paths, is_array = false)
98
+ obj.each do |key, value|
99
+ table_params = { key: key, root_key: root_key}
100
+ is_loop = value.class == Array || value.class == Hash
101
+ if is_loop
102
+ path_exp = construct_path(table_params)
103
+ all_paths << path_exp
104
+ find_path(value, path_exp, all_paths, value.class == Array)
105
+ elsif is_array
106
+ table_params[:index] = obj.find_index(key)
107
+ path_exp = construct_path(table_params)
108
+ find_path(key, path_exp, all_paths, key.class == Array) if key.class == Hash || key.class == Array
109
+ all_paths << path_exp
110
+ else
111
+ all_paths << construct_path(table_params)
112
+ end
113
+ end
114
+ end
115
+
116
+ def self.construct_path(table_row)
117
+ if table_row[:index]
118
+ return table_row[:root_key] + '['+ table_row[:index].to_s + ']'
119
+ else
120
+ return table_row[:root_key] + '.'+ table_row[:key]
121
+ end
122
+ end
123
+
124
+ def first(obj_or_str, *args)
125
+ enum_on(obj_or_str).first(*args)
126
+ end
127
+
128
+ def enum_on(obj_or_str, mode = nil)
129
+ JsonPath::Enumerable.new(self, self.class.process_object(obj_or_str, @opts), mode,
130
+ @opts)
131
+ end
132
+ alias [] enum_on
133
+
134
+ def self.on(obj_or_str, path, opts = {})
135
+ new(path, opts).on(process_object(obj_or_str))
136
+ end
137
+
138
+ def self.for(obj_or_str)
139
+ Proxy.new(process_object(obj_or_str))
140
+ end
141
+
142
+ private
143
+
144
+ def self.process_object(obj_or_str, opts = {})
145
+ obj_or_str.is_a?(String) ? MultiJson.decode(obj_or_str, max_nesting: opts[:max_nesting]) : obj_or_str
146
+ end
147
+
148
+ def deep_clone
149
+ Marshal.load Marshal.dump(self)
150
+ end
151
+
152
+ def set_max_nesting
153
+ return unless @opts[:max_nesting].is_a?(Integer) && @opts[:max_nesting] > MAX_NESTING_ALLOWED
154
+ @opts[:max_nesting] = false
155
+ end
156
+
157
+ def symbolize_keys?(opts)
158
+ opts.fetch(:symbolize_keys, @opts&.dig(:symbolize_keys))
159
+ end
160
+ end