janeway-jsonpath 0.5.0 → 0.6.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: 210bad818f98217873f0055d2ed46432b2b56087442e371671c22f00e51ff0f8
4
- data.tar.gz: 1a2fd7d80573323fb3cb6c6b8c28ddf5182585bf05cd2540816d29faf637ed87
3
+ metadata.gz: 33fc9254c0443121404ced6b9e60c15133111299b8f7052f012fe45d7ad4d281
4
+ data.tar.gz: c5a9eea01a8a332343b47328c64991a52fc50292969247433650914c8af727bd
5
5
  SHA512:
6
- metadata.gz: 35c927af0528d849acf86691fb7b2fa0ec16d69ce6819ad37f231b3918394f9459ceb7ec4340cb65cf4feb48792c251317af111072bb8e23f8cc2d8da6407cf1
7
- data.tar.gz: 22337b8703744e1428632635dcfb1eb25c57042d069b37ecf0ee0b13b70554d2b5fd1c3bc59af75f4ec0d4a23876e8aeb1421bc35c82ed8b652c0ac3ff0baf4a
6
+ metadata.gz: 0c101d0e02380fc56bd4abc83b5012965e3281aabc3bcce10e7f55c6cff61ba5b825f0d582f95c076234e01d59f07a8db19e6b8984c85e923c780d6f362adc2c
7
+ data.tar.gz: e2a762c773653c01415dee8f988b95be53d45da8bb3d1ed6f20817eb333fce00b3c960f9268a2212c929a27582d4c0cdb8e480e3948b2e193974f452b32e526f
data/README.md CHANGED
@@ -126,7 +126,7 @@ Returns all values that match the query.
126
126
  # Returns every book in the store cheaper than $10
127
127
  ```
128
128
 
129
- Alternatively, compile the query once, and share it between threads or ractors with different data sources:
129
+ Alternatively, parse the query once, and share it between threads or ractors with different data sources:
130
130
 
131
131
  ```ruby
132
132
  # Create ractors with their own data sources
@@ -140,7 +140,7 @@ Alternatively, compile the query once, and share it between threads or ractors w
140
140
  end
141
141
 
142
142
  # Construct JSONPath query object and send it to each ractor
143
- query = Janeway.compile('$..book[? @.price<10]')
143
+ query = Janeway.parse('$..book[? @.price<10]')
144
144
  ractors.each { |ractor| ractor.send(query).take }
145
145
  ```
146
146
 
@@ -192,7 +192,7 @@ For example, iterating over an array may yield index values 1, 2 and 3.
192
192
  Deleting the value at index 1 would change the index for the remaining values.
193
193
  Next iteration might yield index 2, but since 1 was deleted it is now really at index 1.
194
194
 
195
- To avoid having to deal with such problems, use the built in `#delete` method below.
195
+ To avoid having to deal with such problems, use the `#delete` method instead.
196
196
 
197
197
 
198
198
  Lastly, the `#each` iterator's fourth yield parameter is the [normalized path](https://www.rfc-editor.org/rfc/rfc9535.html#name-normalized-paths) to the matched value.
@@ -205,8 +205,10 @@ This is a jsonpath query string that uniquely points to the matched value.
205
205
  paths << path
206
206
  end
207
207
  paths
208
- # [ # "$['birds']", "$['dogs']", "$['birds'][0]", "$['birds'][1]", "$['birds'][2]",
209
- # "$['dogs'][0]", "$['dogs'][1]", "$['dogs'][2]"]
208
+ # [
209
+ # "$['birds']", "$['dogs']", "$['birds'][0]", "$['birds'][1]",
210
+ # "$['birds'][2]", "$['dogs'][0]", "$['dogs'][1]", "$['dogs'][2]"
211
+ # ]
210
212
  ```
211
213
 
212
214
  ##### #delete
@@ -222,6 +224,82 @@ The `#delete` method deletes matched values from the input.
222
224
  # dog list is now []
