ChainOptions
ChainOptions is a small gem which allows you to add non-destructive chainable options to your classes. It is useful to incrementally build instances without overriding the previous one and provides an easy-to-understand DSL to set options either through method-chaining or in a block.
An example:
class MyItemFeed
include ChainOptions::Integration
chain_option :page,
default: 1,
invalid: :default,
validate: ->(value) { value.to_i.positive? }
chain_option :per_page,
default: 30,
validate: ->(value) { value.to_i.positive? },
invalid: :default
end
feed = MyItemFeed.new.build_options do
set :page, params[:page]
set :per_page, params[:per_page]
end
# or
feed = MyItemFeed.new.page(params[:page]).per_page(params[:per_page])
Installation
Add this line to your application's Gemfile:
gem 'chain_options'
And then execute:
$ bundle
Or install it yourself as:
$ gem install chain_options
Usage
To use ChainOptions in one of your classes, simply include its integration module:
include ChainOptions::Integration
Afterwards, you're ready to define the options available to instances of your class.
Basic Options
The easiest way to define an option is to call chain_option
with just the option name:
class MyClass
chain_option :my_option
end
This will generate the method #my_option
which is accessible by instances of your class.
When it's called with an argument, it will return a new instance of your class with
the option set to this value, when being called without an argument, it will return the current value.
my_class = MyClass.new #=> Instance 1 of MyClass
my_class.my_option('my value') #=> Instance 2 of MyClass
my_class.my_option #=> 'my value'
Please note that instance variables are currently not carried over to the new
instances built when setting a new option.
This decision was made to ensure no cached values could be used any more
after changing an option value:
class Feed
chain_option :page
chain_option :per_page
def entries
@entries ||= MyModel.page(page).per(per_page)
end
end
Setting page
to a different value after #entries
was called once would not
lead to another page being loaded, the return value would stay the same.
This behaviour might be changed in the future, but would only make the gem more complex for now.
Array may be passed in as multiple arguments or an Array object, so the following calls are equivalent:
my_object.my_value(1, 2, 3)
my_option.my_value([1, 2, 3])
Advanced Options
Filters
It is possible to apply filters to option values. As soon as a filter Proc is defined, it is assumed that the option value will be an Array.
chain_option :my_even_numbers,
filter: -> (number) { number.even? }
my_object.my_even_numbers(1, 2, 3, 4, 5) #=> [2, 4]
Note: As soon as :filter
is defined, the value will be treated as Array, even if only a single
element is passed in:
my_object.my_even_numbers(2) #=> [2]
Value Validations
It is possible to define validations on the setting value. These are executed whenever a new value is set and will either cause an Exception or the option going back to the default value:
chain_option :per_page,
validate: -> (value) { value.to_i.positive? },
invalid: :raise
The above example ensures that a value set for the per_page
option has to be positive.
Otherwise, an ArgumentError
is raised.
chain_option :per_page,
default: 1
validate: -> (value) { value.to_i.positive? },
invalid: :default
my_object.per_page(-1).per_page #=> 1
Note: If filters are set up as well, your validation proc will always receive an Array, never a single element.
Value Transformations
It is possible to perform automatic transformations (or type casts) on an option value, pretty similar to what ActiveRecord does when e.g. a numeric value is assigned to a string attribute.
As options don't have a type, you have to define the transformation yourself:
chain_option :my_strings,
transform: -> (element) { element.to_s }
chain_option :my_strings,
transform: :to_s
The above calls are equivalent. If a symbol is given, the value (resp. each element of it in case of an Array) is expected to respond to a method with the same name.
If the value is an array, the transform
Proc will receive each item individually.
Default Values
It is possible to specify a default value for each option using the :default
keyword argument.
The default value is returned in the following cases:
- No custom value was set for the option yet
- The value set for the option is invalid and the option is set to use the default value instead (see below)
The default value may either be a Proc which is executed on demand or any kind of Ruby object.
chain_option :per_page,
default: -> { SomeStore.get_default_per_page }
Incremental Values
Options can be set to increment their value through multiple setter calls:
chain_option :favourite_books, incremental: true
user.favourite_books('Lord of the Rings').favourite_books('The Hobbit')
#=> [['Lord of the Rings'], ['The Hobbit]]
As the values should still be separateable, the elements which were added in each setter call are wrapped in another array instead of just appending them to the collection. Otherwise, it wouldn't be possible to determine that the following value was caused by two sets:
user.favourite_books('Momo', 'Neverending Story').favourite_books('Lord of the Rings', 'The Hobbit')
#=> [["Momo", "Neverending Story"], ["Lord of the Rings", "The Hobbit"]]
Blocks as option values
If your option accepts blocks as values, setting this to true
allows you to use the block syntax
to set a new option value instead of having to pass in a lambda function or Proc object:
chain_option :my_proc, allow_block: true
my_object = my_object.my_proc do
# ...
end
my_object.my_proc #=> <#Proc...>
Option Testing
ChainOptions comes with basic RSpec integration by providing custom matchers.
To use them, simply require the corresponding module and include it in your specs:
require 'chain_options/test_integration/rspec'
subject { MyClass.new }
describe 'my_option' do
include ChainOptions::TestIntegration::Rspec
it { is_expected.to have_chain_option(:my_option) }
end
Every matcher call starts with have_chain_option
which ensures the the given
object actually has access to a chain option with the given name.
Value Acceptance
To test for values which should raise an exception when being set as a chain option value, continue the matcher as follows:
it { is_expected.to have_chain_option(:my_option).which_takes(42).and_raises_an_exception }
This matcher can only fail if the option is set to invalid: :raise
.
Value Filters / Transformations
To test whether the option is actually set to the correct value after passing an object to it, continue the matcher as follows:
it { is_expected.to have_chain_option(:my_option).which_takes(42).and_sets_it_as_value }
If you expect the option to perform a filtering and/or transformation, you can also specify the actual value you expect to be set:
it { is_expected.to have_chain_option(:my_option).which_takes(42).and_sets("42").as_value }
Default Value
To test whether the option has a certain default value, continue the matcher as follows:
it { is_expected.to have_chain_option(:my_option).with_the_default_value(21) }
Basic Testing
If you can't or don't want to use the custom matchers, you could define your own helper methods to keep your option tests readable:
def expect_to_eql(name, value, expected)
expect(subject.send(name, value).send(name)).to eql expected
end
def expect_to_raise(name, value)
expect { subject.send(name, value) }.to raise_error(ArgumentError, /not valid/),
"`#{value.inspect}` should not be a valid value for option `#{name}`"
end
it { expect_to_eql :my_option, 42, '42' }
it { expect_to_raise :my_option, Object.new }
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/lokalportal/chain_options.
For pull request, please follow git-flow naming conventions.
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the ChainOptions project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.