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'

Ready?

Inheritable Class Attributes

This is for you if you want class attributes to be inherited, which 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 attribute which whatever you want - usually a hash or an array.

You can now use that attribute on the class level.

Song.properties #=> [:title, :track]

Inheriting from Song will result in the properties object being cloned to the sub-class.

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, as 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 resulting in a less dangerous potential. Also, there is no restriction about the way you modify the attribute as found in class_attribute.

This module is very popular amongst numerous gems like Cells, Representable, Roar and Reform.

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 in user context (using instance_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 Procs, 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)

Evaluating Elements

If you want to evaluate a single option element, use #eval.

options.eval(:ttl, user) #=> "n/a"

Single Values

Sometimes you don't need an entire hash but a dynamic value, only.

value = Uber::Options::Value.new(lambda { |volume| volume < 0 ? 0 : volume })

value.evaluate(context, -122.18) #=> 0

Use Options::Value#evaluate to handle single values.

Performance

Evaluating an options hash can be time-consuming. When Options contains static elements only, it behaves and performs like an ordinary hash.

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

When included, Builder allows to add builder instructions on the class level. These can then be evaluated when instantiating the class to conditionally build (sub-)classes based on the incoming parameters.

class Listener
  include Uber::Builder

  builds do |params|
    SignedIn if params[:current_user]
  end
end

class SignedIn
end

The class then has to use the builder to compute a class name using the build blocks you defined.

class Listener
  def self.build(params)
    class_builder.call(params).
    new(params)
  end
end

As you can see, it's still up to you to instantiate the object, the builder only helps you computing the concrete class.

Listener.build({}) #=> Listener
Listener.build({current_user: @current_user}) #=> SignedIn

Note that builders are not inherited to subclasses. This allows instantiating subclasses directly without running builders.

This pattern is used in Cells, Trailblazer and soon Reform and Representable/Roar, too.

Version

Writing gems against other gems often involves checking for versions and loading appropriate version strategies - e.g. "is Rails >= 4.0?". Uber gives you Version for easy, semantic version deciders.

version = Uber::Version.new("1.2.3")

The API currently gives you #>= and #~.

version >= "1.1" #=> true
version >= "1.3" #=> false

The ~ method does a semantic check (currently on major and minor level, only).

version.~ "1.1" #=> false
version.~ "1.2" #=> true
version.~ "1.3" #=> false

Accepting a list of versions, it makes it simple to check for multiple minor versions.

version.~ "1.1", "1.0" #=> false
version.~ "1.1", "1.2" #=> true

Undocumented Features

(Please don't read this!)

  • You can enforce treating values as dynamic (or not): Uber::Options::Value.new("time_to_live", dynamic: true) will always run #time_to_live as an instance method on the context, even though it is not a symbol.

License

Copyright (c) 2014 by Nick Sutterer [email protected]

Roar is released under the MIT License.