FormObject with ActiveRecord type casting
Form Object pattern
A FormObject pattern is an interesting approach for offloading a lot of business logic from models to another objects. The idea is simple - instead of a model you assign an ActiveModel
object in the controller. This object is then used in the views for the call to form_for
. The validation is placed there and any extra stuff that is linked more with the way the model should be presented than persisted. This way the it gets slimmer. And its only concern is to read and write to the database.
Such form object can look like this:
class QuickRegistration
include FormObject
Attributes = [
:email,
:password,
:password_confirmation
]
define_attributes User, *Attributes
validates_presence_of :email, :password, :password_confirmation
validates_confirmation_of :password
def initialize(attributes = {})
assign_attributes(attributes)
end
end
The FormObject
itself would be a mixin:
module FormObject
extend ActiveSupport::Concern
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
included do
class_attribute :attribute_names
class_attribute :model
def self.define_attributes(*names)
self.attribute_names = (attribute_names || []) + names.map(&:intern)
self.model = model
attribute_names.each do |name|
define_method name do
@attributes ||= {}
@attributes[name.intern]
end
define_method "#{name}=" do |v|
@attributes ||= {}
@attributes[name.intern] = v
end
end
end
end
def persisted?
false
end
def errors_for(attribute)
errors[attribute].join(', ') if errors.has_key?(attribute)
end
def assign_attributes(attributes)
@attributes = attributes.slice(*self.class.attribute_names)
end
def attributes
@attributes ||= {}
end
def persistable_attributes
attributes.slice(*model.column_names.map(&:intern))
end
end
In your controller instead of a model you assign a form object:
class QuickRegistrationsController < ApplicationController
def new
@user = QuickRegistration.new
end
def create
@user = QuickRegistration.new(params[:user])
if @user.valid?
User.create!(@user.persistable_attributes)
redirect_to root_path, notice: "You have been registered"
else
render :new
end
end
protected
def user_params
params.require(:user).permit(*QuickRegistration::Attributes)
end
end
In the view you can do:
<%= form_for @user, url: quick_registration_path, as: :user do |f| %>
<%= f.text_field :email %>
<%= f.object.errors_for :email %>
<%= f.text_field :password %>
<%= f.object.errors_for :password %>
<%= f.text_field :password_confirmation %>
<%= f.object.errors_for :password_confirmation %>
<% end %>
Now, what if you have a different registration form in your app that captures the extra date of birth and the acceptance of terms? You can create another form object that inherits from the old one:
class FullRegistration < QuickRegistration
Attributes = QuickRegistration::Attributes + [
:date_of_birth,
:terms_accepted
]
define_attributes User, *Attributes
validates_acceptance_of :terms_accepted
validates_presence_of :date_of_birth
validate :date_of_birth_in_the_past
protected
def date_of_birth_in_the_past
errors.add(:date_of_birth, "have to be in the past") unless date_of_birth.try(:past?)
end
end
Mind that we simply defined extra validation. When valid?
will be called on this object it’ll run the validation defined in both the parent and the child class.
One last thing we have to deal with is the type casting. Since the attributes coming from the controller are strings the date_of_birth
will not have the method past?
. The same terms_accepted
will be either “0” or “1”. The validation for the acceptance will always pass since all it does is checking if terms_accepted
is not nil.
Type casting
What we need here is a type casting of the String
values to the correct types. We could do it by hand but why not re-use what ActiveRecord
does in this matter?
Let’s refine our FormObject::define_attributes
method:
def self.define_attributes(model, *names)
self.attribute_names = (attribute_names || []) + names.map(&:intern)
self.model = model
if Rails::VERSION::STRING > '5'
cast_method = :cast
else
cast_method = :type_cast_from_user
end
attribute_names.each do |name|
define_method name do
@attributes ||= {}
@attributes[name.intern]
end
define_method "#{name}=" do |v|
@attributes ||= {}
column = model.columns.find{ |c| c.name.intern == name }
@attributes[name.intern] = if column
model.connection.type_map.fetch(column.sql_type).send(cast_method, v)
else
v
end
end
end
end
In ActiveRecord 4.2 the method was called type_cast_from_user
hence the check for the rails version.
Conclusion
Of course the type casting will only work when the attribute defined on the form object has the same name as the column in the table. But I guess it’s a sensible assumption to make.
I find this way of developing rails application more flexible than using the “canonical” MVC. Especially I like the fact that each of the form object maps to a different context in which a resource can be persisted. This way I have a lot of room for putting anything that is related to that given context in a form object instead of placing everything in the model.
The full working example of this implementation can be found here.