223
225
  ```
224
226
 
227
+ ##### #delete_if
228
+
229
+ The `#delete_if` method yields matched values to a block, and deletes them if the block returns a truthy result.
230
+ This allows values to be deleted based on conditions that can't be tested by a JSONPath query:
231
+ ```ruby
232
+ # delete any book from the store json data that is not in the database
233
+ Janeway.enum_for('$.store.book.*', data).delete_if do |book|
234
+ results = db.query('SELECT * FROM books WHERE author=? AND title=?', book['author'], book['title'])
235
+ results.size == 0
236
+ end
237
+ ```
238
+
239
+ ##### #replace
240
+
241
+ Replaces every value matched by the query with the given value.
242
+ The replacement does not need to be the same type (eg. you can replace a string value with Hash or nil.)
243
+
244
+ Alternatively, provide a block which receives the value and returns a replacement value.
245
+
246
+ ```ruby
247
+ # Set every price to nil
248
+ Janeway.enum_for('$..price', data).replace(nil)
249
+
250
+ # Suppose the prices were serialized as strings, eg. "price" => "9.99"
251
+ # Convert them to floating point values:
252
+ Janeway.enum_for('$..price', data).replace { |price| price.to_f }
253
+
254
+ # Same thing, but using more terse ruby:
255
+ Janeway.enum_for('$..price', data).replace(&:to_f)
256
+
257
+ # Convert price to hash:
258
+ Janeway.enum_for('$..price', data).replace do |price|
259
+ {
260
+ 'price' => price.to_f,
261
+ 'currency' => 'CAD',
262
+ }
263
+ end
264
+ ```
265
+
266
+ ##### #insert
267
+
268
+ Adds the given value to the input data, at the place specified by the JSONPath query.
269
+
270
+ The query must be a singular query, meaning it can only be made of of name selectors (hash keys) and index selectors (array indexes.)
271
+ The normalized paths which are yielded to `#each` are usable here.
272
+ Examples of singular queries:
273
+ ```
274
+ $.store.book.0.price
275
+ $['store']['book'][0]
276
+ ```
277
+ Examples of queries that are valid JSONPath but are not useable here:
278
+ ```
279
+ $.store.book.*
280
+ $.store.book[1:2]
281
+ $.store.book[? @.price >= 8.99]
282
+ ```
283
+
284
+ Additionally, some other restrictions apply:
285
+ * The "parent" node must exist, eg. for `$.a[1].name`, the path `$.a[1]` must exist and be a Hash
286
+ * Cannot create array index `n` unless the array contains exactly `n-1` elements
287
+ * If query path already exists, the block is called if provided. Otherwise an exception is raised.
288
+
289
+ Here is an example of adding a new book to the store:
290
+ ```ruby
291
+ # Add a new book to the store
292
+ book_count = data['store']['book'].size
293
+ Janeway.enum_for("$.store.book[#{book_count}]", data).insert do
294
+ { "category": "fiction",
295
+ "author": "Kameron Hurley",
296
+ "title": "The Light Brigade",
297
+ "price": 33.11
298
+ }
299
+ end
300
+ ```
301
+
302
+ #### Ruby Enumerable module methods
225
303
 
226
304
  The `Janeway.enum_for` and `Janeway::Query#enum_for` methods return an enumerator, so you can use the usual ruby enumerator methods, such as:
227
305
 
data/bin/janeway CHANGED
@@ -15,25 +15,30 @@ HELP = <<~HELP_TEXT.freeze
15
15
  #{SCRIPT_NAME} [QUERY] [FILENAME]
16
16
 
17
17
  Purpose:
18
- Print the result of applying a JSONPath query to a JSON input.
18
+ Print values from the input data that match the given JSONPath query.
19
+
20
+ Alternatively, use one of the action flags to modify values from the input JSON specified by the query.
19
21
 
20
22
  QUERY is a JSONPath query. Quote it with single quotes to avoid shell errors.
21
23
 
22
- FILENAME is the path to a JSON file to use as input.
23
- Alternately, input JSON can be provided on STDIN.
24
- If input is not provided then the query is just checked for correctness.
24
+ FILENAME is the path to a JSON file to use as input data.
25
+ Alternately, JSON can be provided on STDIN.
26
+ If data is not provided then the query is just checked for correctness.
25
27
 
