chain_options 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.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChainOptions
4
+ #
5
+ # A simple helper class to set multiple chain options after another
6
+ # by using a `set NAME, VALUE` syntax instead of having to
7
+ # use constructs like
8
+ # instance = instance.NAME(VALUE)
9
+ # instance = instance.NAME2(VALUE2) if XY
10
+ # ...
11
+ #
12
+ class Builder
13
+ def initialize(initial_instance, &block)
14
+ @instance = initial_instance
15
+ ChainOptions::Util.instance_eval_or_call(self, &block)
16
+ result
17
+ end
18
+
19
+ def result
20
+ @instance
21
+ end
22
+
23
+ def set(option_name, *value, &block)
24
+ @instance = @instance.send(option_name, *value, &block)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChainOptions
4
+ module Integration
5
+
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ #
12
+ # Generates a combined getter and setter method for the
13
+ # option with the given name.
14
+ #
15
+ # @param [String, Symbol] name
16
+ #
17
+ # @param [Hash] options
18
+ # @option options [Object, Proc] :default (nil)
19
+ # Sets the value which should be used whenever no custom value was set for this option
20
+ #
21
+ # @option options [Symbol] :invalid (:raise)
22
+ # Sets the behaviour when an invalid value is given.
23
+ # If set to `:raise`, an ArgumentError is raised if an option validation fails,
24
+ # if set to `:default`, the default value is used instead of the invalid value
25
+ #
26
+ # @option options [Proc] :validate (nil)
27
+ # Sets up a validation proc for the option value.
28
+ # See :invalid for information about what happens when a validation fails
29
+ #
30
+ # @option options [Proc, Symbol] :filter (nil)
31
+ # An optional filter method to reject certain values.
32
+ # See ChainOptions::OptionSet#filter_value for more information
33
+ #
34
+ # @option options [Proc, Symbol] :transform (nil)
35
+ # An optional transformation that transforms the given value.
36
+ # See ChainOptions::OptionSet#transform_value for more information.
37
+ #
38
+ # @option options [Boolean] :incremental (false)
39
+ # If set to +true+, overriding an option value will no longer be possible.
40
+ # Instead, the whole option value is treated as an Array with each new value simply being
41
+ # appended to it.
42
+ # user.favourite_books('Momo', 'Neverending Story').favourite_books('Lord of the Rings', 'The Hobbit')
43
+ # => [["Momo", "Neverending Story"], ["Lord of the Rings", "The Hobbit"]]
44
+ #
45
+ # @option options [Boolean] :allow_block (false)
46
+ # Sets whether the option value may be a proc object given through a block.
47
+ # If set to +true+, the following statement would result in `block` being saved as option value:
48
+ # instance.my_option(&block)
49
+ #
50
+ def chain_option(name, **options)
51
+ available_chain_options[name.to_sym] = options
52
+
53
+ ChainOptions::OptionSet.handle_warnings(name, **options)
54
+
55
+ define_method(name) do |*args, &block|
56
+ chain_option_set.handle_option_call(name, *args, &block)
57
+ end
58
+ end
59
+
60
+ def available_chain_options
61
+ @available_chain_options ||= {}
62
+ end
63
+ end
64
+
65
+ def initialize(**options)
66
+ @chain_option_values = options
67
+ end
68
+
69
+ #
70
+ # Allows setting multiple options in a block. This makes long option chains easier to read.
71
+ #
72
+ # @example The following expressions are equivalent
73
+ # instance.option1(value).option2(value).option3 { value3 }
74
+ # instance.build_options do
75
+ # set :option1, value
76
+ # set :option2, value2
77
+ # set(:option3) { value3 }
78
+ #
79
+ def build_options(&block)
80
+ ChainOptions::Builder.new(self, &block).result
81
+ end
82
+
83
+ private
84
+
85
+ def chain_option_set
86
+ @chain_option_set ||= OptionSet.new(self, self.class.available_chain_options, chain_option_values)
87
+ end
88
+
89
+ #
90
+ # @return [Hash] the currently set options for the current instance.
91
+ #
92
+ def chain_option_values
93
+ @chain_option_values ||= {}
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChainOptions
4
+ #
5
+ # This class represents an Option from a ChainOptions::OptionSet and is
6
+ # mainly responsible for handling option state.
7
+ #
8
+ class Option
9
+ # The Parameters that need to be turned into instance methods, if they are symbols.
10
+ METHOD_SYMBOLS = %i[filter validate].freeze
11
+ PARAMETERS = %i[incremental default transform filter validate invalid allow_block].freeze
12
+
13
+ (PARAMETERS - [:allow_block]).each do |param|
14
+ define_method(param) { options[param] }
15
+ private param
16
+ end
17
+
18
+ def allow_block
19
+ options[:allow_block]
20
+ end
21
+
22
+ #
23
+ # Extracts options and sets all the parameters.
24
+ #
25
+ def initialize(options)
26
+ self.options = options
27
+ @dirty = false
28
+ end
29
+
30
+ #
31
+ # Builds a new value for the option.
32
+ # It automatically applies transformations and filters and validates the
33
+ # resulting value, raising an exception if the value is not valid.
34
+ #
35
+ def new_value(*args, &block)
36
+ value = value_from_args(args, &block)
37
+
38
+ value = if incremental
39
+ incremental_value(value)
40
+ else
41
+ filter_value(transformed_value(value))
42
+ end
43
+
44
+ if value_valid?(value)
45
+ self.custom_value = value
46
+ elsif invalid.to_s == 'default' && !incremental
47
+ default_value
48
+ else
49
+ fail ArgumentError, "The value #{value.inspect} is not valid."
50
+ end
51
+ end
52
+
53
+ #
54
+ # The current value if there is one. Otherwise returns the default value.
55
+ #
56
+ def current_value
57
+ return custom_value if dirty?
58
+
59
+ default_value
60
+ end
61
+
62
+ #
63
+ # Circumvents the normal value process to set an initial_value. Only works on a
64
+ # clean option.
65
+ #
66
+ def initial_value(value)
67
+ raise ArgumentError, "The initial_value was already set to #{custom_value.inspect}." if dirty?
68
+
69
+ self.custom_value = value
70
+ end
71
+
72
+ #
73
+ # Looks through the parameters and returns the non-nil values as a hash
74
+ #
75
+ def to_h
76
+ PARAMETERS.each_with_object({}) do |param, hash|
77
+ next if send(param).nil?
78
+
79
+ hash[param] = send(param)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ attr_accessor :options
86
+
87
+ # A value that has been set by a user. Overrides default_value.
88
+ attr_reader :custom_value
89
+
90
+ #
91
+ # Sets the current value and marks the option as dirty.
92
+ #
93
+ def custom_value=(value)
94
+ @dirty = true
95
+ @custom_value = value
96
+ end
97
+
98
+ #
99
+ # Returns the block if nothing else if given and blocks are allowed to be
100
+ # values.
101
+ #
102
+ def value_from_args(args, &block)
103
+ return block if ChainOptions::Util.blank?(args) && block && allow_block
104
+
105
+ flat_value(args)
106
+ end
107
+
108
+ #
109
+ # Reverses the auto-cast to Array that is applied at `new_value`.
110
+ #
111
+ def flat_value(args)
112
+ return args.first if args.is_a?(Enumerable) && args.count == 1
113
+
114
+ args
115
+ end
116
+
117
+ #
118
+ # Incremental values assume 2d arrays. Here the current_value is only
119
+ # prepended if the option is dirty.
120
+ #
121
+ def incremental_value(value)
122
+ dirty? ? [*current_value, Array(value)] : [Array(value)]
123
+ end
124
+
125
+ #
126
+ # Describes whether the default value has already been replaced.
127
+ #
128
+ def dirty?
129
+ !!@dirty
130
+ end
131
+
132
+ #
133
+ # Checks whether a new chain option value is valid or not using the `validate` values
134
+ # used when setting up `chain_option`s.
135
+ #
136
+ # Please note that the value passed to this function already went through
137
+ # the chain option's filters - if any. This means that it will always be a collection
138
+ # if a filter was defined.
139
+ #
140
+ # If no validation was set up, the new value will always be accepted
141
+ #
142
+ # @return [Boolean] +true+ if the new value is valid.
143
+ #
144
+ def value_valid?(value)
145
+ return true unless validate
146
+
147
+ validate.call(value)
148
+ end
149
+
150
+ #
151
+ # Applies a transformation to the given value.
152
+ #
153
+ # @param [Object] value
154
+ # The new value to be transformed
155
+ #
156
+ # If a `transform` was set up for the given option, it is used
157
+ # as `to_proc` target when iterating over the value.
158
+ # The value is always treated as a collection during this phase.
159
+ #
160
+ def transformed_value(value)
161
+ return value unless transform
162
+
163
+ transformed = Array(value).map(&transform)
164
+ value.is_a?(Enumerable) ? transformed : transformed.first
165
+ end
166
+
167
+ #
168
+ # Applies a filter to the given value.
169
+ # Expects the value to be some kind of collection. If it isn't, it is treated
170
+ # as an array with one element.
171
+ #
172
+ # @param [Object] value
173
+ # The new value to be filtered
174
+ #
175
+ # @example using a filter proc
176
+ # filter_value [1, 2, 3, 4, 5] # with filter: ->(entry) { entry.even? }
177
+ # #=> [2, 4]
178
+ #
179
+ def filter_value(value)
180
+ return value unless filter
181
+
182
+ Array(value).select(&filter)
183
+ end
184
+
185
+ #
186
+ # @return [Object] the default value which was set for the given option name.
187
+ # If a proc is given, the result of `proc.call` will be returned.
188
+ #
189
+ def default_value
190
+ return default unless default.respond_to?(:call)
191
+
192
+ default.call
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChainOptions
4
+ class OptionSet
5
+
6
+ class << self
7
+
8
+ #
9
+ # Warns of incompatible options for a chain_option.
10
+ # This does not necessarily mean that an error will be raised.
11
+ #
12
+ def warn_incompatible_options(option_name, *options)
13
+ STDERR.puts "The options #{options.join(', ')} are incompatible for the chain_option #{option_name}."
14
+ end
15
+
16
+ #
17
+ # Prints warnings for incompatible options which were used as arguments in `chain_option`
18
+ #
19
+ def handle_warnings(name, incremental: false, invalid: :raise, filter: nil, transform: nil, **)
20
+ if incremental
21
+ warn_incompatible_options(name, 'invalid: :default', 'incremental: true') if invalid.to_s == 'default'
22
+ warn_incompatible_options(name, 'incremental: true', 'filter:') if filter
23
+ warn_incompatible_options(name, 'incremental: true', 'transform:') if transform
24
+ end
25
+ end
26
+ end
27
+
28
+ #
29
+ # @param [Object] instance the object that uses the chain_option set.
30
+ # @param [Hash] chain_options a hash of `{name: config_hash}` to initialize
31
+ # options from a config hash.
32
+ # @param [Hash] values a hash of `{name: value}` with initial values for the
33
+ # named options.
34
+ #
35
+ def initialize(instance, chain_options = {}, values = {})
36
+ @instance = instance
37
+ @values = values
38
+ @chain_options = chain_options.inject({}) do |options, (name, config)|
39
+ options.merge(name => config.merge(instance_method_hash(config)))
40
+ end
41
+ end
42
+
43
+ attr_reader :instance
44
+
45
+ #
46
+ # Checks the given option-parameters for incompatibilities and registers a
47
+ # new option.
48
+ #
49
+ def add_option(name, parameters)
50
+ self.class.handle_warnings(name, **parameters.dup)
51
+ chain_options.merge(name => parameters.merge(method_hash(parameters)))
52
+ end
53
+
54
+ #
55
+ # Returns the current_value of an option.
56
+ #
57
+ def current_value(name)
58
+ option(name).current_value
59
+ end
60
+
61
+ #
62
+ # Returns an option registered under `name`.
63
+ #
64
+ def option(name)
65
+ config = chain_options[name] || raise_no_option_error(name)
66
+ Option.new(config).tap { |o| o.initial_value(values[name]) if values.key?(name) }
67
+ end
68
+
69
+ #
70
+ # Builds a new value for the given chain option.
71
+ # It automatically applies transformations and filters and validates the
72
+ # resulting value, raising an exception if the value is not valid.
73
+ #
74
+ def new_value(name, *args, &block)
75
+ option(name).new_value(*args, &block)
76
+ end
77
+
78
+ #
79
+ # Handles a call of #option_name.
80
+ # Determines whether the call was meant to be a setter or a getter and
81
+ # acts accordingly.
82
+ #
83
+ def handle_option_call(option_name, *args, &block)
84
+ if getter?(option_name, *args, &block)
85
+ current_value(option_name)
86
+ else
87
+ new_value = new_value(option_name, *args, &block)
88
+ instance.class.new(@values.merge(option_name.to_sym => new_value))
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :values, :chain_options
95
+
96
+ #
97
+ # @return [Boolean] +true+ if a call to the corresponding option method with the given args / block
98
+ # can be handled as a getter (no args / no block usage)
99
+ #
100
+ def getter?(option_name, *args, &block)
101
+ args.empty? && (block.nil? || !option(option_name).allow_block)
102
+ end
103
+
104
+ # no-doc
105
+ def raise_no_option_error(name)
106
+ fail ArgumentError, "There is no option registered called #{name}."
107
+ end
108
+
109
+ #
110
+ # Checks the given options and transforms certain options into closures,
111
+ # if they are symbols before.
112
+ # The keys that are being transformed can be seen at `Option::METHOD_SYMBOLS`.
113
+ # @return [Hash] a set of parameters and closures.
114
+ #
115
+ def instance_method_hash(options)
116
+ ChainOptions::Util.slice(options, Option::METHOD_SYMBOLS).each_with_object({}) do |(meth, value), h|
117
+ next h[meth] = value if value.respond_to?(:call)
118
+
119
+ h[meth] = instance.public_method(value)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+
5
+ #
6
+ # Custom matcher to test for chain option behaviour.
7
+ #
8
+ # Every matcher call starts with `have_chain_option` which ensures the the given
9
+ # object actually has access to a chain option with the given name.
10
+ #
11
+ # ## Value Acceptance
12
+ #
13
+ # To test for values which should raise an exception when being set as a chain option value,
14
+ # continue the matcher as follows:
15
+ # it { is_expected.to have_chain_option(:my_option).which_takes(42).and_raises_an_exception }
16
+ #
17
+ # ## Value Filters / Transformations
18
+ #
19
+ # To test whether the option is actually set to the correct value after passing an object to it,
20
+ # continue the matcher as follows:
21
+ # it { is_expected.to have_chain_option(:my_option).which_takes(42).and_sets_it_as_value }
22
+ #
23
+ # If you expect the option to perform a filtering and/or transformation, you can also
24
+ # specify the actual value you expect to be set:
25
+ # it { is_expected.to have_chain_option(:my_option).which_takes(42).and_sets("42").as_value }
26
+ #
27
+ # ## Default Value
28
+ #
29
+ # To test whether the option has a certain default value, continue the matcher as follows:
30
+ # it { is_expected.to have_chain_option(:my_option).with_the_default_value(21) }
31
+ #
32
+ module ChainOptions
33
+ module TestIntegration
34
+ module Rspec
35
+ extend ::RSpec::Matchers::DSL
36
+
37
+ matcher :have_chain_option do |option_name|
38
+ match do |instance|
39
+ unless chain_option?(instance)
40
+ error_lines "Expected the class `#{instance.class}`",
41
+ "to define the chain option `:#{option_name}`,",
42
+ "but it didn't."
43
+ next false
44
+ end
45
+
46
+ if instance_variable_defined?('@expected_default_value')
47
+ next false unless correct_default_value?(instance)
48
+ end
49
+
50
+ if instance_variable_defined?('@given_value')
51
+ if @exception_expected
52
+ check_for_exception(instance)
53
+ elsif instance_variable_defined?('@expected_value')
54
+ check_for_expected_value(instance)
55
+ end
56
+ end
57
+
58
+ @error.nil?
59
+ end
60
+
61
+ def error_lines(*lines)
62
+ @error = lines[1..-1].reduce(lines.first) do |error_string, line|
63
+ error_string + "\n #{line}"
64
+ end
65
+ end
66
+
67
+ define_method :chain_option? do |instance|
68
+ instance.class.available_chain_options.key?(option_name.to_sym)
69
+ end
70
+
71
+ define_method :correct_default_value? do |instance|
72
+ actual_default_value = instance.send(option_name)
73
+ next true if actual_default_value == @expected_default_value
74
+
75
+ error_lines "Expected the chain option `:#{option_name}`",
76
+ "of the class `#{instance.class}`",
77
+ "to have the default value `#{@expected_default_value.inspect}`",
78
+ "but the actual default value is `#{actual_default_value.inspect}`"
79
+ end
80
+
81
+ define_method :check_for_exception do |instance|
82
+ begin
83
+ instance.send(option_name, @given_value)
84
+ error_lines "Expected the chain option `:#{option_name}`",
85
+ "not to accept the value `#{@given_value.inspect}`,",
86
+ 'but it did.'
87
+ rescue ArgumentError => e
88
+ raise unless e.message.include?('not valid')
89
+ end
90
+ end
91
+
92
+ define_method :check_for_expected_value do |instance|
93
+ actual_value = instance.send(option_name, @given_value).send(option_name)
94
+ if actual_value != @expected_value
95
+ error_lines "Expected the chain option `:#{option_name}`",
96
+ "of the class `#{instance.class}`",
97
+ "to accept the value `#{@given_value.inspect}`",
98
+ "and set the option value to `#{@expected_value.inspect}`,",
99
+ "but it was set to `#{actual_value.inspect}`"
100
+ end
101
+ end
102
+
103
+ chain :with_the_default_value do |expected_default_value|
104
+ @expected_default_value = expected_default_value
105
+ end
106
+
107
+ chain :which_takes do |value|
108
+ @given_value = value
109
+ end
110
+
111
+ chain :and_sets do |expected_value|
112
+ @expected_value = expected_value
113
+ end
114
+
115
+ chain :and_sets_it_as_value do
116
+ @expected_value = @given_value
117
+ end
118
+
119
+ chain :and_raises_an_exception do
120
+ @exception_expected = true
121
+ end
122
+
123
+ chain(:as_value) {}
124
+
125
+ failure_message do
126
+ @error.to_s
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChainOptions
4
+ module Util
5
+
6
+ def self.blank?(obj)
7
+ (obj.is_a?(Enumerable) && obj.empty?) || obj.nil? || obj == ''
8
+ end
9
+
10
+ def self.slice(hash, keys)
11
+ keys.each_with_object({}) do |key, h|
12
+ h[key] = hash[key] if hash[key]
13
+ end
14
+ end
15
+
16
+ #
17
+ # Evaluate the given proc in the context of the given object if the
18
+ # block's arity is non-positive, or by passing the given object as an
19
+ # argument if it is negative.
20
+ #
21
+ # ==== Parameters
22
+ #
23
+ # object<Object>:: Object to pass to the proc
24
+ #
25
+ def self.instance_eval_or_call(object, &block)
26
+ if block.arity.positive?
27
+ block.call(object)
28
+ else
29
+ ContextBoundDelegate.instance_eval_with_context(object, &block)
30
+ end
31
+ end
32
+
33
+ #
34
+ # Shamelessly copied from sunspot/sunspot with some alterations
35
+ #
36
+ class ContextBoundDelegate
37
+ class << self
38
+ def instance_eval_with_context(receiver, &block)
39
+ calling_context = eval('self', block.binding, __FILE__, __LINE__)
40
+
41
+ parent_calling_context = calling_context.instance_eval do
42
+ @__calling_context__ if defined?(@__calling_context__)
43
+ end
44
+
45
+ calling_context = parent_calling_context if parent_calling_context
46
+ new(receiver, calling_context).instance_eval(&block)
47
+ end
48
+
49
+ private :new
50
+ end
51
+
52
+ BASIC_METHODS = Set[:==, :equal?, :"!", :"!=", :instance_eval,
53
+ :object_id, :__send__, :__id__]
54
+
55
+ instance_methods.each do |method|
56
+ unless BASIC_METHODS.include?(method.to_sym)
57
+ undef_method(method)
58
+ end
59
+ end
60
+
61
+ def initialize(receiver, calling_context)
62
+ @__receiver__, @__calling_context__ = receiver, calling_context
63
+ end
64
+
65
+ def id
66
+ @__calling_context__.__send__(:id)
67
+ rescue ::NoMethodError => e
68
+ begin
69
+ @__receiver__.__send__(:id)
70
+ rescue ::NoMethodError
71
+ raise(e)
72
+ end
73
+ end
74
+
75
+ # Special case due to `Kernel#sub`'s existence
76
+ def sub(*args, &block)
77
+ __proxy_method__(:sub, *args, &block)
78
+ end
79
+
80
+ def method_missing(method, *args, &block)
81
+ __proxy_method__(method, *args, &block)
82
+ end
83
+
84
+ def respond_to_missing?(meth)
85
+ @__receiver__.respond_to?(meth) || @__calling_context__.respond_to?(meth)
86
+ end
87
+
88
+ def __proxy_method__(method, *args, &block)
89
+ @__receiver__.__send__(method.to_sym, *args, &block)
90
+ rescue ::NoMethodError => e
91
+ begin
92
+ @__calling_context__.__send__(method.to_sym, *args, &block)
93
+ rescue ::NoMethodError
94
+ raise(e)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChainOptions
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chain_options/version'
4
+
5
+ require 'chain_options/util'
6
+ require 'chain_options/option_set'
7
+ require 'chain_options/option'
8
+ require 'chain_options/integration'
9
+ require 'chain_options/builder'
10
+
11
+ module ChainOptions
12
+ end