Adornable
Adornable provides the ability to cleanly decorate methods in Ruby. You can make and use your own decorators, and you can also use some of the built-in ones that the gem provides. Decorating methods is as simple as slapping a decorate :some_decorator
above your method definition. Defining decorators can be as simple as defining a method that yields to a block, or as complex as manipulating the decorated method's receiver and arguments, and/or changing the functionality of the decorator based on custom options supplied to it when initially applying the decorator.
Installation
NOTE: This library is tested with Ruby versions 2.5.x through 3.2.x.
Locally (to your application)
Add the gem to your application's Gemfile
:
gem 'adornable'
...and then run:
bundle install
Globally (to your system)
Alternatively, install it globally:
gem install adornable
Usage
The basics
Think of a decorator as if it's just a wrapper function. You want something to happen before, around, or after a method is called, in a reusable (but dynamic) way? Maybe you want to print to a log whenever a certain method is called, or memoize its result so that additional calls don't have to re-execute the body of the method. You've tried this:
class RandomValueGenerator
def value
# logging the method call
puts "Calling method `RandomValueGenerator#value` with no arguments"
# memoizing the result
@value ||= rand
end
def values(max)
# logging the method call
puts "Calling method `RandomValueGenerator#values` with arguments `[#{max}]`"
# memoizing the result
@values ||= {}
@values[max] ||= (1..max).map { rand }
end
end
random_value_generator = RandomValueGenerator.new
values1 = random_value_generator.values(1000)
# Calling method `RandomValueGenerator#values` with arguments `[1000]`
#=> [0.7044444114998132, 0.401953296596267, 0.3023797513191562, ...]
values1 = random_value_generator.values(1000)
# Calling method `RandomValueGenerator#values` with arguments `[1000]`
#=> [0.7044444114998132, 0.401953296596267, 0.3023797513191562, ...]
values3 = random_value_generator.values(5000)
# Calling method `RandomValueGenerator#values` with arguments `[5000]`
#=> [0.9916088057511011, 0.04466750434972333, 0.6073659341272127]
value1 = random_value_generator.value
# Calling method `RandomValueGenerator#value` with no arguments
#=> 0.4196007135344746
value2 = random_value_generator.value
# Calling method `RandomValueGenerator#value` with no arguments
#=> 0.4196007135344746
However, you have a million more methods to write, and if you refactor, you'll have to screw around with a slew of method definitions across your app.
What if you could do this, instead, to achieve the same result?
class RandomValueGenerator
extend Adornable
decorate :log
decorate :memoize
def value
rand
end
decorate :log
decorate :memoize
def values(max)
(1..max).map { rand }
end
end
Nice, right?
Note: in the case of multiple decorators decorating a method, each is executed from top to bottom.
Adding decorator functionality
Add the ::decorate
macro to your classes by extend
-ing Adornable
:
class Foo
extend Adornable
# ...
end
Decorating methods
Use the decorate
macro to decorate methods.
Using built-in decorators
There are a couple of built-in decorators for common use-cases (these can be overridden if you so choose):
class Foo
extend Adornable
decorate :log
def some_method
# the method name (Foo#some_method) and arguments will be logged
end
decorate :memoize
def some_other_method
# the return value will be cached
end
decorate :memoize
def yet_another_method(some_arg, some_other_arg = true, key_word_arg:, key_word_arg_with_default: 123)
# the return value will be cached based on the arguments the method receives
end
decorate :log
decorate :memoize, for_any_arguments: true
def oh_boy_another_method(some_arg, some_other_arg = true, key_word_arg:, key_word_arg_with_default: 123)
# the method name (Foo#oh_boy_another_method) and arguments will be logged
# the return value will be cached regardless of the arguments received
end
decorate :log
def self.yeah_it_works_on_class_methods_too
# the method name (Foo::yeah_it_works_on_class_methods_too) and arguments
# will be logged
end
end
decorate :log
logs the method name and any passed arguments to the consoledecorate :memoize
caches the result of the first call and returns that initial result (and does not execute the method again) for any additional calls. By default, it namespaces the cache by the arguments passed to the method, so it will re-compute only if the arguments change; if the arguments are the same as any previous time the method was called, it will return the cached result instead.- pass the
for_any_arguments: true
option (e.g.,decorate :memoize, for_any_arguments: true
) to ignore the arguments in the caching process and simply memoize the result no matter what - a
nil
value returned from a memoized method will still be cached like any other value
- pass the
Note: in the case of multiple decorators decorating a method, each is executed from top to bottom.
Writing custom decorators and using them explicitly
You can reference any decorator method you write, like so:
class FooDecorators
# Note: this is defined as a CLASS method, but it can be applied to both class
# and instance methods. The only difference is in how you source the
# decorator when doing the decoration; see below for more info.
def self.blast_it(context)
puts "Blasting it!"
value = yield
"#{value}!"
end
# Note: this is defined as an INSTANCE method, but it can be applied to both
# class and instance methods. The only difference is in how you source
# the decorator when doing the decoration; see below for more info.
def wait_for_it(context, dot_count: 3)
ellipsis = dot_count.times.map { '.' }.join
puts "Waiting for it#{ellipsis}"
value = yield
"#{value}#{ellipsis}"
end
end
class Foo
extend Adornable
# Note: `from: FooDecorators` references a class (and will look for the
# `::blast_it` method on that class)
decorate :blast_it, from: FooDecorators
def some_method
"haha I'm a method"
end
# Note: `from: FooDecorators.new` references an instance (and will look for
# the `#wait_for_it` method on that instance)
decorate :wait_for_it, from: FooDecorators.new
def other_method
"haha I'm another method"
end
decorate :log
def yet_another_method(foo, bar:)
"haha I'm yet another method"
end
end
foo = Foo.new
foo.some_method
#=> "haha I'm a method!" # Note the exclamation mark
foo.other_method
#=> "haha I'm another method..." # Note the ellipsis
foo.yet_another_method(123, bloop: "bleep")
# Calling method `Foo#yet_another_method` with arguments `[123, {:bloop=>"bleep"}]`
#=> "haha I'm yet another method"
Use the from:
option to specify what should receive the decorator method. Keep in mind that the decorator method will be called on the thing specified by from:
... so, if you provide a class, it better be a class method on that thing, and if you supply an instance, it better be an instance method on that thing.
Every custom decorator method that you define must take one required argument (context
) and any number of keyword arguments. It should also yield
(or take a block argument and invoke it) at some point in the body of the method. The point at which you yield
will be the point at which the decorated method will execute (or, if there are multiple decorators on the method, each following decorator will be invoked until the decorators have been exhausted and the decorated method is finally executed).
context
)
The required argument (The required argument is an instance of Adornable::Context
, which has some useful information about the decorated method being called
Adornable::Context#method_name
: the name of the decorated method being called (a symbol; e.g.,:some_method
or:other_method
)Adornable::Context#method_receiver
: the actual object that the decorated method (the#method_name
) belongs to/is being called on (an object/class; e.g., the classFoo
if it's a decorated class method, or an instance ofFoo
if it's a decorated instance method)Adornable::Context#method_arguments
: an array of arguments passed to the decorated method, including keyword arguments as a final hash (e.g., if:yet_another_method
was called likeFoo.new.yet_another_method(123, bar: true, baz: 456)
thenmethod_arguments
would be[123, {:bar=>true,:baz=>456}]
)Adornable::Context#method_positional_args
: an array of just the positional arguments passed to the decorated method, excluding keyword arguments (e.g., if:yet_another_method
was called likeFoo.new.yet_another_method(123, bar: true, baz: 456)
thenmethod_positional_args
would be[123]
)Adornable::Context#method_kwargs
: a hash of just the keyword arguments passed to the decorated method (e.g., if:yet_another_method
was called likeFoo.new.yet_another_method(123, { bam: "hi" }, bar: true, baz: 456)
thenmethod_kwargs
would be{:bar=>true,:baz=>456}
)
Custom keyword arguments (optional)
The optional keyword arguments are any parameters you want to be able to pass to the decorator method when decorating a method with ::decorate
:
- If you define a decorator like
def self.some_decorator(context)
then it takes no options when it is used:decorate :some_decorator
. - If you define a decorator like
def self.some_decorator(context, some_option:)
then it takes one required keyword argument when it is used:decorate :some_decorator, some_option: 123
(so that::some_decorator
will receive123
as thesome_option
parameter every time the decorated method is called). You can customize functionality of the decorator this way. - Similarly, if you define a decorator like
def self.some_decorator(context, some_option: 456)
, then it takes one optional keyword argument when it is used:decorate :some_decorator
is valid (and impliessome_option: 456
since it has a default), anddecorate :some_decorator, some_option: 789
is valid as well.
Yielding to the next decorator/decorated method
Every decorator method should also probably yield
at some point in the method body. I say "should" because, technically, you don't have to, but if you don't then the original method will never be called. That's a valid use-case, but 99% of the time you're gonna want to yield
.
Note: the return value of your decorator will replace the return value of the decorated method, so also you should probably return whatever value
yield
returned. Again, it is a valid use case to return something else, but 99% of the time you probably want to return the value returned by the wrapped method.A contrived example of when you might want to muck around with the return value:
class FooDecorators def self.coerce_to_int(context) value = yield new_value = value.strip.to_i puts "New value: #{value.inspect} (class: #{value.class})" new_value end end class Foo extend Adornable decorate :coerce_to_int, from: FooDecorators def get_number_from_user print "Enter a number: " value = gets puts "Value: #{value.inspect} (class: #{value.class})" value end end foo = Foo.new foo.get_number_from_user # Enter a number # > 123 # Value: "123" (class: String) # New value: 123 (class: Integer) #=> 123
Writing custom decorators and using them implicitly
You can also register decorator receivers so that you don't have to reference them with the from:
option:
class FooDecorators
def self.blast_it(context)
puts "Blasting it!"
value = yield
"#{value}!"
end
end
class MoreFooDecorators
def wait_for_it(context, dot_count: 3)
ellipsis = dot_count.times.map { '.' }.join
puts "Waiting for it#{ellipsis}"
value = yield
"#{value}#{ellipsis}"
end
end
class Foo
extend Adornable
add_decorators_from FooDecorators
add_decorators_from MoreFooDecorators.new
decorate :blast_it
decorate :wait_for_it, dot_count: 9
def some_method
"haha I'm a method"
end
end
foo = Foo.new
foo.some_method
# Blasting it!
# Waiting for it.........
#=> "haha I'm a method!........."
Note: All the rest of the stuff from the previous section (using decorators explicitly) also applies here (using decorators implicitly).
Note: In the case of duplicate decorator methods, later receivers registered with
::add_decorators_from
will override any decorators by the same name from earlier registered receivers.
Note: in the case of multiple decorators decorating a method, each is executed from top to bottom; i.e., the top wraps the next, which wraps the next, and so on, until the method itself is wrapped.
Development
Install dependencies
bin/setup
Run the tests
rake spec
Run the linter
rubocop
Contributing
Bug reports and pull requests for this project are welcome at its GitHub page. If you choose to contribute, please be nice so I don't have to run out of bubblegum, etc.
License
This project is open source, under the terms of the MIT license.