26
28
  For an introduction to JSONPath, see https://goessner.net/articles/JsonPath/
27
29
  For the complete reference, see https://www.rfc-editor.org/info/rfc9535
28
30
 
29
31
  Examples:
30
- #{SCRIPT_NAME} '$.store.book[*].author' input.json
31
- cat input.json | #{SCRIPT_NAME} '$.store.book[*].author'
32
+ #{SCRIPT_NAME} '$.store.book[*].author' data.json
33
+ cat data.json | #{SCRIPT_NAME} '$.store.book[*].author'
32
34
 
33
35
  HELP_TEXT
34
36
 
35
37
  # Command-line options
36
- Options = Struct.new(:query, :query_file, :input, :compact_output, :delete, :verbose)
38
+ Options = Struct.new(
39
+ :query, :query_file, :action, :value, :value_given, :data,
40
+ :compact_output, :verbose, keyword_init: true
41
+ )
37
42
 
38
43
  # Parse the command-line arguments.
39
44
  # This includes both bare words and hyphenated options.
@@ -44,9 +49,12 @@ def parse_args(argv)
44
49
  argv << '--help' if argv.empty?
45
50
  options = parse_options(argv)
46
51
 
47
- # Next get jsonpath query and input jsonn
52
+ # Get jsonpath query and input json
48
53
  options.query = read_query(options.query_file, argv)
49
- options.input = read_input(argv.first)
54
+ options.data = read_input(argv.first)
55
+ if %i[insert replace].include?(options.action) && !options.value_given
56
+ abort("Need value for #{options.action}, use -v or -V")
57
+ end
50
58
  options
51
59
  end
52
60
 
@@ -54,14 +62,29 @@ end
54
62
  #
55
63
  # @param argv [Array<String>]
56
64
  def parse_options(argv)
57
- options = Options.new
65
+ options = Options.new(action: :search)
58
66
  op = OptionParser.new do |opts|
59
67
  opts.banner = HELP
60
68
  opts.separator('Options:')
61
69
 
62
70
  opts.on('-q', '--query FILE', 'Read jsonpath query from file') { |o| options.query_file = o }
63
- opts.on('-c', '--compact', 'Express result in compact json format') { options.compact_output = true }
64
- opts.on('-d', '--delete', 'Print the input, with matching values deleted') { options.delete = true }
71
+ opts.on('-c', '--compact', 'Print result in compact json format') { options.compact_output = true }
72
+ opts.on('-v', '--value STRING', 'VALUE for insert or replace (str/number/null/true/false)') do |o|
73
+ options.value = read_value(o)
74
+ options.value_given = true # must track this separately since value may be false / nil
75
+ end
76
+ desc =
77
+ 'JSON file containing VALUE for insert or replace ' \
78
+ '(bare literals are valid JSON, eg. 9, "string", null)'
79
+ opts.on('-V', '--value-file FILENAME', desc) do |o|
80
+ options.value = read_value_file(o)
81
+ options.value_given = true
82
+ end
83
+ opts.separator('Actions:')
84
+ opts.on('-d', '--delete', 'Print data, with matches deleted') { options.action = :delete }
85
+ opts.on('-r', '--replace', 'Print data, with matches replaced by VALUE') { options.action = :replace }
86
+ opts.on('-i', '--insert', 'Print data, with VALUE inserted at query location') { options.action = :insert }
87
+ opts.on('-p', '--paths', 'List normalized path for each match') { options.action = :paths }
65
88
  opts.on('--version', 'Show version number') { abort(Janeway::VERSION) }
66
89
  opts.on('-h', '--help', 'Show this help message') { abort(opts.to_s) }
67
90
  end
@@ -99,43 +122,69 @@ def read_input(path)
99
122
  parse_json(json)
100
123
  end
101
124
 
