One of the ironies of working with Ruby on Rails is that making a feature do less often results in more work. For a great example of this, consider the ingredients that go into a standard Rails form: route, controller, ERB template,Active Record model. If you do things “The Rails Way”, we could have everything working in just a few minutes.
Now take that form and swap out the Active Record models in favor of plain ol' Ruby objects. Suddenly nothing works! (That’s what we get for going off the Rails, I guess.)
I recently had to work through this myself, but arrived at a surprisingly simple solution enabled by the sorta-public-but-still-kinda-private Attributes API.
In this post, we’ll walk through why rendering forms for non-Active Record model objects is so difficult and how mixing in the Attributes API can make things an awful lot easier on ourselves.
How might we make a filterable list?
“Why wouldn’t you want to persist a form?”, a hypothetical antagonist might ask. You might respond that one good example of an ephemeral, unpersisted form would be criteria fields used to filter a list of items. Something like this, to illustrate:
Let’s suppose the app will filter a list of items based on the provided criteria. These conditions don’t need to be persisted themselves—they’re inherently ephemeral, and a simple stateless form is all the feature calls for.
In this post, we’ll build the form step-by-step, and we’ll see why the Attributes API is a great choice for capturing values from unpersisted form fields.
Building a form with plain ol’ Ruby objects
Let’s start off by adding a date field.
The easiest thing to do would be to add each filter field as an instance variable, first in the controller action:
@available_on = params[:available_on] || Time.zone.today
And then in the view:
<%= f.date_field :available_on, value: @available_on %>
Storing each field in an instance variable will work, but it won’t scale very well if we have a lot of filters, if a different set of filters should appear based on the type of list, or if certain types of filters might appear multiple times.
Knowing this, we might decide to create a plain ol’ Ruby object (a “PORO”) to encapsulate the user’s input:
class Filter
attr_reader :available_on
def initialize(available_on:)
@available_on = available_on || Time.zone.today
end
end
And update the field:
<%= f.date_field :available_on, value: @filter.available_on %>
Because I tend to write a lot of repetitive-looking PORO value objects, I tend to reach for Struct in cases like this. This value could be reworked as:
Filter = Struct.new(:available_on, keyword_init: true) do
def initialize(**kwargs)
super
self.available_on ||= Time.zone.today
end
end
On first load, all three of the above approaches will render the form fine, and will continue rendering the selected value correctly after each form submission.
However, actually using this date as a Date
will prove problematic. Adding this filter operation to our controller action would work on the initial render:
@items.select { |item| item.available_on <= @filter.available_on }
But after each form submission, it’ll raise an error:
ArgumentError: comparison of Date with String failed
This happens because we’ve gone off the Rails. It’s now our job to cast submitted form values from String
into whatever we intend them to be. The reason we don’t normally need to worry about this when we’re building forms of Active Record models is because Rails looks at the underlying database table and says, “oh, available_on
is a SQL DATE
column, so I’ll parse this"2022-10-05"
string I got from the form as a Ruby Date
”.
Without a database table to inspect, and because form submissions collapse every value into a String
, neither Ruby nor Rails will know what to do for us.Ruby’s dynamic typing is usually a convenience boost, but this is one case where we’ll need to be explicit about types.
We could try to update our Struct to convert the value in the initializer, in a custom writer method, or a custom reader method. Here’s one way we might hack up the initializer:
Filter = Struct.new(:available_on, keyword_init: true) do
def initialize(**kwargs)
super
self.available_on = if kwargs[:available_on].present?
Date.parse(kwargs[:available_on])
else
Time.zone.today
end
end
end
But just look at that. We don’t want a mess like that to propagate every time we add a non-string field to a form! Besides, this doesn’t even work, as this approach would be defeated if anyone called the attribute’s writer method (e.g.@filter.available_on = "2022-10-05"
).
If you’re anything like me, you’ll start thinking about the scope of the rest of the application and the next place your head will go is to ask, “should we extract a utility for defining value classes with typed attributes?” The answer to that question is, of course, no!
Instead (and you may have seen this coming), the Rails Attributes API is abetter answer. It can already do all of this for us, and it would allow us to refactor our value object into:
class Filter
include ActiveModel::Model
include ActiveModel::Attributes
attribute :available_on, :date
end
And the action to:
@filter = Filter.new(available_on: params[:available_on] || Time.zone.today)
This will actually work! Even if instantiated with a string, you’ll always get a Date
back:
filter = Filter.new(available_on: "2022-10-05")
filter.available_on.class # => Date
filter.attributes["available_on"].class # => Date
filter.available_on = "2022-11-01"
filter.available_on.inspect # => "Tue, 01 Nov 2022"
But now that our value object quacks a lot more like a Rails model, we can actually go all the way and pass our Filter
object to the form builder.
Setting an Attributes object as the form builder’s model
We can make things even easier by passing any value that includes ActiveModel::Attributes
as the model:
keyword argument to form_with
:
<%= form_with model: @filter, url: items_path, method: :get do |f| %>
Now the form can take responsibility for setting each field’s value, allowing us to simplify this:
<%= f.date_field :available_on, value: @filters.available_on %>
Into this:
<%= f.date_field :available_on %>
When we make this change, the :available_on
param will no longer be on the top-level of the params
object, but rather grouped under a filter
key, so we need to update the controller action to match:
This change has the added benefit of passing to Filter.new
only what fields are actually present, whereas passing nil
or ""
explicitly to keyword arguments would defeat most approaches to setting default values in an initializer.
@filter = Filter.new(params[:filter].permit(:available_on))
Because this change removes our || Time.zone.today
short-circuit, we’ll need a different way to set a default value for the available_on
. Good news: the Attributes API can help us here, too! All we need to do is add a default:
to the attribute definition:
attribute :available_on, :date, default: -> { Time.zone.today }
Array attributes pair well with multi-select fields
Dates are relatively straightforward, but what about groups of checkboxes—like the ones pictured above, allowing users to filter items by color? Because the Attributes API is the same code that enables Active Record to support Postgres Array columns, we can create array attributes here, as well!
Let’s define a colors
array attribute to our filter:
attribute :colors, array: true, default: -> { ["royal_blue"] }
And add them to our form:
The call to collection_check_boxes is a little tricky, because the method expects as its third and fourth arguments the names of methods for each element’s value and name, respectively. Since we’re passing a 2D array, we can tell Rails to call Array#first
and Array#second
on each item and it’ll “just work”
<%= f.collection_check_boxes :colors, [
["blue", "Blue"],
["royal_blue", "Royal Blue"],
["navy_blue", "Navy Blue"],
["raspberry_blue", "Blue Raspberry"]
], :first, :second %>
And pass them to Filter.new
(note that permit
requires us to pass colors
as an array):
@filter = Filter.new(
params[:filter].permit(:available_on, colors: [])
)
That’s all we need to see the checkboxes appear correctly on initial and post-submission renders of the form. If you look at the value returned, you’ll see an extra element with an empty string (due to a hidden field that Rails includes to ensure a value is submitted even if no items are checked):
> @filter.colors
=> ["", "royal_blue", "navy_blue", "raspberry_blue"]
We can work around this by either passing include_hidden: false
to collection_check_boxes
or defining a custom colors=
writer to scrub the blank value (impressively, the latter will also correctly handle any colors
passed to the initializer):
class Filter
# …
def colors=(colors)
super(colors.reject(&:blank?))
end
end
Writing validations for our attributes
Validations are another great feature of Active Record, but because they’re actually implemented on ActiveModel::Model
we also have access to the validations API in our Filter
objects, as well! (Earlier, we included ActiveModel::Model
along with ActiveModel::Attributes
to ensure the initializer was set up appropriately.)
For this example, let’s start by adding a min and max price as attributes to Filter
:
attribute :min_price, :float, default: -> { 0.00 }
attribute :max_price, :float, default: -> { 100.00 }
And form fields:
<%= f.number_field :min_price %>
<%= f.number_field :max_price %>
Then update the controller’s invocation of Filter.new
to include them:
@filter = Filter.new(params[:filter].permit(
:available_on, :min_price, :max_price,
colors: []
))
Out of the box, there’s nothing stopping a user from specifying a minimum price that’s higher than the maximum price. We could attempt to handle that edge case gracefully by adding a simple validation to ensure that max_price
is equal to or less than min_price
.
But since this class only exists in memory, maybe it makes sense to do something less drastic than show the user an error message. Here’s how we might simply clamp the min_price
field’s value to ensure it does not exceed the max:
class Filter
# …
validate :min_isnt_more_than_max
def min_isnt_more_than_max
if min_price > max_price
self.min_price = max_price
end
end
end
Then if we update our controller action to call @filter.validate
before rendering the form, any min_price
that exceeded the max_price
would be set to the value of max_price
. Nice!
Defining custom attribute types
Discerning readers will have grimaced when they saw a monetary attribute being set to a float
. Currency values are often used in math, and combining fractional floats with division is a great way to end up with an inexact result. Instead, many people store the fractional portion (cents, in the case of dollars) as an integer and then convert the value to an accuracy-preserving BigDecimal or String
representation when presenting the value to a user.
The Attributes API gives us a mechanism for defining and registering custom types that can be helpful in accomplishing this sort of type bifurcation. It’s primarily designed for serializing values to the database and deserializing values from the database, however, so it’s a little finicky for use in stateless forms (as you’ll soon see).
Below, we’ll create a custom type that defines a cast
method which will branch on the not-completely-bulletproof heuristic of assuming Numeric
values are already in cents and any other values will need to be converted from dollars.This should work in our simple case, because every value from the form submission will be a String
object.
So we could start with a custom type like this:
class Cents < ActiveRecord::Type::Integer
def cast(value)
return super if value.is_a?(Numeric)
price_in_dollars = value.to_s.delete("$").to_d
price_in_cents = (price_in_dollars * 100).to_i
super(price_in_cents)
end
end
This way, if a string is set to an attribute of this type (whether from the form via an initializer or by the setter method), any non-Numeric
values will be assumed to be dollar representations and converted to cent integers. And if a Numeric
value is set (for example, if we manually construct the value object from code), it will be left unchanged.
Next, we can globally register our custom attribute type to a symbol name:
ActiveModel::Type.register(:cents, Cents)
Then we can update our price attributes to use the new type:
attribute :min_price, :cents, default: -> { 50_00 }
attribute :max_price, :cents, default: -> { 100_00 }
Note that we also updated our default values from floats to integer cent values.
Now, and here’s the tricky part: how do we make the form display these cent values as dollars? The Attributes API itself doesn’t expose any presentational methods that might do this for us, so we need to define a method that will convert integral cents to dollars. Here’s a class method we could add to Cents
that converts them to strings, replete with a leading $
character to be extra fancy:
class Cents < ActiveRecord::Type::Integer
def self.dollarize(value)
price_in_cents = value.to_d
"$#{"%.2f" % (price_in_cents / 100)}"
end
# …
end
Again, because this is a custom method, we need to call it from the form, which we can do by referencing the attribute value from the form builder, like so:
Min:
<%= f.text_field :min_price, value: Cents.dollarize(f.object.min_price) %>
Max:
<%= f.text_field :max_price, value: Cents.dollarize(f.object.max_price) %>
This approach effectively split our value between a presentational mode (string dollars) and a more useful and portable logical value (integer cents). Caution is warranted, though. Introducing a custom attribute type is a significant enough deviation from the path of least surprise that I’d only consider doing so if it provided enough meaningful expressiveness to make up for the added code complexity.
Building dynamic forms with nested attributes
Suppose that we decide to expand the feature’s functionality so that the number and kinds of filter fields can vary dynamically. To accommodate this, we would need to split up our Filter
object to support a form that grows and shrinks toallow arbitrarily many criteria types.
If you’ve ever tried to generate f.fields_for
over a loop of nested hashes or Struct
objects, it’s likely that all you remember is how painful it was.Rather than document the four or five edge cases that one needs to cover when manually generating nested fields, I’ll instead ask you take my word for it that iterating over an array of ActiveModel::Attributes
objects is a lot easier.
We’ve built up quite a lot of the code in this post line-by-line, but the best way to illustrate this more fundamental change is to share it all at once.
First, if we decide to extract each category of criteria out of the Filter
class and into its own standalone class, here’s what it might look like if wewere trying not to get too fancy with metaprogramming:
module Criteria
def self.type_for(name)
all.find { |criteria_type|
criteria_type.type_name.to_s == name.to_s
}
end
def self.all
[
Price,
AvailableOn,
Colors
]
end
class Base
include ActiveModel::Model
include ActiveModel::Attributes
attribute :id, :integer
end
class Price < Base
attribute :min_price, :float, default: 0.00
attribute :max_price, :float, default: 100.00
def self.type_name
:price
end
end
class AvailableOn < Base
attribute :date, :date, default: Time.zone.today
def self.type_name
:available_on
end
end
class Colors < Base
attribute :colors, array: true, default: ["royal_blue"]
def self.type_name
:colors
end
def self.supported_colors
[
["blue", "Blue"],
["royal_blue", "Royal Blue"],
["navy_blue", "Navy Blue"],
["raspberry_blue", "Blue Raspberry"]
]
end
def colors=(colors)
super(colors.select(&:present?))
end
end
end
It’s longer, sure, but ready to be mixed and matched!
Next, we would need to rewrite our controller action to consider both the initial render flow as well as each re-render when the form is updated:
class ItemsController < ApplicationController
def index
@items = Item.all
@criteria = if params[:criteria_attributes].present? && params[:commit] != "Reset"
params[:criteria_attributes].values.map { |criteria|
criteria_class = Criteria.type_for(criteria[:type_name])
criteria_class.new(criteria.except(:type_name))
}
else
[
Criteria::Price.new(id: 0),
Criteria::AvailableOn.new(id: 1),
Criteria::Colors.new(id: 2)
]
end
end
end
Note that the else
expression above is the base case for rendering an empty form, and it’s here that we define which Criteria
classes are instantiated and in what order. Also, it’s worth noting that any object passed to fields_for
must return a distinct id
in order for Rails to keep it separate it from its siblings in params
. The value itself doesn’t matter, but keeping it sequential makes it easier to debug.
The last big change is to the view:
<div>
<%= form_with url: items_path, method: :get do |f| %>
<ol>
<% @criteria.each.with_index do |criteria, i| %>
<li>
<%= f.fields_for "criteria_attributes[]", criteria do |ff| %>
<%= ff.hidden_field :id, value: i %>
<%= ff.hidden_field :type_name, value: criteria.class.type_name %>
<%= render partial: criteria.class.type_name.to_s,
locals: { ff: ff }
%>
<% end %>
</li>
<% end %>
</ol>
<div>
<%= f.submit value: "Reset" %>
<%= f.submit value: "Update" %>
</div>
<% end %>
</div>
Each of the form fields would then reside in a partial matching their class’s type_name
. In app/views/items/_available_on.html.erb
:
<%= ff.date_field :date %>
In app/views/items/_colors.html.erb
:
<%= ff.collection_check_boxes :colors,
Criteria::Colors.supported_colors, :first, :second %>
And in app/views/items/_price.html.erb
:
Min: <%= ff.number_field :min_price %>
Max: <%= ff.number_field :max_price %>
In aggregate, this might feel like a lot of code, but hopefully it provides some clarity and may even serve as a starting point if you’re looking to build something similar.
The rail less traveled
When working with Ruby on Rails, it can often feel like any deviation from “The Rails Way” is akin to voiding the warranty on a new car: whatever goes wrong,you’re on your own. There were times when this rang true, but it’s genuinely impressive how modular Rails has become without sacrificing the batteries-included defaults that made it famous. And while Rails 7 is still very much omakase, it’s never been more accommodating of individual dietary restrictions.
This problem also serves as an interesting example of the tension between lexical complexity—that is, how much code we carry and how gnarly it is to maintain—as compared to operational complexity—the actual actions a program takes when it is run. Because Rails makes the reasonable assumption that most forms are backed by a database record, writing a database-free form that performs many fewer operations at run-time actually requires significantly more code. Both computing resources and programmer time cost money, but striking the right balance of trade-offs like these often requires careful thought and lots of context.
If your team could use more developers who appreciate the nuanced decisions needed to write great software, then you’re in luck—that’s exactly what Test Double sells! We’d love to talk to you if you might be able to use our help. 💚