Associates
Associate multiple models together and make them behave as one. Quacks like a single Model for the Views (validations, errors, form endpoints) and for the Controller (restful actions). Also a great alternative to #accepts_nested_attributes_for
.
You might want to check out apotonick/reform to handle more complex situations.
Update: The Rails core team is now working on Activeform and I would strongly suggest to use it instead of this one.
Usage
# app/forms/guest_order
class GuestOrder
include Associates
associate :user
associate :order, only: :product, depends_on: :user
associate :payment, depends_on: :order
end
# app/models/user
class User < ActiveRecord::Base
validates :username, :password, presence: true
end
# app/models/order
class Order < ActiveRecord::Base
attr_accessor :product
belongs_to :user
validates :user, :product, presence: true
end
# app/models/payment
class Payment < ActiveRecord::Base
attr_accessor :amount
belongs_to :order
validates :order, presence: true
end
# config/routes
resource :guest_orders, only: [:new, :create]
# app/controllers/guest_orders_controller
class GuestOrdersController < ApplicationController
def new
@guest_order = GuestOrder.new
end
def create
@guest_order = GuestOrder.new(permitted_params)
if @guest_order.save
sign_in @guest_order.user
redirect_to root_path
else
render action: :new
end
end
private
def permitted_params
params.require(:guest_order).permit(:username, :password, :product, :amount)
end
end
# views/guest_orders/_form.html.erb
<%= form_for @guest_order do |f| %>
<%= f.text_field :username %>
<%= f.text_field :password %>
<%= f.text_field :product %>
<%= f.text_field :amount %>
<%= f.submit %>
<% end %>
Validations
For the object to be valid, every associated model must be valid too. Associated models' errors are traversed and added to the form object's error hash.
o = GuestOrder.new(username: nil, password: '12345', product: 'surfboard', amount: 20)
o.valid?
# => false
o.errors.messages
# => { username: [ "can't be blank" ] }
When an attribute is invalid and isn't defined on the object including Associates, the corresponding error is added to the :base
key.
o = GuestOrder.new(username: 'phildionne', password: '12345', product: 'surfboard')
o.valid?
# => false
o.errors[:base]
# => "Amount can't be blank"
Persistence
Calling #save
will persist every associated model. By default associated models are persisted inside a database transaction: if any associated model can't be persisted, none will be. Read more on ActiveRecord transactions. You can also override the #save
method and implement a different persistence logic.
o = GuestOrder.new(username: 'phildionne', password: '12345', product: 'surfboard', amount: 20)
o.save
[o.user, o.order, o.payment].all?(&:persisted?)
# => true
Associations
belongs_to
associations between associated models can be handled using the depends_on
option:
class GuestOrder
include Associates
associate :user
associate :order, depends_on: :user
end
o = GuestOrder.new
o.user = User.find(1)
o.save
o.order.user
# => #<User id: 1 ... >
or by declaring an attribute which will define a method with the same signature as the foreign key setter:
class GuestOrder
include Associates
associate :order, only: :user_id
end
Delegation
Associates works by delegating the right method calls to the right models. By default, delegation is enabled and will define the following methods:
class GuestOrder
include Associates
associate :user
end
#user
#user=
#username
#username=
#password
#password=
You might want to disable delegation to avoid attribute name clashes between associated models:
class GuestOrder
include Associates
associate :user
associate :referring_user, class_name: User, delegate: false
end
#user
#user=
#username
#username=
#password
#password=
#referring_user
#referring_user=
or granularly select each attribute:
class GuestOrder
include Associates
associate :user, only: [:username]
associate :order, except: [:product]
end
#user
#user=
#username
#username=
#order
#order=
An alternative to the current nested forms solution
I'm not a fan of Rails' current solution for handling multi-model forms using #accepts_nested_attributes_for
. I feel like it breaks the Single Responsibility Principle by handling the logic on one of the models. Add just a bit of custom behavior and it usually leads to spaghetti logic in the controller and the tests. Using Associates to refactor nested forms logic into a multi-model object is a great fit.
Contributing
- Fork it
- Create a topic branch
- Add specs for your unimplemented modifications
- Run
bundle exec rspec
. If specs pass, return to step 3. - Implement your modifications
- Run
bundle exec rspec
. If specs fail, return to step 5. - Commit your changes and push
- Submit a pull request
- Thank you!
Inspiration
- rafBM's presentation at OpenCode XII
- Thoughtbot's Harlow Ward ActiveModel Form Objects post
- Ryan Bates Form objects' railscast
Author
License
See LICENSE