125
+ # Read insert/replacement value from file
126
+ # @return [Array,Hash,String,Numeric,nil]
127
+ def read_value_file(path)
128
+ abort("File not readable: #{path}") unless File.exist?(path)
129
+
130
+ parse_json File.read(path)
131
+ end
132
+
133
+ # Read insert/replacement value from command-line argument
134
+ # @return [Integer, Float, String]
135
+ def read_value(value_str)
136
+ case value_str
137
+ when 'null' then nil
138
+ when 'true' then true
139
+ when 'false' then false
140
+ when /^\d+$/ then value_str.to_i # integer
141
+ when /^\d+\.\d+$/ then value_str.to_f # float
142
+ else value_str # string
143
+ end
144
+ end
145
+
102
146
  # Parse JSON, and abort if it is invalid
103
147
  # @param json [String]
104
148
  # @return [Hash, Array] un-serialized json, as ruby objects
105
149
  def parse_json(json)
106
150
  JSON.parse(json)
107
151
  rescue JSON::JSONError => e
108
- # JSON error messages may include the entire input, so limit how much is printed.
152
+ # JSON error messages may include the entire data, so limit how much is printed.
109
153
  msg = e.message[0..256]
110
154
  msg += "\n..." if e.message.length > 256
111
- abort "Input is not valid JSON: #{msg}"
155
+ abort "Input data is not valid JSON: #{msg}"
112
156
  end
113
157
 
114
158
  # Just pares the query ane then exit.
115
159
  # Useful for testing whether a query is valid.
116
160
  # @param query [String] jsonpath
117
161
  def parse_query_and_exit(query)
118
- Janeway.compile(query)
162
+ Janeway.parse(query)
119
163
  puts 'Query is valid. Provide input json to run the query.'
120
164
  exit(0)
121
165
  end
122
166
 
123
167
  # @param options [Options]
124
168
  def main(options)
125
- parse_query_and_exit(options.query) unless options.input
126
-
127
- enum = Janeway.enum_for(options.query, options.input)
128
- if options.delete
129
- results = enum.delete
130
- if options.compact_output
131
- puts JSON.generate(options.input)
169
+ parse_query_and_exit(options.query) unless options.data
170
+
171
+ enum = Janeway.enum_for(options.query, options.data)
172
+ results =
173
+ case options.action
174
+ when :search then enum.search
175
+ when :paths then enum.map { |_, _, _, path| path }
176
+ when :replace
177
+ enum.replace(options.value)
178
+ options.data
179
+ when :insert
180
+ enum.insert(options.value)
181
+ options.data
182
+ when :delete
183
+ enum.delete
184
+ options.data
132
185
  else
133
- puts JSON.pretty_generate(options.input)
186
+ raise "Unknown action: #{options.action}"
134
187
  end
135
- exit(0)
136
- else
137
- results = enum.search
138
- end
139
188
 
140
189
  if options.compact_output
141
190
  puts JSON.generate(results)
@@ -149,7 +198,7 @@ begin
149
198
  main(options)
150
199
  rescue Janeway::Error => e
151
200
  warn "Error: #{e.message}\nQuery: #{e.query}\n"
152
- warn " #{' ' * e.location.col}^" if e.location
201
+ warn " #{' ' * e.location.col}^" if e.location # point to the character at the location index
153
202
  exit(1)
154
203
  rescue Interrupt, Errno::EPIPE
155
204
  abort("\n")
@@ -2,16 +2,17 @@
2
2
 
3
3
  require 'forwardable'
4
4
 
5
- # A set of selectors within brackets, as a comma-separated list.
6
- # https://www.rfc-editor.org/rfc/rfc9535.html#child-segment
7
- #
8
- # @example
9
- # $[*, *]
10
- # $[1, 2, 3]
11
- # $[name1, [1:10]]
12
5
  module Janeway
13
6
  module AST
14
7
  # Represent a union of 2 or more selectors.
8
+ #
9
+ # A set of selectors within brackets, as a comma-separated list.
10
+ # https://www.rfc-editor.org/rfc/rfc9535.html#child-segment
11
+ #
12
+ # @example
13
+ # $[*, *]
14
+ # $[1, 2, 3]
15
+ # $[name1, [1:10]]
15
16
  class ChildSegment < Janeway::AST::Expression
16
17
  extend Forwardable
17
18
  def_delegators :@value, :size, :first, :last, :each, :map, :empty?
@@ -2,8 +2,6 @@
2
2
 
