shared_tools 0.3.0 → 0.3.1

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,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm/tool'
4
+ require 'time'
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # A tool for parsing, validating, and explaining cron expressions.
9
+ # Supports standard 5-field cron format (minute, hour, day of month, month, day of week).
10
+ #
11
+ # @example
12
+ # tool = SharedTools::Tools::CronTool.new
13
+ # result = tool.execute(action: 'parse', expression: '0 9 * * 1-5')
14
+ # puts result[:description] # "At 09:00, Monday through Friday"
15
+ class CronTool < RubyLLM::Tool
16
+ def self.name = 'cron'
17
+
18
+ description <<~'DESCRIPTION'
19
+ Parse, validate, and explain cron expressions.
20
+
21
+ Supports standard 5-field cron format:
22
+ - minute (0-59)
23
+ - hour (0-23)
24
+ - day of month (1-31)
25
+ - month (1-12 or JAN-DEC)
26
+ - day of week (0-7 or SUN-SAT, where 0 and 7 are Sunday)
27
+
28
+ Special characters supported:
29
+ - * (any value)
30
+ - , (value list separator)
31
+ - - (range of values)
32
+ - / (step values)
33
+
34
+ Actions:
35
+ - 'parse': Parse and explain a cron expression
36
+ - 'validate': Check if a cron expression is valid
37
+ - 'next': Calculate the next N execution times
38
+ - 'generate': Generate a cron expression from a description
39
+
40
+ Example usage:
41
+ tool = SharedTools::Tools::CronTool.new
42
+
43
+ # Parse and explain
44
+ tool.execute(action: 'parse', expression: '0 9 * * 1-5')
45
+ # => "At 09:00, Monday through Friday"
46
+
47
+ # Validate
48
+ tool.execute(action: 'validate', expression: '0 9 * * *')
49
+ # => { valid: true }
50
+
51
+ # Get next execution times
52
+ tool.execute(action: 'next', expression: '0 * * * *', count: 5)
53
+
54
+ # Generate from description
55
+ tool.execute(action: 'generate', description: 'every day at 9am')
56
+ DESCRIPTION
57
+
58
+ params do
59
+ string :action, description: <<~DESC.strip
60
+ The action to perform:
61
+ - 'parse': Parse and explain a cron expression
62
+ - 'validate': Check if expression is valid
63
+ - 'next': Calculate next execution times
64
+ - 'generate': Generate expression from description
65
+ DESC
66
+
67
+ string :expression, description: <<~DESC.strip, required: false
68
+ The cron expression to parse, validate, or calculate.
69
+ Required for 'parse', 'validate', and 'next' actions.
70
+ DESC
71
+
72
+ string :description, description: <<~DESC.strip, required: false
73
+ Human-readable schedule description for 'generate' action.
74
+ Examples: 'every day at 9am', 'every monday at noon', 'every 5 minutes'
75
+ DESC
76
+
77
+ integer :count, description: <<~DESC.strip, required: false
78
+ Number of next execution times to return for 'next' action.
79
+ Default: 5, Maximum: 20
80
+ DESC
81
+ end
82
+
83
+ DAYS_OF_WEEK = {
84
+ 'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
85
+ 'THU' => 4, 'FRI' => 5, 'SAT' => 6
86
+ }.freeze
87
+
88
+ MONTHS = {
89
+ 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
90
+ 'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
91
+ 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
92
+ }.freeze
93
+
94
+ # @param logger [Logger] optional logger
95
+ def initialize(logger: nil)
96
+ @logger = logger || RubyLLM.logger
97
+ end
98
+
99
+ # Execute cron action
100
+ #
101
+ # @param action [String] action to perform
102
+ # @param expression [String, nil] cron expression
103
+ # @param description [String, nil] schedule description for generate
104
+ # @param count [Integer, nil] number of next executions
105
+ # @return [Hash] result
106
+ def execute(action:, expression: nil, description: nil, count: nil)
107
+ @logger.info("CronTool#execute action=#{action.inspect}")
108
+
109
+ case action.to_s.downcase
110
+ when 'parse'
111
+ parse_expression(expression)
112
+ when 'validate'
113
+ validate_expression(expression)
114
+ when 'next'
115
+ next_executions(expression, count || 5)
116
+ when 'generate'
117
+ generate_expression(description)
118
+ else
119
+ {
120
+ success: false,
121
+ error: "Unknown action: #{action}. Valid actions are: parse, validate, next, generate"
122
+ }
123
+ end
124
+ rescue => e
125
+ @logger.error("CronTool error: #{e.message}")
126
+ {
127
+ success: false,
128
+ error: e.message
129
+ }
130
+ end
131
+
132
+ private
133
+
134
+ def parse_expression(expression)
135
+ return { success: false, error: "Expression is required" } if expression.nil? || expression.empty?
136
+
137
+ validation = validate_expression(expression)
138
+ return validation unless validation[:valid]
139
+
140
+ parts = expression.strip.split(/\s+/)
141
+ minute, hour, dom, month, dow = parts
142
+
143
+ {
144
+ success: true,
145
+ expression: expression,
146
+ fields: {
147
+ minute: minute,
148
+ hour: hour,
149
+ day_of_month: dom,
150
+ month: month,
151
+ day_of_week: dow
152
+ },
153
+ description: build_description(minute, hour, dom, month, dow),
154
+ expanded: {
155
+ minutes: expand_field(minute, 0, 59),
156
+ hours: expand_field(hour, 0, 23),
157
+ days_of_month: expand_field(dom, 1, 31),
158
+ months: expand_field(month, 1, 12),
159
+ days_of_week: expand_field(dow, 0, 6)
160
+ }
161
+ }
162
+ end
163
+
164
+ def validate_expression(expression)
165
+ return { valid: false, error: "Expression is required" } if expression.nil? || expression.empty?
166
+
167
+ parts = expression.strip.split(/\s+/)
168
+
169
+ unless parts.length == 5
170
+ return {
171
+ valid: false,
172
+ error: "Invalid cron expression: expected 5 fields, got #{parts.length}"
173
+ }
174
+ end
175
+
176
+ minute, hour, dom, month, dow = parts
177
+
178
+ errors = []
179
+ errors << validate_field(minute, 0, 59, 'minute')
180
+ errors << validate_field(hour, 0, 23, 'hour')
181
+ errors << validate_field(dom, 1, 31, 'day of month')
182
+ errors << validate_field(month, 1, 12, 'month')
183
+ errors << validate_field(dow, 0, 7, 'day of week')
184
+
185
+ errors.compact!
186
+
187
+ if errors.empty?
188
+ { valid: true, expression: expression }
189
+ else
190
+ { valid: false, errors: errors }
191
+ end
192
+ end
193
+
194
+ def next_executions(expression, count)
195
+ return { success: false, error: "Expression is required" } if expression.nil? || expression.empty?
196
+
197
+ validation = validate_expression(expression)
198
+ return { success: false, error: validation[:errors]&.join(', ') || validation[:error] } unless validation[:valid]
199
+
200
+ count = [[count.to_i, 1].max, 20].min
201
+
202
+ parts = expression.strip.split(/\s+/)
203
+ minute_spec, hour_spec, dom_spec, month_spec, dow_spec = parts
204
+
205
+ minutes = expand_field(minute_spec, 0, 59)
206
+ hours = expand_field(hour_spec, 0, 23)
207
+ doms = expand_field(dom_spec, 1, 31)
208
+ months = expand_field(month_spec, 1, 12)
209
+ dows = expand_field(dow_spec, 0, 6)
210
+
211
+ executions = []
212
+ current = Time.now + 60 # Start from next minute
213
+
214
+ max_iterations = 366 * 24 * 60 # One year of minutes max
215
+ iterations = 0
216
+
217
+ while executions.length < count && iterations < max_iterations
218
+ iterations += 1
219
+
220
+ if matches_cron?(current, minutes, hours, doms, months, dows)
221
+ executions << current.strftime('%Y-%m-%d %H:%M')
222
+ current += 60
223
+ else
224
+ current += 60
225
+ end
226
+ end
227
+
228
+ {
229
+ success: true,
230
+ expression: expression,
231
+ count: executions.length,
232
+ next_executions: executions
233
+ }
234
+ end
235
+
236
+ def generate_expression(description)
237
+ return { success: false, error: "Description is required" } if description.nil? || description.empty?
238
+
239
+ desc = description.downcase.strip
240
+ expression = nil
241
+
242
+ # Common patterns - more specific patterns must come before general ones
243
+ case desc
244
+ when /every\s+minute/
245
+ expression = '* * * * *'
246
+ when /every\s+(\d+)\s+minutes?/
247
+ interval = $1.to_i
248
+ expression = "*/#{interval} * * * *"
249
+ when /every\s+hour/
250
+ expression = '0 * * * *'
251
+ when /every\s+(\d+)\s+hours?/
252
+ interval = $1.to_i
253
+ expression = "0 */#{interval} * * *"
254
+ when /every\s+day\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
255
+ hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
256
+ hour += 12 if ampm == 'pm' && hour != 12
257
+ hour = 0 if ampm == 'am' && hour == 12
258
+ expression = "#{min} #{hour} * * *"
259
+ when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
260
+ day_name = $1.upcase[0..2]
261
+ day_num = DAYS_OF_WEEK[day_name]
262
+ hour, min, ampm = $2.to_i, ($3 || '0').to_i, $4
263
+ hour += 12 if ampm == 'pm' && hour != 12
264
+ hour = 0 if ampm == 'am' && hour == 12
265
+ expression = "#{min} #{hour} * * #{day_num}"
266
+ when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/
267
+ day_name = $1.upcase[0..2]
268
+ day_num = DAYS_OF_WEEK[day_name]
269
+ expression = "0 0 * * #{day_num}"
270
+ when /weekdays?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
271
+ hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
272
+ hour += 12 if ampm == 'pm' && hour != 12
273
+ hour = 0 if ampm == 'am' && hour == 12
274
+ expression = "#{min} #{hour} * * 1-5"
275
+ when /weekends?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
276
+ hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
277
+ hour += 12 if ampm == 'pm' && hour != 12
278
+ hour = 0 if ampm == 'am' && hour == 12
279
+ expression = "#{min} #{hour} * * 0,6"
280
+ when /at\s+noon/
281
+ expression = '0 12 * * *'
282
+ when /at\s+midnight/
283
+ expression = '0 0 * * *'
284
+ when /monthly|first\s+of\s+(the\s+)?month/
285
+ expression = '0 0 1 * *'
286
+ when /yearly|annually/
287
+ expression = '0 0 1 1 *'
288
+ when /hourly\s+at\s+:?(\d{2})/
289
+ min = $1.to_i
290
+ expression = "#{min} * * * *"
291
+ end
292
+
293
+ if expression
294
+ parsed = parse_expression(expression)
295
+ {
296
+ success: true,
297
+ description: description,
298
+ expression: expression,
299
+ explanation: parsed[:description]
300
+ }
301
+ else
302
+ {
303
+ success: false,
304
+ error: "Could not parse description: '#{description}'. Try formats like 'every day at 9am', 'every monday at noon', 'every 5 minutes'"
305
+ }
306
+ end
307
+ end
308
+
309
+ def validate_field(field, min, max, name)
310
+ return nil if field == '*'
311
+
312
+ # Handle step values
313
+ if field.include?('/')
314
+ base, step = field.split('/')
315
+ return "Invalid step value in #{name}" unless step =~ /^\d+$/ && step.to_i > 0
316
+ return validate_field(base, min, max, name) unless base == '*'
317
+ return nil
318
+ end
319
+
320
+ # Handle ranges
321
+ if field.include?('-')
322
+ parts = field.split('-')
323
+ return "Invalid range in #{name}" unless parts.length == 2
324
+ start_val = normalize_value(parts[0], name)
325
+ end_val = normalize_value(parts[1], name)
326
+ return "Invalid range values in #{name}" if start_val.nil? || end_val.nil?
327
+ return "Range out of bounds in #{name}" if start_val < min || end_val > max || start_val > end_val
328
+ return nil
329
+ end
330
+
331
+ # Handle lists
332
+ if field.include?(',')
333
+ field.split(',').each do |val|
334
+ err = validate_field(val.strip, min, max, name)
335
+ return err if err
336
+ end
337
+ return nil
338
+ end
339
+
340
+ # Single value
341
+ val = normalize_value(field, name)
342
+ return "Invalid value '#{field}' in #{name}" if val.nil?
343
+ return "Value #{val} out of bounds (#{min}-#{max}) in #{name}" if val < min || val > max
344
+
345
+ nil
346
+ end
347
+
348
+ def normalize_value(val, field_name)
349
+ return nil if val.nil? || val.empty?
350
+
351
+ # Handle day of week names
352
+ if %w[day\ of\ week].include?(field_name)
353
+ return DAYS_OF_WEEK[val.upcase] if DAYS_OF_WEEK.key?(val.upcase)
354
+ end
355
+
356
+ # Handle month names
357
+ if field_name == 'month'
358
+ return MONTHS[val.upcase] if MONTHS.key?(val.upcase)
359
+ end
360
+
361
+ return val.to_i if val =~ /^\d+$/
362
+
363
+ nil
364
+ end
365
+
366
+ def expand_field(field, min, max)
367
+ return (min..max).to_a if field == '*'
368
+
369
+ if field.include?('/')
370
+ base, step = field.split('/')
371
+ step = step.to_i
372
+ start_vals = base == '*' ? (min..max).to_a : expand_field(base, min, max)
373
+ return start_vals.select { |v| (v - start_vals.first) % step == 0 }
374
+ end
375
+
376
+ if field.include?(',')
377
+ return field.split(',').flat_map { |f| expand_field(f.strip, min, max) }.sort.uniq
378
+ end
379
+
380
+ if field.include?('-')
381
+ start_val, end_val = field.split('-').map { |v| normalize_value(v, '') || v.to_i }
382
+ return (start_val..end_val).to_a
383
+ end
384
+
385
+ [normalize_value(field, '') || field.to_i]
386
+ end
387
+
388
+ def matches_cron?(time, minutes, hours, doms, months, dows)
389
+ return false unless minutes.include?(time.min)
390
+ return false unless hours.include?(time.hour)
391
+ return false unless months.include?(time.month)
392
+
393
+ # Day matching: either day of month OR day of week must match
394
+ # (unless both are specified as specific values)
395
+ dom_match = doms.include?(time.day)
396
+ dow_match = dows.include?(time.wday)
397
+
398
+ dom_match || dow_match
399
+ end
400
+
401
+ def build_description(minute, hour, dom, month, dow)
402
+ parts = []
403
+
404
+ # Time part
405
+ time_desc = describe_time(minute, hour)
406
+ parts << time_desc if time_desc
407
+
408
+ # Day of month part
409
+ if dom != '*'
410
+ parts << "on day #{describe_field(dom)} of the month"
411
+ end
412
+
413
+ # Month part
414
+ if month != '*'
415
+ month_names = expand_field(month, 1, 12).map { |m| Date::MONTHNAMES[m] }
416
+ parts << "in #{month_names.join(', ')}"
417
+ end
418
+
419
+ # Day of week part
420
+ if dow != '*'
421
+ day_names = expand_field(dow, 0, 6).map { |d| Date::DAYNAMES[d] }
422
+ if day_names == %w[Monday Tuesday Wednesday Thursday Friday]
423
+ parts << "on weekdays"
424
+ elsif day_names == %w[Sunday Saturday]
425
+ parts << "on weekends"
426
+ else
427
+ parts << "on #{day_names.join(', ')}"
428
+ end
429
+ end
430
+
431
+ parts.empty? ? "Every minute" : parts.join(', ')
432
+ end
433
+
434
+ def describe_time(minute, hour)
435
+ if minute == '*' && hour == '*'
436
+ return nil # Every minute, handled by default
437
+ end
438
+
439
+ if minute == '*'
440
+ hours = expand_field(hour, 0, 23)
441
+ return "Every minute during hour(s) #{hours.join(', ')}"
442
+ end
443
+
444
+ if hour == '*'
445
+ minutes = expand_field(minute, 0, 59)
446
+ if minute.include?('/')
447
+ return "Every #{minute.split('/').last} minutes"
448
+ end
449
+ return "At minute #{minutes.join(', ')} of every hour"
450
+ end
451
+
452
+ hours = expand_field(hour, 0, 23)
453
+ minutes = expand_field(minute, 0, 59)
454
+
455
+ times = hours.flat_map do |h|
456
+ minutes.map { |m| format('%02d:%02d', h, m) }
457
+ end
458
+
459
+ "At #{times.join(', ')}"
460
+ end
461
+
462
+ def describe_field(field)
463
+ return 'every' if field == '*'
464
+
465
+ if field.include?('/')
466
+ base, step = field.split('/')
467
+ return "every #{step}#{base == '*' ? '' : " starting at #{base}"}"
468
+ end
469
+
470
+ field
471
+ end
472
+ end
473
+ end
474
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm/tool'
4
+ require 'time'
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # A tool that returns the current date, time, and timezone information.
9
+ # Useful for AI assistants that need to know the current time context.
10
+ #
11
+ # @example
12
+ # tool = SharedTools::Tools::CurrentDateTimeTool.new
13
+ # result = tool.execute
14
+ # puts result[:date] # "2025-12-17"
15
+ # puts result[:time] # "13:45:30"
16
+ # puts result[:timezone] # "America/Chicago"
17
+ class CurrentDateTimeTool < RubyLLM::Tool
18
+ def self.name = 'current_date_time'
19
+
20
+ description <<~'DESCRIPTION'
21
+ Returns the current date, time, and timezone information from the system.
22
+ This tool provides accurate temporal context for AI assistants that need
23
+ to reason about time-sensitive information or schedule-related queries.
24
+
25
+ The tool returns:
26
+ - Current date in ISO 8601 format (YYYY-MM-DD)
27
+ - Current time in 24-hour format (HH:MM:SS)
28
+ - Current timezone name and UTC offset
29
+ - Unix timestamp for precise time calculations
30
+ - Day of week and week number for scheduling context
31
+
32
+ Example usage:
33
+ tool = SharedTools::Tools::CurrentDateTimeTool.new
34
+ result = tool.execute
35
+ puts "Today is #{result[:day_of_week]}, #{result[:date]}"
36
+ puts "Current time: #{result[:time]} #{result[:timezone]}"
37
+ DESCRIPTION
38
+
39
+ params do
40
+ string :format, description: <<~DESC.strip, required: false
41
+ Output format preference. Options:
42
+ - 'full' (default): Returns all date/time information
43
+ - 'date_only': Returns only date-related fields
44
+ - 'time_only': Returns only time-related fields
45
+ - 'iso8601': Returns a single ISO 8601 formatted datetime string
46
+ DESC
47
+ end
48
+
49
+ # @param logger [Logger] optional logger
50
+ def initialize(logger: nil)
51
+ @logger = logger || RubyLLM.logger
52
+ end
53
+
54
+ # Execute the date/time query
55
+ #
56
+ # @param format [String] Output format ('full', 'date_only', 'time_only', 'iso8601')
57
+ # @return [Hash] Current date/time information
58
+ def execute(format: 'full')
59
+ @logger.info("DateTimeTool#execute format=#{format.inspect}")
60
+
61
+ now = Time.now
62
+
63
+ case format.to_s.downcase
64
+ when 'date_only'
65
+ date_only_response(now)
66
+ when 'time_only'
67
+ time_only_response(now)
68
+ when 'iso8601'
69
+ iso8601_response(now)
70
+ else
71
+ full_response(now)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # Full response with all date/time information
78
+ def full_response(now)
79
+ {
80
+ success: true,
81
+ date: now.strftime('%Y-%m-%d'),
82
+ time: now.strftime('%H:%M:%S'),
83
+ datetime: now.iso8601,
84
+ timezone: now.zone,
85
+ timezone_name: timezone_name(now),
86
+ utc_offset: formatted_utc_offset(now),
87
+ unix_timestamp: now.to_i,
88
+ day_of_week: now.strftime('%A'),
89
+ day_of_year: now.yday,
90
+ week_number: now.strftime('%V').to_i,
91
+ is_dst: now.dst?,
92
+ quarter: ((now.month - 1) / 3) + 1
93
+ }
94
+ end
95
+
96
+ # Date-only response
97
+ def date_only_response(now)
98
+ {
99
+ success: true,
100
+ date: now.strftime('%Y-%m-%d'),
101
+ year: now.year,
102
+ month: now.month,
103
+ month_name: now.strftime('%B'),
104
+ day: now.day,
105
+ day_of_week: now.strftime('%A'),
106
+ day_of_year: now.yday,
107
+ week_number: now.strftime('%V').to_i,
108
+ quarter: ((now.month - 1) / 3) + 1
109
+ }
110
+ end
111
+
112
+ # Time-only response
113
+ def time_only_response(now)
114
+ {
115
+ success: true,
116
+ time: now.strftime('%H:%M:%S'),
117
+ time_12h: now.strftime('%I:%M:%S %p'),
118
+ hour: now.hour,
119
+ minute: now.min,
120
+ second: now.sec,
121
+ timezone: now.zone,
122
+ timezone_name: timezone_name(now),
123
+ utc_offset: formatted_utc_offset(now),
124
+ unix_timestamp: now.to_i,
125
+ is_dst: now.dst?
126
+ }
127
+ end
128
+
129
+ # ISO 8601 formatted response
130
+ def iso8601_response(now)
131
+ {
132
+ success: true,
133
+ datetime: now.iso8601,
134
+ utc: now.utc.iso8601
135
+ }
136
+ end
137
+
138
+ # Get the IANA timezone name if available
139
+ def timezone_name(time)
140
+ # Try to get IANA timezone from TZ environment variable
141
+ ENV['TZ'] || time.zone
142
+ end
143
+
144
+ # Format UTC offset as "+HH:MM" or "-HH:MM"
145
+ def formatted_utc_offset(time)
146
+ offset_seconds = time.utc_offset
147
+ sign = offset_seconds >= 0 ? '+' : '-'
148
+ hours, remainder = offset_seconds.abs.divmod(3600)
149
+ minutes = remainder / 60
150
+ format('%s%02d:%02d', sign, hours, minutes)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -62,14 +62,17 @@ module SharedTools
62
62
  end
