Uber
Gem-authoring tools like class method inheritance in modules, dynamic options and more.
Installation
Add this line to your application's Gemfile:
gem 'uber'
Uber runs with Ruby >= 1.9.3.
Inheritable Class Attributes
If you want inherited class attributes, this is for you. This is a mandatory mechanism for creating DSLs.
require 'uber/inheritable_attr'
class Song
extend Uber::InheritableAttr
inheritable_attr :properties
self.properties = [:title, :track] # initialize it before using it.
end
Note that you have to initialize your class attribute with whatever you want - usually a hash or an array.
Song.properties #=> [:title, :track]
A subclass of Song
will have a clone
d properties
class attribute.
class Hit < Song
end
Hit.properties #=> [:title, :track]
The cool thing about the inheritance is: you can work on the inherited attribute without any restrictions. It is a copy of the original.
Hit.properties << :number
Hit.properties #=> [:title, :track, :number]
Song.properties #=> [:title, :track]
It's similar to ActiveSupport's class_attribute
but with a simpler implementation.
It is less dangerous. There are no restrictions for modifying the attribute. compared to class_attribute
.
Uncloneable Values
::inheritable_attr
will clone
values to copy them to subclasses. Uber won't attempt to clone Symbol
, nil
, true
and false
per default.
If you assign any other unclonable value you need to tell Uber that.
class Song
extend Uber::InheritableAttr
inheritable_attr :properties, clone: false
This won't clone
but simply pass the value on to the subclass.
Dynamic Options
Implements the pattern of defining configuration options and dynamically evaluating them at run-time.
Usually DSL methods accept a number of options that can either be static values, symbolized instance method names, or blocks (lambdas/Procs).
Here's an example from Cells.
cache :show, tags: lambda { Tag.last }, expires_in: 5.mins, ttl: :time_to_live
Usually, when processing these options, you'd have to check every option for its type, evaluate the tags:
lambda in a particular context, call the #time_to_live
instance method, etc.
This is abstracted in Uber::Options
and could be implemented like this.
require 'uber/options'
options = Uber::Options.new(tags: lambda { Tag.last },
expires_in: 5.mins,
ttl: :time_to_live)
Just initialize Options
with your actual options hash. While this usually happens on class level at compile-time, evaluating the hash happens at run-time.
class User < ActiveRecord::Base # this could be any Ruby class.
# .. lots of code
def time_to_live(*args)
"n/a"
end
end
user = User.find(1)
options.evaluate(user, *args) #=> {tags: "hot", expires_in: 300, ttl: "n/a"}
Evaluating Dynamic Options
To evaluate the options to a real hash, the following happens:
- The
tags:
lambda is executed inuser
context (usinginstance_exec
). This allows accessing instance variables or calling instance methods. - Nothing is done with
expires_in
's value, it is static. -
user.time_to_live?
is called as the symbol:time_to_live
indicates that this is an instance method.
The default behaviour is to treat Proc
s, lambdas and symbolized :method
names as dynamic options, everything else is considered static. Optional arguments from the evaluate
call are passed in either as block or method arguments for dynamic options.
This is a pattern well-known from Rails and other frameworks.
Uber::Callable
A third way of providing a dynamic option is using a "callable" object. This saves you the unreadable lambda syntax and gives you more flexibility.
require 'uber/callable'
class Tags
include Uber::Callable
def call(context, *args)
[:comment]
end
end
By including Uber::Callable
, uber will invoke the #call
method on the specified object.
Note how you simply pass an instance of the callable object into the hash instead of a lambda.
options = Uber::Options.new(tags: Tags.new)
Option
Uber::Option
implements the pattern of taking an option, such as a proc, instance method name, or static value, and evaluate it at runtime without knowing the option's implementation.
Creating Option
instances via ::[]
usually happens on class-level in DSL methods.
with_proc = Uber::Option[ ->(options) { "proc: #{options.inspect}" } ]
with_static = Uber::Option[ "Static value" ]
with_method = Uber::Option[ :name_of_method ]
def name_of_method(options)
"method: #{options.inspect}"
end
Use #call
to evaluate the options at runtime.
with_proc.(1, 2) #=> "proc: [1, 2]"
with_static.(1, 2) #=> "Static value" # arguments are ignored
with_method.(self, 1, 2) #=> "method: [1, 2]" # first arg is context
It's also possible to evaluate a callable object. It has to be marked with Uber::Callable
beforehand.
class MyCallable
include Uber::Callable
def call(context, *args)
"callable: #{args.inspect}, #{context}"
end
end
with_callable = Uber::Option[ MyCallable.new ]
The context is passed as first argument.
with_callable.(Object, 1, 2) #=> "callable: [1, 2] Object"
You can also make blocks being instance_exec
ed on the context, giving a unique API to all option types.
with_instance_proc = Uber::Option[ ->(options) { "proc: #{options.inspect} #{self}" }, instance_exec: true ]
The first argument now becomes the context, exactly the way it works for the method and callable type.
with_instance_proc.(Object, 1, 2) #=> "proc [1, 2] Object"
Delegates
Using ::delegates
works exactly like the Forwardable
module in Ruby, with one bonus: It creates the accessors in a module, allowing you to override and call super
in a user module or class.
require 'uber/delegates'
class SongDecorator
def initialize(song)
@song = song
end
attr_reader :song
extend Uber::Delegates
delegates :song, :title, :id # delegate :title and :id to #song.
def title
super.downcase # this calls the original delegate #title.
end
end
This creates readers #title
and #id
which are delegated to #song
.
song = SongDecorator.new(Song.create(id: 1, title: "HELLOWEEN!"))
song.id #=> 1
song.title #=> "helloween!"
Note how #title
calls the original title and then downcases the string.
Builder
Builders are good for polymorphically creating objects without having to know where that happens. You define a builder with conditions in one class, and that class takes care of creating the actual desired class.
Declarative Interface
Include Uber::Builder
to leverage the ::builds
method for adding builders, and ::build!
to run those builders in a given context and with arbitrary options.
require "uber/builder"
class User
include Uber::Builder
builds do |options|
Admin if params[:admin]
end
end
class Admin
end
Note that you can call builds
as many times as you want per class.
Run the builders using ::build!
.
User.build!(User, {}) #=> User
User.build!(User, { admin: true }) #=> Admin
The first argument is the context in which the builder blocks will be executed. This is also the default return value if all builders returned a falsey value.
All following arguments will be passed straight through to the procs.
Your API should communicate User
as the only public class, since the builder hides details about computing the concrete class.
Builder: Procs
You may also use procs instead of blocks.
class User
include Uber::Builder
builds ->(options) do
return SignedIn if params[:current_user]
return Admin if params[:admin]
Anonymous
end
end
Note that this allows return
s in the block.
Builder: Direct API
In case you don't want the builds
DSL, you can instantiate a Builders
object yourself and add builders to it using #<<
.
MyBuilders = Uber::Builder::Builders.new
MyBuilders << ->(options) do
return Admin if options[:admin]
end
Note that you can call Builders#<<
multiple times per instance.
Invoke the builder using #call
.
MyBuilders.call(User, {}) #=> User
MyBuilders.call(User, { admin: true }) #=> Admin
Again, the first object is the context/default return value, all other arguments are passed to the builder procs.
Builder: Contexts
Every proc is instance_exec
ed in the context you pass into build!
(or call
), allowing you to define generic, shareable builders.
MyBuilders = Uber::Builder::Builders.new
MyBuilders << ->(options) do
return self::Admin if options[:admin] # note the self:: !
end
class User
class Admin
end
end
class Instructor
class Admin
end
end
Now, depending on the context class, the builder will return different classes.
MyBuilders.call(User, {}) #=> User
MyBuilders.call(User, { admin: true }) #=> User::Admin
MyBuilders.call(Instructor, {}) #=> Instructor
MyBuilders.call(Instructor, { admin: true }) #=> Instructor::Admin
Don't forget the self::
when writing generic builders, and write tests.
License
Copyright (c) 2014 by Nick Sutterer apotonick@gmail.com
Uber is released under the MIT License.