3
3
  require_relative 'selector'
4
4
 
5
- # https://datatracker.ietf.org/doc/rfc9535/
6
-
7
5
  module Janeway
8
6
  module AST
9
7
  # Filter selectors are used to iterate over the elements or members of
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'expression'
4
+
3
5
  module Janeway
4
6
  module AST
5
7
  # Represents a JSONPath built-in function.
@@ -2,27 +2,26 @@
2
2
 
3
3
  require_relative 'expression'
4
4
 
5
- # https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base/blob/main/draft-ietf-jsonpath-base.md#selectors
6
- # Selectors:
7
- #
8
- # A name selector, e.g. 'name', selects a named child of an object.
9
- #
10
- # An index selector, e.g. 3, selects an indexed child of an array.
11
- #
12
- # A wildcard * ({{wildcard-selector}}) in the expression [*] selects all
13
- # children of a node and in the expression ..[*] selects all descendants of a
14
- # node.
15
- #
16
- # An array slice start:end:step ({{slice}}) selects a series of elements from
17
- # an array, giving a start position, an end position, and an optional step
18
- # value that moves the position from the start to the end.
19
- #
20
- # Filter expressions ?<logical-expr> select certain children of an object or array, as in:
21
- #
22
- # $.store.book[?@.price < 10].title
23
5
  module Janeway
24
6
  module AST
25
7
  # Represent a selector, which is an expression that filters nodes from a list based on a predicate.
8
+ #
9
+ # https://www.rfc-editor.org/rfc/rfc9535.html#name-selectors
10
+ # Selectors:
11
+ #
12
+ # A name selector, e.g. 'name', selects a named child of an object.
13
+ #
14
+ # An index selector, e.g. 3, selects an indexed child of an array.
15
+ #
16
+ # A wildcard * in the expression [*] selects all children of a node and in
17
+ # the expression ..[*] selects all descendants of a node.
18
+ #
19
+ # An array slice start:end:step selects a series of elements from an array,
20
+ # giving a start position, an end position, and an optional step value that
21
+ # moves the position from the start to the end.
22
+ #
23
+ # Filter expressions ?<logical-expr> select certain children of an object or array, as in:
24
+ # $.store.book[?@.price < 10].title
26
25
  class Selector < Janeway::AST::Expression
27
26
  # Subsequent expression that modifies the result of this selector list.
28
27
  attr_accessor :next
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Janeway
4
+ # Abstract Syntax Tree
5
+ module AST
6
+ # These are the limits of what javascript's Number type can represent
7
+ INTEGER_MIN = -9_007_199_254_740_991
8
+ INTEGER_MAX = 9_007_199_254_740_991
9
+ end
10
+ end
11
+
12
+ require_relative 'ast/array_slice_selector'
13
+ require_relative 'ast/binary_operator'
14
+ require_relative 'ast/boolean'
15
+ require_relative 'ast/child_segment'
16
+ require_relative 'ast/current_node'
17
+ require_relative 'ast/descendant_segment'
18
+ require_relative 'ast/error'
19
+ require_relative 'ast/expression'
20
+ require_relative 'ast/filter_selector'
21
+ require_relative 'ast/function'
22
+ require_relative 'ast/helpers'
23
+ require_relative 'ast/index_selector'
24
+ require_relative 'ast/name_selector'
25
+ require_relative 'ast/null'
26
+ require_relative 'ast/number'
27
+ require_relative 'ast/root_node'
28
+ require_relative 'ast/selector'
29
+ require_relative 'ast/string_type'
30
+ require_relative 'ast/unary_operator'
31
+ require_relative 'ast/wildcard_selector'
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'interpreter'
4
+
3
5
  module Janeway
4
- # Enumerator combines a parsed JSONpath query with input.
5
- # It provides enumerator methods.
6
+ # Enumerator combines a JSONPath query with input.
7
+ # It provides methods for running queries on the input and enumerating over the results.
6
8
  class Enumerator
7
9
  include Enumerable
8
10
 