63
63
 
64
64
 
65
- # @param driver [SharedTools::Tools::Database::BaseDriver] required database driver (SqliteDriver, PostgresDriver, etc.)
65
+ # @param driver [SharedTools::Tools::Database::BaseDriver] database driver (SqliteDriver, PostgresDriver, etc.)
66
+ # Required for execute, but optional for instantiation to support RubyLLM tool discovery
66
67
  # @param logger [Logger] optional logger
67
- def initialize(driver:, logger: nil)
68
- raise ArgumentError, "driver is required for DatabaseTool" if driver.nil?
68
+ def initialize(driver: nil, logger: nil)
69
69
  @driver = driver
70
70
  @logger = logger || RubyLLM.logger
71
71
  end
72
72
 
73
+ # Set driver after instantiation (useful when tool is discovered by RubyLLM)
74
+ attr_writer :driver
75
+
73
76
  # @example
74
77
  # tool = SharedTools::Tools::Database::BaseTool.new
75
78
  # tool.execute(statements: ["SELECT * FROM people"])
@@ -78,6 +81,8 @@ module SharedTools
78
81
  #
79
82
  # @return [Array<Hash>]
80
83
  def execute(statements:)
84
+ raise ArgumentError, "driver is required for DatabaseTool#execute. Set via initialize(driver:) or tool.driver=" if @driver.nil?
85
+
81
86
  [].tap do |executions|
82
87
  statements.map do |statement|
83
88
  execution = perform(statement:).merge(statement:)