1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
|
# frozen_string_literal: true
require_relative 'option_definition'
module ProcessExecuter
module Options
# Defines, validates, and holds a set of option values
#
# Options are defined by subclasses by overriding the `define_options` method.
#
# @example Define an options class with two options
# class MyOptions < ProcessExecuter::Options::Base
# def define_options
# # Call super to include options defined in the parent class
# [
# *super,
# ProcessExecuter::Options::OptionDefinition.new(
# :option1, default: '', validator: method(:assert_is_string)
# ),
# ProcessExecuter::Options::OptionDefinition.new(
# :option2, default: '', validator: method(:assert_is_string)
# ),
# ProcessExecuter::Options::OptionDefinition.new(
# :option3, default: '', validator: method(:assert_is_string)
# )
# ]
# end
# def assert_is_string(key, value)
# return if value.is_a?(String)
# errors << "#{key} must be a String but was #{value}"
# end
# end
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.option1 # => 'value1'
# options.option2 # => 'value2'
#
# @example invalid option values
# begin
# options = MyOptions.new(option1: 1, option2: 2)
# rescue ProcessExecuter::ArgumentError => e
# e.message #=> "option1 must be a String but was 1\noption2 must be a String but was 2"
# end
#
# @api public
class Base
# Create a new Options object
#
# Normally you would use a subclass instead of instantiating this class
# directly.
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
#
# @example with invalid option values
# begin
# options = MyOptions.new(option1: 1, option2: 2)
# rescue ProcessExecuter::ArgumentError => e
# e.message #=> "option1 must be a String but was 1\noption2 must be a String but was 2"
# end
#
# @param options_hash [Hash] a hash of options
#
def initialize(**options_hash)
@options_hash = allowed_options.transform_values(&:default).merge(options_hash)
@errors = []
assert_no_unknown_options
define_accessor_methods
validate_options
end
# All the allowed options as a hash whose keys are the option names
#
# The returned hash what is returned from `define_options` but with the option
# names as keys. The values are instances of `OptionDefinition`.
#
# The returned hash is frozen and cannot be modified.
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.allowed_options # => {
# option1: #<OptionDefinition>,
# option2: #<OptionDefinition>
# }
#
# @return [Hash<Symbol, ProcessExecuter::Options::OptionDefinition>] A hash
# where keys are option names and values are their definitions.
#
def allowed_options
@allowed_options ||=
define_options.each_with_object({}) do |option, hash|
hash[option.name] = option
end.freeze
end
# A string representation of the object that includes the options
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.to_s # => #<MyOptions option1: "value1", option2: "value2">'
#
# @return [String]
#
def to_s
"#{super.to_s[0..-2]} #{inspect}>"
end
# A string representation of the options
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.inspect # => '{:option1=>"value1", :option2=>"value2"}'
#
# @return [String]
#
def inspect
options_hash.inspect
end
# A hash representation of the options
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.to_h # => { option1: "value1", option2: "value2" }
#
# @return [Hash]
#
def to_h
options_hash.dup
end
# Iterate over each option with an object
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.each_with_object({}) { |(option_key, option_value), obj| obj[option_key] = option_value }
# # => { option1: "value1", option2: "value2" }
#
# @yield [key_value, obj]
#
# @yieldparam key_value [Array<Object, Object>] An array containing the option key and its value
#
# @yieldparam obj [Object] The object passed to the block.
#
# @return [Object] the obj passed to the block
#
def each_with_object(obj, &)
options_hash.each_with_object(obj, &)
end
# Merge the given options into the current options object
#
# Subsequent hashes' values overwrite earlier ones for the same key.
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# h1 = { option2: 'new_value2' }
# h2 = { option3: 'value3' }
# options.merge!(h1, h2) => {option1: "value1", option2: "new_value2", option3: "value3"}
#
# @param other_options_hashes [Array<Hash>] zero of more hashes to merge into the current options
#
# @return [self] the current options object with the merged options
#
# @api public
#
def merge!(*other_options_hashes)
options_hash.merge!(*other_options_hashes)
end
# Returns a new options object formed by merging self with each of other_hashes
#
# @example
# options = MyOptions.new(option1: 'value1', option2: 'value2')
# options.object_id # => 1025
# h1 = { option2: 'new_value2' }
# h2 = { option3: 'value3' }
# merged_options = options.merge(h1, h2)
# merged_options.object_id # => 1059
#
# @param other_options_hashes [Array<Hash>] the options to merge into the current options
#
# @return [self.class]
#
def merge(*other_options_hashes)
merged_options = other_options_hashes.reduce(options_hash, :merge)
self.class.new(**merged_options)
end
protected
# An array of OptionDefinition objects that define the allowed options
#
# Subclasses MUST override this method to define the allowed options.
#
# @return [Array<OptionDefinition>]
#
# @api private
#
def define_options
[].freeze
end
# Determine if the given option is a valid option
#
# May be overridden by subclasses to add additional validation.
#
# @param option [Symbol] the option to be tested
# @return [Boolean] true if the given option is a valid option
# @api private
def valid_option?(option)
allowed_options.keys.include?(option)
end
private
# The list of validation errors
#
# Validators should add error messages to this array.
#
# @return [Array<String>]
#
# @api private
#
attr_reader :errors
# @!attribute [r]
#
# A hash of all options keyed by the option name
#
# @return [Hash<Object, Object>]
#
# @api private
#
attr_reader :options_hash
# Raise an argument error for invalid option values
# @return [void]
# @raise [ProcessExecuter::ArgumentError] if any invalid option values are found
# @api private
def validate_options
options_hash.each_key do |option_key|
validator = allowed_options[option_key]&.validator
instance_exec(option_key, send(option_key), &validator.to_proc) unless validator.nil?
end
raise ProcessExecuter::ArgumentError, errors.join("\n") unless errors.empty?
end
# Define accessor methods for each option
# @return [void]
# @api private
def define_accessor_methods
allowed_options.each_key do |option|
define_singleton_method(option) do
options_hash[option]
end
end
end
# Determine if the options hash contains any unknown options
# @return [void]
# @raise [ProcessExecuter::ArgumentError] if the options hash contains any unknown options
# @api private
def assert_no_unknown_options
unknown_options = options_hash.keys.reject { |key| valid_option?(key) }
return if unknown_options.empty?
raise(
ArgumentError,
"Unknown option#{'s' if unknown_options.count > 1}: #{unknown_options.join(', ')}"
)
end
end
end
end
|