9
- # @param query [Janeway::Query] @param input [Array, Hash]
11
+ # @return [Janeway::Query]
12
+ attr_reader :query
13
+
14
+ # @param query [Janeway::Query]
15
+ # @param input [Array, Hash]
10
16
  def initialize(query, input)
11
17
  @query = query
12
18
  @input = input
@@ -34,10 +40,151 @@ module Janeway
34
40
  Janeway::Interpreter.new(@query, as: :iterator, &block).interpret(@input)
35
41
  end
36
42
 
37
- # Delete each value matched by the JSONPath query.
38
- # @return [Array, Hash]
43
+ # Delete values from the input that are matched by the JSONPath query.
44
+ # @return [Array] deleted values
39
45
  def delete
40
46
  Janeway::Interpreter.new(@query, as: :deleter).interpret(@input)
41
47
  end
48
+
49
+ # Delete values from the input that are matched by the JSONPath query and also return a truthy value
50
+ # from the block.
51
+ # @return [Array] deleted values
52
+ def delete_if(&block)
53
+ Janeway::Interpreter.new(@query, as: :delete_if, &block).interpret(@input)
54
+ end
55
+
56
+ # Assign the given value at every query match.
57
+ # @param replacement [Object]
58
+ # @return [void]
59
+ def replace(replacement = :no_replacement_value_was_given, &block)
60
+ if replacement != :no_replacement_value_was_given && block_given?
61
+ raise Janeway::Error.new('#replace needs either replacement value or block, not both', @query)
62
+ end
63
+
64
+ # Avoid infinite loop when the replacement data would also be matched by the query
65
+ previous = []
66
+ each do |_, parent, key|
67
+ # Update the previous match
68
+ unless previous.empty?
69
+ prev_parent, prev_key = *previous
70
+ prev_parent[prev_key] = block_given? ? yield(prev_parent[prev_key]) : replacement
71
+ end
72
+
73
+ # Record this match to be updated later
74
+ previous = [parent, key]
75
+ end
76
+ return if previous.empty?
77
+
78
+ # Update the final match
79
+ prev_parent, prev_key = *previous
80
+ prev_parent[prev_key] = block_given? ? yield(prev_parent[prev_key]) : replacement
81
+ nil
82
+ end
83
+
84
+ # Insert `value` into the input at a location specified by a singular query.
85
+ #
86
+ # This has restrictions:
87
+ # * Only for singular queries
88
+ # (no wildcards, filter expressions, child segments, etc.)
89
+ # * The "parent" node must exist
90
+ # eg. for $.a[1].name, the path $.a[1] must exist and be a Hash
91
+ # * Cannot create array index N unless the array
92
+ # already has exactly N-1 elements
93
+ # * If query path already exists, the block is called if provided.
94
+ # Otherwise an exception is raised.
95
+ #
96
+ # Optionally, pass in a block to be called when there is already a value at the
97
+ # specified array index / hash key. The parent object and the array index
98
+ # or hash key will be yielded to the block.
99
+ #
100
+ # @yieldparam [Array, Hash] parent object
101
+ # @yieldparam [Intgeer, String] array index or hash key
102
+ def insert(value, &block)
103
+ # Query must point to a single target object
104
+ unless @query.singular_query?
105
+ msg = 'Janeway::Query#insert may only be used with a singular query'
106
+ raise Janeway::Error.new(msg, @query)
107
+ end
108
+
109
+ # Find parent of new value
110
+ parent, key, parent_path = find_parent
111
+
112
+ # Insert new value into parent
113
+ case parent
114
+ when Hash then insert_into_hash(parent, key, value, parent_path, &block)
115
+ when Array then insert_into_array(parent, key, value, parent_path, &block)
116
+ else
117
+ raise Error.new("cannot insert into basic type: #{parent.inspect}", @query.to_s)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ # Find the 'parent' of the object pointed to by the query. For singular query only.
124
+ #
125
+ # @!visibility private
126
+ # @return [Object, Object, String] parent object (Hash / Array), key/index (String / Integer), path to parent
127
+ def find_parent
128
+ # Make a Query that points to the target's parent
129
+ parent_query = @query.dup
130
+ selector = parent_query.pop # returns a name or index selector
131
+
132
+ # Find parent element, give up if parent does not exist
133
+ results = Interpreter.new(parent_query).interpret(@input)
134
+ case results.size
135
+ when 0 then raise "no parent found for #{@query}, cannot insert value"
136
+ when 1 then parent = results.first
137
+ else raise "query #{parent_query} matched multiple elements!" # not possible for singular query
138
+ end
139
+
140
+ [parent, selector.value, parent_query.to_s]
141
+ end
142
+
143
+ # Insert value into hash at the given key
144
+ #
145
+ # @!visibility private
146
+ # @param hash [Hash]
147
+ # @param key [String]
148
+ # @param value [Object]
149
+ # @param path [String] jsonpath singular query to parent
150
+ # @yieldparam [Hash] parent object
151
+ # @yieldparam [String] hash key
152
+ def insert_into_hash(hash, key, value, path, &block)
153
+ unless key.is_a?(String) || key.is_a?(Symbol)
154
+ raise Error.new("cannot use #{key.inspect} as hash key", @query.to_s)
155
+ end
156
+
157
+ if hash.key?(key)
158
+ raise Error.new("hash at #{path} already has key #{key.inspect}", @query.to_s) unless block_given?
159
+
160
+ yield hash, key
161
+ end
162
+
163
+ hash[key] = value
164
+ end
165
+
166
+ # Insert value into array at the given index
167
+ #
168
+ # @!visibility private
169
+ # @param array [Array]
170
+ # @param index [Integer]
171
+ # @param value [Object]
172
+ # @param path [String] jsonpath singular query to parent
173
+ # @yieldparam [Array] parent object
174
+ # @yieldparam [Integer] array index
175
+ def insert_into_array(array, index, value, path, &block)
176
+ raise Error.new("cannot use #{index.inspect} as array index", @query.to_s) unless index.is_a?(Integer)
177
+
178
+ if index < array.size
179
+ raise Error.new("array at #{path} already has index #{index}", @query.to_s) unless block_given?
180
+
181
+ yield array, index
182
+ end
183
+ if index > array.size
184
+ raise Error.new("cannot add index #{index} because array at #{path} is too small", @query.to_s)
185
+ end
186
+
187
+ array << value
188
+ end
42
189
  end
