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
puts "Calling method `RandomValueGenerator#value` with no arguments"
@value ||= rand
end
def values(max)
puts "Calling method `RandomValueGenerator#values` with arguments `[#{max}]`"
@values ||= {}
@values[max] ||= (1..max).map { rand }
end
end
random_value_generator = RandomValueGenerator.new
values1 = random_value_generator.values(1000)
values1 = random_value_generator.values(1000)
values3 = random_value_generator.values(5000)
value1 = random_value_generator.value
value2 = random_value_generator.value
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
end
decorate :memoize
def some_other_method
end
decorate :memoize
def yet_another_method(some_arg, some_other_arg = true, key_word_arg:, key_word_arg_with_default: 123)
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)
end
decorate :log
def self.yeah_it_works_on_class_methods_too
end
end
decorate :log
logs the method name and any passed arguments to the console
decorate :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
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
def self.blast_it(context)
puts "Blasting it!"
value = yield
"#{value}!"
end
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
decorate :blast_it, from: FooDecorators
def some_method
"haha I'm a method"
end
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
foo.other_method
foo.yet_another_method(123, bloop: "bleep")
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).
The required argument (context
)
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 class Foo
if it's a decorated class method, or an instance of Foo
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 like Foo.new.yet_another_method(123, bar: true, baz: 456)
then method_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 like Foo.new.yet_another_method(123, bar: true, baz: 456)
then method_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 like Foo.new.yet_another_method(123, { bam: "hi" }, bar: true, baz: 456)
then method_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 receive 123
as the some_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 implies some_option: 456
since it has a default), and decorate :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
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
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.