Class Macros. Let's create simplified version of Reform
- 26 February 2017
- Ruby
- Class Macros. Let's create simplified version of Reform
If you use Rails you could see and use class macros many times. For example we use it for defining associations in ActiveRecord models:
class User
has_many :posts
end
has_many
it's a class macro.
Many gems provide own class macros, for example:
class Post
acts_as_paranoid
end
acts_as_paranoid
- that's another class macro.
Today we will learn how to create own class macros.
I was going to to write about class macros couple months ago, but now I feel that it's time. I've attended Trailblazer workshop where creator of Trailblazer Nick Sutterer showed us many cool features. That was really inspiring so I decided to write something similar to one of components of Trailblazer.
Reform - it's one of Trailblazer's gems. You can easy find out how to use it. I would describe it as a form object which allows us to define which params we want to accept from user. Also we can connect model to form object. Form is responsible for validation, filtering params and applying valid params to connected model.
Description might be a little bit hard to understand, so let's check the code. Today we're going to create mini-version of Reform gem. Let's see how our form object will look like when we implement it:
class MyForm < Proform
attribute :title
attribute :description
validates :title, presence: true
end
We will inherit MyForm
class from base class Proform
(which we will implement later). We will be able to specify which attributes we're going to accept from user. Besides that we will specify validation rules: for example title
is required for MyForm
.
It will allow us to use form object this way:
# let's use Struct as a model for simplicity
Product = Struct.new(:title, :description)
my_product = Product.new('RubyBlog', 'Blog about Ruby and Ruby on Rails')
# we use params hash as a user's input
params = { description: 'Foo' }
# instantiate form object with my_product model
form = MyForm.new(my_product)
form.valid? # => true. valid by default
# validate form with params
form.validate(params) # => false, because title is required
form.valid? # => false
form.errors # {:title=>["can't be blank"]}
# let's pass valid params to form object
params = { title: 'rubyblog.pro' }
form = MyForm.new(my_product)
form.validate(params) # => true
form.valid? # => true
form.apply # assigns provided title to model my_product
I know that we have a lot of code in this example, but let's try to understand what we can do with this form object. In MyForm
we declared that form will accept attributes title
and description
. Also we specified that title
field is required.
Let's imagine that we have this model of data:
Product = Struct.new(:title, :description)
my_product = Product.new('RubyBlog', 'Blog about Ruby and Ruby on Rails')
For this example I used Struct
, but in your application that could be ActiveRecord
model or any other data object.
All we need to do is pass model to form initialization:
form = MyForm.new(my_product)
Now we have our form
object so we can pass any data we receive from user. Form will be able to validate that data using method validate
. To check if form is valid we can use valid?
method. In case of invalid form - errors
will return description of each particular error. If everything's ok we can call apply
method which will assign attributes of form object to model.
I hope that at this moment we understand what we expect from forms and we can move forward to the most interesting part - implementation of parent class Proform
, which will allow us to specify attributes and validation rules for form objects.
Implementation of Proform with Class Macros
Now let's check our form code one more time:
class MyForm < Proform
attribute :title
attribute :description
validates :title, presence: true
end
We see that we need to add two class macros for Proform
: attribute
and validates
.
attribute
will define which params we're going to accept. If we receive param that wasn't defined as an attribute - we will ignore it.
Also, we will need to add validates
macro, which will allow us to configure validation for attribute. For simplicity we will implement just one type of validation: presence: true
.
Let's start from the base class Proform
:
class Proform
end
class MyForm < Proform
end
Let's try to add non-existing macro and see what happens:
class Proform
end
class MyForm < Proform
attribute :title # => `<class:MyForm>': undefined method `attribute' for MyForm:Class (NoMethodError)
end
That makes sense. Because Ruby executes all code inside a class - it tried to call method attribute
for MyForm
. Inside MyForm
class at the moment of execution self
equals to MyForm
. So we need to add class method attribute
to make it working.
class Proform
def self.attribute(name)
puts name
end
end
class MyForm < Proform
attribute :title # => title
end
Class Macro worked well this time because we defined that class method in parent class.
Now we have an interesting question: where should we store list of all forms' attributes? Macros is being called immediately, because Ruby executes code inside class. It means that at any moment of time MyForm
should know its own set of attributes.
But self.attribute
it's a class method. It knows nothing about future objects of that class. Having that we can't assign any instance variable. We can not use class variables as well because each form should have own unique set of attributes. For example:
class Proform
@@attributes = []
def self.attribute(name)
@@attributes << name
end
def self.attributes
@@attributes
end
end
class MyForm < Proform
attribute :title
end
class FooForm < Proform
attribute :foo
end
MyForm.attributes # => WRONG [:title, :foo]
Class Proform
can't use class variables because attributes defined in one of classes will be attributes for other classes.
I don't want to go too deep into this idea with class variables. To implement Class Macro we will use class instance variable
. In Ruby almost everything is an object. The same with classes. Each class it's an object of class Class
. If it's an instance of class Class
- it may have own instance variables.
If you feel that you don't understand this concept - it's ok, because I had to read "Metaprogramming Ruby 2" book three times to get the idea. If you haven't read this book yet - you definitely should do that. Let's move forward and use class instance variables to store array of attributes which should support our form:
class Proform
def self.attributes
@attributes ||= []
end
def self.attribute(name)
attributes << name
end
end
class MyForm < Proform
attribute :title
attribute :description
end
class FooForm < Proform
attribute :foo
end
MyForm.attributes # => [:title, :description]
FooForm.attributes #=> [:foo]
It works well and now each particular form knows about own attributes! Now it's not that hard to add one more class macro:
class Proform
def self.attributes
@attributes ||= []
end
def self.validations
@validations ||= {}
end
def self.attribute(name)
attributes << name
end
def self.validates(attr, params)
validations[attr] = params
end
end
We've added two class methods. self.validations
returns list of all validations and self.validates(attr, params)
adds new validation rule.
Now when we have those class macros, MyForm
class can have attributes and validations:
class MyForm < Proform
attribute :title
validates :title, presence: true
end
MyForm.attributes # => [:title]
MyForm.validations #=> {:title=>{:presence=>true}}
Now we can define validation and attributes which will be processed by form.
Next, we're going to add methods: #errors
, #valid?
, #validate(params)
and #apply
As I mentioned before, on form initialization we will accept a model which will be tied to form object. Let's add method initialize
to Proform
class and implement valid?
method:
class Proform
def self.attributes
@attributes ||= []
end
def self.validations
@validations ||= {}
end
def self.attribute(name)
attributes << name
end
def self.validates(attr, params)
validations[attr] = params
end
attr_reader :model, :errors
def initialize(model)
@model = model
@errors = Hash.new { |h, k| h[k] = [] }
end
def valid?
errors.empty?
end
end
If you're wondering why default values for Hash looks weird, you can read more about it here. Long story short: main idea to have empty array for each new key in that hash.
Let's implement one of the most important parts: validate
method. As we agreed we will support only one type of validation: presence: true
, which will check that param was passed to form object:
def validate(params)
@params = params.keep_if { |attr| self.class.attributes.include?(attr) }
self.class.validations.each do |attr, validations|
validations.each do |type, value|
if type == :presence && value == true
errors[attr] << "can't be blank" unless params[attr]
end
end
end
valid?
end
We could write that code better, but since we have just one type of validation it's good enough to make it working. Let's check what we have in that method:
@params = params.keep_if { |attr| self.class.attributes.include?(attr) }
This line allows us to filter params. We filter out params which are not declared as attribute
for form. One important thing here that we can get an access to form attributes using self.class.attributes
.
Then we go through all defined validations and validate each param using validates
method.
As I said now we have just a basic validation for presence of param:
if type == :presence && value == true
errors[attr] << "can't be blank" unless params[attr]
end
Definitely if we add more validation we would need to pull out this logic into separate module and don't do all checks inside code of loop. But I tried to keep this example as simple as possible.
Last method which we need to implement - #apply
, which would assign all valid params to model. Implementation is pretty straightforward:
def apply
return false unless valid?
return true unless params
params.each {|attr, val| model.public_send("#{attr}=", val)}
end
We return false
if params are not valid. Also we return true
if params are blank.
If we have valid attributes, we go through all of them and assign its values to model using public_send
.
Let's recap all code that we've created today:
class Proform
def self.attributes
@attributes ||= []
end
def self.validations
@validations ||= {}
end
def self.attribute(name)
attributes << name
end
def self.validates(attr, params)
validations[attr] = params
end
attr_reader :model, :errors, :params
def initialize(model)
@model = model
@errors = Hash.new { |h, k| h[k] = [] }
end
def validate(params)
@params = params.keep_if { |attr| self.class.attributes.include?(attr) }
self.class.validations.each do |attr, validations|
validations.each do |type, value|
if type == :presence && value == true
errors[attr] << "can't be blank" unless params[attr]
end
end
end
valid?
end
def valid?
errors.empty?
end
def apply
return false unless valid?
return true unless params
params.each {|attr, val| model.public_send("#{attr}=", val)}
end
end
class MyForm < Proform
attribute :title
attribute :description
validates :title, presence: true
end
Let's use our fresh new class MyForm
. We will consider two cases: with valid and invalid params:
Product = Struct.new(:title, :description)
model = Product.new('RubyBlog', 'Description')
# => #<struct Product title="RubyBlog", description="Description">
form = MyForm.new(model)
# with invalid params
form.validate(description: 'Test') # => false
# results of validation appeared in errors hash
form.errors # => {:title=>["can't be blank"]}
form.apply # => false. Invalid params didn't change model, apply returns false
model # => #<struct Product title="RubyBlog", description="Description">
form = MyForm.new(model)
# with valid params
form.validate(title: 'http://rubyblog.pro', description: 'Blog about Ruby') # => true
form.valid? # => true
form.errors # => {}
# apply changed model and assigned new attributes
form.apply # {:title=>"http://rubyblog.pro", :description=>"Blog about Ruby"}
model # => #<struct Product title="http://rubyblog.pro", description="Blog about Ruby">
I've commented almost each important line in that example.
This post is relatively long, but I hope it was useful. Often when developers see such class macros they think that it's some sort of magic. But in fact there is no magic. It's not that hard to use such approach in own gem or application.
I'll really appreciate if you write couple words about this article in comments and share link with friends. Also, please, send me a message if you have better way to implement class macro. I'm always open to new ideas!
For those who still think about class instance variables - I would highly recommend to read "Metaprogramming Ruby 2" book. Every time I read that book I find something new for myself.
Thanks for reading!