Build a minimal decorator with Ruby in 30 minutes

Build a minimal decorator with Ruby in 30 minutes
← Return to the Ruby blogA few weeks ago, I needed to add some view-related methods to an object. Decorators are my go-to pattern to handle this kind of logic.
Normally, I’d use the draper gem to build decorators. But the app I’m working on used an older and incompatible version of Rails.
So I built a minimal decorator from scratch, added a bunch of extra behaviors, only to end up abstracting all of these away. Follow along!
What I’m working with
My Teacher
class has a handful of methods:
- A one-to-many relationship with the
Student
class. - Two public methods: one that exposes the maximum number of students a teacher can teach to, and one exposing the available teaching places.
class Teacher < ApplicationRecord
has_many :students
def maximum_number_of_students = 30
def available_places
(maximum_number_of_students <=> students.size).clamp(0..)
end
end
In my views, I want to display a table of teachers where the number of available places for each teacher is backed by a background colour.
I could write the However, models are not the place for methods generating CSS classes. Decorators are!
My decorator should accept an instance of Now, I can instantiate my decorator and use it in my views:
When I can call But if I were to run this code as is, I’d get a beautiful My views do not handle instances of My decorator needs to be able to handle both its own public methods and the public methods defined on the underlying record ( And we do that by using Ruby’s When I call To do that, I need to re-open Ruby’s In this example, I keep the original signature of Ruby’s The only thing I tweak is forwarding the method call to the underlying Now, What would be cool now, is to allow other decorators to share this behavior.
One way to gather default behavior shared across various decorators is to rely on inheritance. I can create an Then, I can have my My Some Rails native helpers will have a hard time handling my decorator.
Consider this code:
But if How do I make my decorator integrate with Rails default behavior?
I can re-open the Of course, forwarding every Rails default behaviors to the underlying record is not a great strategy (too much complexity). So, how should I do it?
SimpleDelegator provides the means to delegate all supported method calls to the object passed into the constructor.
This means that by using SimpleDelegator, I can remove the initialization and the delegation logics from my Everything is abstracted away. And it just works™. Here’s what I ended up with:
That’s it! A 30-minute minimal decorator in plain Ruby.
Cheers,
Rémi - @[email protected]
PS: I'm available for hire.
# teachers/index.html.erb
<table class="table table-striped">
<thead>
<tr>
<th>Name of the teacher</th>
Available placesth>
</tr>
thead>
<tbody>
<% teachers.each do |teacher| %>
<td><%= teacher.full_name %>
<% end %>
"<%= teacher.colour_coded_availability %>">
<%= teacher.available_places %>
Teacher#colour_coded_availability
method in my model like so:
class Teacher < ApplicationRecord
has_many :students
def maximum_number_of_students = 30
def available_places
(maximum_number_of_students <=> students.size).clamp(0..)
end
def colour_coded_availability
case available_places
when 0 then "bg-colour-red"
else "bg-colour-green"
end
end
end
Drafting a decorator
Teacher
and expose the colour_coded_availability
public method.
# app/decorators/teacher_decorator.rb
class TeacherDecorator
attr_reader :teacher
def initialize(teacher:)
@teacher = teacher
end
def colour_coded_availability
case teacher.available_places
when 0 then "bg-colour-red"
else "bg-colour-green"
end
end
end
# app/controllers/teachers_controller.rb
class TeachersController < ApplicationController
def index
@teachers = Teacher.all.map { TeacherDecorator.new(teacher: _1) }
end
end
# teachers/index.html.erb
<table class="table table-striped">
<thead>
<tr>
<th>Name of the teacher</th>
Available placesth>
</tr>
thead>
<tbody>
<% @teachers.each do |teacher| %>
<td><%= teacher.full_name %>
<% end %>
"<%= teacher.colour_coded_availability %>">
<%= teacher.available_places %>
teacher.colour_coded_availability
in my views, the method retrieves a CSS class and adds it to the HTML tag.
NoMethodError
. Why?
Teacher
anymore. They handle instances of TeacherDecorator
. So, when I’m calling the public methods defined on Teacher
, the decorator doesn’t know what to do with them.
Teacher
, in this case).
method_missing
.
Ruby’s
method_missing
to the rescuemethod_missing
is how Ruby handles method calls made on objects where said methods are not defined. Ruby passes the method call along the ancestry chain until it can either resolves it or raises a NoMethodError
.
@teacher.full_name
, I want my decorator to rescue the NoMethodError
, and forward #full_name
to the underlying instance of Teacher
.
method_missing
, add a custom behavior, then allow method_missing
to run its normal course.
class TeacherDecorator
attr_reader :teacher
def initialize(teacher)
@teacher = teacher
end
def availability_as_background
case teacher.max_number_of_students <=> teacher.available_places
when -1 then "background-danger"
when 0 then "background-warning"
when 1 then "background-success"
end
end
private
def method_missing(method, *args, &)
return teacher.public_send(method, *args, &) if teacher.respond_to?(method)
super
end
def respond_to_missing?(name, include_private = false)
teacher.respond_to?(name) || super
end
end
method_missing
.
teacher
. I only forward it if the teacher
responds to the method. Then, I let Ruby resume its original behavior 1.
@teacher.full_name
is properly forwarded to the underlying instance of Teacher
.
Normalizing the behavior to create other decorators
ApplicationDecorator
whose job is to handle instantiation, and forwarding method calls to the underlying record.
TeacherDecorator
inherit from the ApplicationDecorator
.
class ApplicationDecorator
def initialize(record)
@record = record
end
private
attr_reader :record
def method_missing(method, *args, &block)
if record.respond_to?(method)
record.public_send(method, *args, &block)
else
super
end
end
def respond_to_missing?(name, include_private = false)
record.respond_to?(name) || super
end
end
class TeacherDecorator < ApplicationDecorator
attr_reader :teacher
def availability_as_background
case teacher.max_number_of_students <=> teacher.available_places
when -1 then "background-danger"
when 0 then "background-warning"
when 1 then "background-success"
end
end
private
alias_method :teacher, :record
end
TeacherDecorator
doesn’t need to bother about its initialization since it’s handled by the parent ApplicationDecorator
. The only thing I added, is the ability to reference the record
as teacher
so it’s clearer what kind of record we’re working with.
Ensure Rails default behavior works well
`edit_teacher_path(@teacher)` # => Should generate teachers/1/edit
@teacher
references an instance of my TeacherDecorator
, the generated path is teachers/#TeacherDecorator/edit
.
to_param
method which is responsible for turning (among other things) a record into its id
, and delegating its behavior to the record.
class ApplicationDecorator
def initialize(record)
@record = record
end
delegate :to_param, to: :record
private
attr_reader :record
def method_missing(method, *args, &block)
if record.respond_to?(method)
record.public_send(method, *args, &block)
else
super
end
end
def respond_to_missing?(name, include_private = false)
record.respond_to?(name) || super
end
end
Use Ruby standard SimpleDelegator
ApplicationDelegator
.
require "delegate"
class ApplicationDecorator < SimpleDelegator ; end
@record
is not available anymore for my TeacherDecorator
to reference, but SimpleDelegator exposes a __getobj__
that works exactly as my previous @record
ivar.
Final implementation
require "delegate"
class ApplicationDecorator < SimpleDelegator ; end
class TeacherDecorator < ApplicationDecorator
def availability_as_background
case teacher.max_number_of_students <=> teacher.available_places
when -1 then "background-danger"
when 0 then "background-warning"
when 1 then "background-success"
end
end
alias_method :teacher, :__getobj__
end
respond_to_missing?
only ensures that the responds_to?
does not return false positives by allowing the decorator to respond to methods even if they are not statically defined on it. ↩
What's Your Reaction?
Like
0
Dislike
0
Love
0
Funny
0
Angry
0
Sad
0
Wow
0