43
190
  end
@@ -11,6 +11,7 @@ module Janeway
11
11
  # It should be created for a single query and then discarded.
12
12
  class Interpreter
13
13
  attr_reader :jsonpath, :output
14
+
14
15
  include Interpreters
15
16
 
16
17
  # Interpret a query on the given input, return result
@@ -25,7 +26,7 @@ module Janeway
25
26
  # @param query [Query] abstract syntax tree of the jsonpath query
26
27
  def initialize(query, as: :finder, &block)
27
28
  raise ArgumentError, "expect Query, got #{query.inspect}" unless query.is_a?(Query)
28
- unless %i[finder iterator deleter].include?(as)
29
+ unless %i[finder iterator deleter delete_if].include?(as)
29
30
  raise ArgumentError, "invalid interpreter type: #{as.inspect}"
30
31
  end
31
32
 
@@ -80,6 +81,7 @@ module Janeway
80
81
  case @type
81
82
  when :iterator then interpreters.push(Yielder.new(&block))
82
83
  when :deleter then interpreters.push make_deleter(interpreters.pop)
84
+ when :delete_if then interpreters.push make_delete_if(interpreters.pop, &block)
83
85
  end
84
86
 
85
87
  # Link interpreters together
@@ -114,6 +116,14 @@ module Janeway
114
116
  TreeConstructor.ast_node_to_deleter(interpreter.node)
115
117
  end
116
118
 
119
+ # Make a DeleteIf that will delete the results matched by a Selector,
120
+ # after yielding to a block for approval.
121
+ #
122
+ # @param interpreter [Interpreters::Base] interpeter subclass
123
+ def make_delete_if(interpreter, &block)
124
+ TreeConstructor.ast_node_to_delete_if(interpreter.node, &block)
125
+ end
126
+
117
127
  # Return an Interpreter::Error with the specified message, include the query.
118
128
  #
119
129
  # @param msg [String] error message