/associates

Multi-model objects

Primary LanguageRubyMIT LicenseMIT

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.

Gem Version Code Climate Coverage Status Dependency Status Build Status associates API Documentation

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

  1. Fork it
  2. Create a topic branch
  3. Add specs for your unimplemented modifications
  4. Run bundle exec rspec. If specs pass, return to step 3.
  5. Implement your modifications
  6. Run bundle exec rspec. If specs fail, return to step 5.
  7. Commit your changes and push
  8. Submit a pull request
  9. Thank you!

Inspiration

Author

Philippe Dionne

License

See LICENSE