Disciplined Rails: Form Object Techniques & Patterns — Part 2

Dealing with Collections

Jaryl Sim
9 min readAug 19, 2019

Part 1: Ground rules for form objects
Part 2: Dealing with collections
Part 3: Tying up loose ends

Do skim through the first part even if you are already well versed with form objects, as the techniques discussed here do build on the first part. You can find the accompanying code here:

To continue where we left off in part 1, the form object allows you to decouple concerns between the view template, and your models (and to a lesser degree your controllers). The form object allows you to create view templates that makes the most semantic sense, while mapping your model’s outputs to sensible inputs to your views.

Dealing with Collections

When it comes to dealing with collections, things get a little bit trickier, and while rails has some built-in mechanisms (like accepts_attributes_for) for dealing with collections, using them can get a bit clumsy when everything’s crammed into the same model.

I’ve also used gems like cocoon to some success, which is useful if you want the user to be able to dynamically add more records using JavaScript. Some of these techniques can be adapted to be used in conjunction with cocoon, but that’s out of scope for this series.

Example of a non-ruby of collection.

When dealing with a collection, there are many concerns to deal with like object lifecycles, adding/removing elements, etc. A lot of these complexities can wreak havoc on the controller, which you can see here, an article on creating multiple objects in rails, without a form object:

I don’t mean to call out other rails developers here, but this is a bit of a mess, where you do the bulk of the data wrangling across both the controller and view.

Array of Email Strings

Let’s start with an example where we are working with a collection of emails, which are just plain strings:

class UserInvitationForm  include ActiveModel::Model  attr_accessor :emails  validate :at_least_one_email  def initialize(params = {})
super(params)
@emails ||= ['', ''] # to show two fields in the view
end
def submit
return false if invalid?
emails.each { |email| puts email } # do something with it
end
private def at_least_one_email
errors.add(:emails, 'are blank') if emails.blank?
end
end

Note that there are no ActiveRecord objects here; just plain ruby strings in fact. As such, you don’t have to add anemails_attributes= method because rails supports arrays of strings directly.

Your controller now looks like this:

def new
@form = UserInvitationForm.new
end
def create
@form = UserInvitationForm.new(user_invitation_form_params)
if @form.submit
redirect_to root_path, notice: 'Thank you for your enquiry'
else
render :new
end
end
privatedef user_invitation_form_params
params.require(:user_invitation_form).permit(emails: [])
end

Neat. Your view template looks like this:

= simple_form_for @form, url: invitations_path do |f|  = f.object.emails.each do |email|
= f.input :emails, input_html: { multiple: true, value: email }
= f.submit 'Send'

If you’re following along, you‘d’ have noticed that there’s no way to add more email fields. If you want to add more email fields, you’re going to need some JavaScript, which again is out of scope. However, just know that rails automatically builds an array out of the available email values, and that this is the simplest form of handling collections in forms.

In your strong parameters, do make sure that you are letting an array through as such: permit(emails: []).

When you inspect your params hash, you’ll see that you do indeed get an array of emails, and if you inspect your HTML source, you’ll see that your form inputs are given the names user_invitation_form[emails][]. This little big of magic, is what we’re taking advantage of whether we are working with a collection of strings, or ActiveRecord models.

Collection of Virtual Models

The next step up from here is to construct an actual model around the invitation, so that we can layer additional business logic around. For example, we want to check if the user has already been invited to our application, and not send out an invitation. We could work with the raw email array directly, but let’s build a model to house our logic, starting with a non-ActiveRecord one.

class Invitation  include ActiveModel::Model  attr_accessor :name, :email  validates :name, :email, presence: true
validate :user_already_registered
private def user_already_registered
errors.add(email: 'already registered') if Person.exists?(email: email)
end
end

This virtual model looks like our form object implementation, except that there is no save or submit method. So even though it’s not the entry point of a form we could still say that it is also a form object, because it’s an object that’s used in a form. That’s up for debate, but let’s carry on.

There’s actually nothing complicated here, so let’s see how we can use it in the user invitation form, reproduced here since there are quite a lot of changes.

class UserInvitationForm  include ActiveModel::Model  attr_accessor :invitations  validate :at_least_one_invitation, :invitations_are_valid  def initialize(params = {})
super(params)
@invitations ||= [Invitation.new]
end
def invitations_attributes=(values)
@invitations = values.map { |_key, params| Invitation.new(params) }
end
def submit
return false if invalid?
@invitations.each do |invitation|
# deliver invitation mailer
end
true
end
private def at_least_one_invitation
errors.add(:invitations, 'are blank') if invitations.empty?
end
def invitations_are_valid
errors.add(:invitations, 'are invalid') if invitations.any?(&:invalid?)
end
end

The only thing of note here is how we construct the invitation objects, and put them in the instance variable. To know how this works, we need to look at how rails passes data to us in the params hash. If you can recall from part 1, once rails detects the invitations_attributes= method, they will line up the params to look something like this:

{
"0" => { "name" = "John Doe", "email" => "john.doe@example.net" },
"1" => { "name" = "Jane Doe", "email" => "jane.doe@example.net" }
}

Compared to the previous example that dealt with emails directly as strings, it is important to note the separation of the attributes and the constructed model instances itself. If we look at the names of the form elements that are generated, you’ll see that instead of user_invitation_form[emails][] for a simple array, we get user_invitation_form[invitations_attributes][0][name].

In case of the latter, we now get an index that accompanies each record, allowing rails to keep track of our data in between multiple submissions (for e.g. due to validation errors). This same mechanism is used when we also need to deal with objects that we load from the database for editing, which we cover next.

Collection of ActiveRecord Models

If you take the above example, and turn the invitation model into full-fledged, database-backed, ActiveRecord model, it is going to look very much the same, except that you will need to save the records in the submit method.

You may want to wrap things up in a database transaction, so the submit method will look like this:

def submit
return false if invalid?
ActiveRecord::Base.transaction do
@invitations.each(&:save!)
end
true # exception handling is omitted for brevity
end

Let’s look at something more complex though; when working with ActiveRecord, we typically need to deal with associations between the models which can trip a lot of people up, or at least result in a lot of unnecessary complexity.

We’ll look at a common e-commerce scenario, where we will be working with an order model, which has many line items, and belongs to a person, so we get to see how to handle both parent and child associations in the same form.

Let’s look at the form object first.

class OrderForm  include ActiveModel::Model  attr_accessor :shipping_address, :billing_address, :shipping_same_as_billing_address  attr_reader :person, :order, :shipping_same_as_billing_address  delegate :line_items, to: :order  validates :shipping_address, presence: true
validates :billing_address, presence: true, unless: :shipping_same_as_billing_address?
validate :order_is_valid, :line_items_are_valid def initialize(person, params = {})
@person = person
@shipping_same_as_billing_address = true
@order = @person.orders.build
super(params)
@order.line_items.build if order.line_items.empty?
end
def line_items_attributes=(values)
order.line_items.clear
values.each { |_key, params| order.line_items.build(params) }
end
def submit
@order.attributes = order_params
return false if invalid?
@order.save
true
end
def shipping_same_as_billing_address=(value)
@shipping_same_as_billing_address = ActiveModel::Type::Boolean.new.cast(value)
end
def shipping_same_as_billing_address?
@shipping_same_as_billing_address
end
private def order_params
{
order_no: 'some-generated-number',
shipping_address: shipping_address,
billing_address: billing_address
}
end
def order_is_valid
errors.add(:order, 'is invalid') if order.invalid?
end
def line_items_are_valid
errors.add(:line_items, 'are invalid') if line_items.any?(&:invalid?)
end
end

The form is definitely more complex, and there are a couple of new techniques introduced, but structurally the form object is the same. The only structural change is that we now accept a person object as the first param in the initializer. We use this for establishing ownership (over order), but it’s just a regular parameter and could also be used for validation, or other purposes.

Setting the owner of the order was easy, but working the association between order and line items is a bit more complicated. For this implementation, we delegate line items at the form level to the order itself, and when we get some parameters, we clear the line items, and recreate them on the order itself when we receive params through the line_items_attributes= method. This is convenient, but will not work with already persisted objects because we are reconstructing the records each time.

Another way we can handle this association is to make use of rails’ nested attributes features, which I mentioned earlier could be something we avoid so that we don’t tie our form UI to our data model. However, line items are indeed associated to orders (quite inextricably too), so this is actually a valid use case for nested attributes. What we don’t want to do is use the advanced options available to us and twist nested attributes into the frankenstein we need; stick with simple associations.

Don’t worry though, we can have our cake and eat it too.

First, look in the private section where we defined the method order_params. This technique is useful to clean up the submit method, and express very clearly what are the variables needed to save the order object but it’s more than a convenience method. It is in fact a mini-adapter pattern at work, and is a great example of how we map the form’s interface to our data model.

To make use of nested attributes, we need only change the order_params method, and use attr_accessor to store the incoming params rather than rolling our own method for line items:

attr_accessor ... :line_item_attributes# ...def order_params
{
order_no: 'some-generated-number',
shipping_address: shipping_address,
billing_address: billing_address,
line_items_attributes: line_items_attributes
}
end

You also need to make sure that your order model calls accepts_nested_attributes_for on line items. Here you can define the behavior you want, such as rejecting blank values, etc, as per your knowledge of nested attributes.

We aren’t doing anything magical here, we are merely offloading the params to our order model to handle using nested attributes. Don’t forget to delete the line_items_attributes= which will otherwise intercept our params.

Even in situations where you need more control over how you create child objects, I would stick to using nested attributes especially if we are dealing with persisted records. Dealing with the ActiveRecord lifecycle is often more trouble than it’s worth, and will be a source of bugs.

Alternatively, I’ve also found it easier to work with virtual models that then apply changes to ActiveRecord objects if there is a need for more control. Again, this brings us back to isolating the data model from the UI, but in this case from our business logic.

To recap part 2, we’ve covered how to work with forms that deal with multiple records, whether they be simple strings, or a hierarchy of more complex models. We can even mix and match ActiveRecord and virtual models, as long as we adhere to the naming rules of having the right ‘thing_attributes=’ method.

Virtual models give us an opportunity to define our validations and other form logic more declaratively, and avoid writing imperative code that is intertwined with the structure of our inputs or outputs. Virtual models are easily testable, and allows us to keep our basic rails lifecycle (just call save on the record).

When working with objects at different levels of the hierarchy, we can flatten it so that our form just works with what it needs . In our order form example, we could also have our form object deal with orders and line items at the same level, i.e. our form object has a collection of line items, and a single order object that we associate upon submit as opposed to in the initializer. The UI is none the wiser and can just use fields_for to work with each of them like they were both simply nested under the form object. This is the approach that I’ve favored over time.

To iterate what we learnt in part 1, we want to keep user-facing data in the view templates, and keep our data in the most performant configuration in our data model. This gives us our inputs and outputs for our form, and we just have to work to translate them in transit.

When we can make changes to the UI, we merely have to update the form object, and its translation code, our models can (or should) stay the same. The opposite should even be true when we are making minor changes to our models.

I have plans for one more article on rails forms, and that is dealing with complex search forms.

--

--

Jaryl Sim

Tech Activist | Creator of RssTogether, LocalVerse | Mastodon: @jaryl@merlion.social