They Don't All Have To Be ActiveRecord Models

Published 01 Sep 2023 · 15 min read
Learn how to handle forms in Rails when the UI does not map one to one with the underlying data model.

Rails makes it relatively easy to go from an idea to a working web application. If you follow along with the Getting Started with Rails guide, you can see how straightforward it is to go from an idea of a blog application with articles that have that have a title and body text, to creating a database table that stores articles, a model to represent the articles and validation rules, views that render HTML to display articles, and a controller to handle http request/responses, and delegate to the model to perform the CRUD operations.

There's also plenty of online tutorials and courses that cover creating a Rails application from start to finish for other domains besides a blogging application. Going through any of these will give you a sense of how Rails can be used to represent just about any domain where you want to build a web app to expose CRUD operations on a relational data model.

However, all of the learning materials that I have seen make an assumption that there is always a one to one relationship between each form field that will be displayed in the web view, and the underlying database table that will be persisted. For example a Pluralsight course I took on Adding Resources to a Rails Application covered building a cryptocurrency tracking application. Here's the new cryptocurrency creation form from the course, notice that the fields shown in the form map neatly to the underlying table:

not all ar cryptocurrency table vs form

This symmetry between the UI form and the underlying table is very nice for learning. But what I've found in the "real world" is that often, the interface presented in the web view does not exactly match the database table, due to complex business requirements. Then it's not obvious how to make use of all the Rails niceties around form binding to the model, validations and saving. This post will show a technique that can be used when there's a mismatch between a form presented to a user, and what needs to be persisted in the database.

Example

Consider an example of a back office for some application where administrators will fill out a form to create new customers. The customers table will persist email, first and last names. However, the form also has a field for the customers age. It's used for validation where customer must be greater than a certain age, but it will not be persisted to table, therefore its not part of customer model. A more complex case might be that the form asks for SIN/SSN to validate against an external service, but this value is not required to be persisted.

Here's a visual, notice that most of the fields in the new customer creation form map to the customers table, but the "age" field does not, because the requirements are that it should not be persisted in the database:

not all ar customer table vs form

If we ignore the complexity of the "age" field for the moment, the majority of this requirement could be implemented with the scaffold generator. This will create a migration, model, controller, and all the CRUD views for the Customer model, specifying the fields that should be persisted for each customer:

bin/rails generate scaffold customer email:string first_name:string last_name:string

Here is the migration that creates the customers table. Notice there is no :age column because the requirements are that it should not be persisted. I've added not null constraints on all the fields, and a unique constraint on the :email field:

class CreateCustomers < ActiveRecord::Migration[7.0]
  def change
    create_table :customers do |t|
      t.string :email, null: false, index: { unique: true }
      t.string :first_name, null: false
      t.string :last_name, null: false

      t.timestamps
    end
  end
end

Here is the corresponding Customer model. I've added some validations for the email, first, and last name fields. I'm also using the annotate gem to get schema information outputted as comments at the top of the model class:

# == Schema Information
#
# Table name: customers
#
#  id         :integer          not null, primary key
#  email      :string           not null
#  first_name :string           not null
#  last_name  :string           not null
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
# Indexes
#
#  index_customers_on_email  (email) UNIQUE
#
class Customer < ApplicationRecord
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :first_name, presence: true
  validates :last_name, presence: true
end

First Attempt

Suppose in this hypothetical app, that the customer's age must be greater than 18. If you were to attempt to implement the age requirement in the standard Rails way as per the getting started guide and common tutorials, you might add the validation rule to the Customer model such as:

class Customer < ApplicationRecord
  # validation rules based on fields in the `customers` table
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :first_name, presence: true
  validates :last_name, presence: true

  # this won't work as we'll see because `age` is not a column on the `customers` table
  validates :age, presence: true, numericality: { only_integer: true, greater_than: 18 }
end

Add the age field to the customer creation form in the view:

<%= form_with(model: customer) do |form| %>
  <!-- other customer fields -->

  <!-- try to bind the `age` field even though it does not exist on customer model -->
  <div>
    <%= form.label :age, style: "display: block" %>
    <%= form.number_field :age %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

Update the CustomersController to permit :age as a parameter for creation:

class CustomersController < ApplicationController

  # standard CRUD methods...

  # POST /customers or /customers.json
  def create
    @customer = Customer.new(customer_params)

    respond_to do |format|
      if @customer.save
        format.html { redirect_to customer_url(@customer), notice: "Customer was successfully created." }
        format.json { render :show, status: :created, location: @customer }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @customer, status: :unprocessable_entity }
      end
    end
  end

  private

  def customer_params
    # try to let `age` field through from the UI form
    params.require(:customer).permit(:email, :first_name, :last_name, :age)
  end
end

When attempting to save a new customer, the following error will result, indicating that the record could not be saved due to unknown attribute age:

ActiveModel::UnknownAttributeError (unknown attribute 'age' for Customer.
  raise UnknownAttributeError.new(self, k.to_s)
^^^^^):

Solution

This type of problem can be solved with ActiveModel. This is a module in Rails that provides a way to create model-like objects that have many of the same features as traditional Rails models, but are not necessarily backed by a database table. This is useful when modeling data that is not directly related to the application's database, which is exactly what we need to solve this example.

The steps in this section will explain what needs to be changed from the scaffolded code to implement the requirement of having a field in the create form that is used for validation, but not persisted. You can also check out this project that demonstrates the complete solution.

CustomerForm

Start by introducing a CustomerForm model. This will be used instead of the Customer model to bind to the form that admin users fill out to create a customer. This class lives in the same models directory as all the other Rails models, but rather than inheriting from ApplicationRecord as the other models that are backed by a database table, it includes the ActiveModel::Model module:

# app/models/customer_form.rb
class CustomerForm
  include ActiveModel::Model
end

Next, define attributes for all the fields that are needed for the customer form. Unlike with an ActiveRecord model, Rails doesn't create these automatically because there is no backing database table. Notice the attributes don't necessarily have to match columns in the customers table because this model is not attached to any table:

# app/models/customer_form.rb
class CustomerForm
  include ActiveModel::Model

  attr_accessor :email, :first_name, :last_name, :age
end

Including ActiveModel::Model, provides access to the validation methods that are available with ActiveRecord. Let's specify that all the fields are required, email must be a valid (according to the format defined in the RFC 6068 standard for the "mailto" URI scheme), and that age must be numeric and greater than 18:

# app/models/customer_form.rb
class CustomerForm
  include ActiveModel::Model

  attr_accessor :email, :first_name, :last_name, :age

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :age, presence: true, numericality: { only_integer: true, greater_than: 18 }
end

This is a good place to pause, launch a Rails console (bin/rails c), and play around with this model to see some of the behaviour that's now available to the CustomerForm model, as a result of including ActiveModel::Model. Note that CustomerForm can be initialized with a hash of attributes, just like an Active Record object, and the same validation methods are available as with Active Record:

customer_form = CustomerForm.new
# => #<CustomerForm:0x0000000112e41408>

customer_form.valid?
# => false

customer_form.errors.full_messages
# => [
#       "Email can't be blank",
#       "Email is invalid",
#       "First name can't be blank",
#       "Last name can't be blank",
#       "Age can't be blank",
#       "Age is not a number"
#     ]

customer_form = CustomerForm.new(
  email: "sarah@example.com",
  first_name: "Sarah",
  last_name: "Smith",
  age: 46
)
# => #<CustomerForm:0x000000011496b878 @age=46, @email="sarah@example.com", @first_name="Sarah", @last_name="Smith">

customer_form.valid?
# => true

customer_form.age = 6
customer_form.valid?
# => false

customer_form.errors.full_messages
# => ["Age must be greater than 18"]

Nice, so we can have (nearly) all the validations that are on the Customer model, plus some additional ones that aren't backed by a table column. The uniqueness: true validation isn't available on ActiveModel because that requires access to a database table to verify a unique value among all the existing records. Since email should be unique across all customers, this can be implemented with a custom validate method. The API is the same as adding custom validation methods to an ActiveRecord object:

class CustomerForm
  include ActiveModel::Model

  attr_accessor :email, :first_name, :last_name, :age

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :age, presence: true, numericality: { only_integer: true, greater_than: 18 }

  validate :email_available?

  def email_available?
    errors.add(:email, "already taken") if Customer.find_by(email:)
  end
end

Let's try this version of CustomerForm in a Rails console to see how the custom uniqueness validation works:

# First save a customer to the database
customer = Customer.create(email: "alice@example.com", first_name: "Alice", last_name: "Jones")
# ... TRANSACTION (1.7ms)  commit transaction

# Create a CustomerForm object with an email that has already been taken
customer_form = CustomerForm.new(email: "alice@example.com", first_name: "Alice", last_name: "Parker", age: 41)

# Checking if its valid queries the customer table by email
customer_form.valid?
# Customer Load (0.2ms)  SELECT "customers".* FROM "customers" WHERE "customers"."email" = ? LIMIT ?  [["email", "alice@example.com"], ["LIMIT", 1]]
# => false

# Error message indicates email is already taken
customer_form.errors.full_messages
# => ["Email already taken"]

The last thing we'll need on the CustomerForm model is the ability to save a customer to the database. The save method should return false if the model is invalid, otherwise create and save an instance of the Customer model. Let's also make the saved Customer model an instance variable so that it can be accessed by the controller to populate the show view.

The final CustomerForm class:

# app/models/customer_form.rb
class CustomerForm
  include ActiveModel::Model

  attr_accessor :email, :first_name, :last_name, :age
  attr_reader :customer

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :age, presence: true, numericality: { only_integer: true, greater_than: 18 }

  validate :email_available?

  def email_available?
    errors.add(:email, "already taken") if Customer.find_by(email:)
  end

  def save
    return false unless valid?

    @customer = Customer.new
    @customer.email = email
    @customer.first_name = first_name
    @customer.last_name = last_name
    @customer.save
  end
end

CustomersController New

Now that we have the CustomerForm class that implements all the required validations and the ability to save a Customer record, it needs to be integrated into the customer creation workflow. The first step is to modify the CustomersController#new method to make a CustomerForm instance variable available to the new view instead of a Customer instance variable:

class CustomersController < ApplicationController
  # GET /customers/new
  def new
    # This line is the standard Rails way to make the Active Record model instance available to the view:
    # @customer = Customer.new

    # Instead, let's make our ActiveModel model available:
    @customer_form = CustomerForm.new
  end
end

New View and Form

The next step is to modify the new view to use the CustomerForm object instead of a Customer object, and pass it to a new form partial that will render the customer creation form. Note that the scaffold command used earlier generated a _form.html.erb partial to be used for both creating and editing customer instances. We're changing this to introduce a _form_new.html.erb partial since the customer creation requirements are different from editing:

<!-- app/views/customers/new.html.erb -->
<h1>New customer</h1>

<%= render "form_new", customer_form: @customer_form %>

<br>

<div>
  <%= link_to "Back to customers", customers_path %>
</div>

Here is the form that binds the form fields to the @customer_form instance variable passed to it by the new view. Notice the url: customers_path is specified for the form_with helper, otherwise the view will attempt to render a url customer_forms_path by convention of the model name, but this does not exist. Note also we pass the label "Create Customer" to the form.submit helper, otherwise by convention of the model name, it would render a label "Create Customer form".

<!-- app/views/customers/_form_new.html.erb -->
<%= form_with(model: customer_form, url: customers_path) do |form| %>
  <% if customer_form.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(customer_form.errors.count, "error") %> prohibited this customer from being saved:</h2>

      <ul>
        <% customer_form.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :email, style: "display: block" %>
    <%= form.text_field :email %>
  </div>

  <div>
    <%= form.label :first_name, style: "display: block" %>
    <%= form.text_field :first_name %>
  </div>

  <div>
    <%= form.label :last_name, style: "display: block" %>
    <%= form.text_field :last_name %>
  </div>

  <div>
    <%= form.label :age, style: "display: block" %>
    <%= form.number_field :age %>
  </div>

  <div>
    <%= form.submit "Create Customer" %>
  </div>
<% end %>

CustomersController Create

When the "Create Customer" button is clicked from the new form, it will submit a POST request to the CustomersController#create method. This method must be modified to use the CustomerForm model instead of Customer. We also need to define a customer_form_params method to specify the allowed fields for customer creation.

If the customer is saved successfully, the create method should redirect to the show view, providing the customer instance variable of the customer_form object. Otherwise, render the new view with a 422 Unprocessable Entity HTTP response code. Since the @customer_form instance variable has been set, the new view will be able to iterate over its errors property and display them:

# app/controllers/customers_controller.rb
class CustomersController < ApplicationController

  # POST /customers or /customers.json
  def create
    # Initialize the ActiveModel with form parameters from the view
    @customer_form = CustomerForm.new(customer_form_params)

    respond_to do |format|
      # `save` is a custom method defined in `CustomerForm` that first calls `valid?`
      if @customer_form.save
        # If `save` returns true, redirect to `show` view passing in the saved customer
        format.html { redirect_to customer_url(@customer_form.customer), notice: "Customer successfully created." }
        format.json { render :show, status: :created, location: @customer }
      else
        # If `save` returns false, render the `new` view to display the errors in `customer_form`:
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @customer_form.errors, status: :unprocessable_entity }
      end
    end
  end

  private

  # Specify allowed fields for the CustomerForm model
  def customer_form_params
    params.require(:customer_form).permit(:email, :first_name, :last_name, :age)
  end
end

Try It

Putting all of these code changes together, we can now navigate to the new customer create form at http://localhost:3000/customers/new, and fill it in with an age that is invalid:

not all ar invalid form

Here is the rendered HTML form, notice the name attributes of the input fields are like customer_form[field] rather than customer[field]. This is because we're binding to the CustomerForm ActiveModel rather than the Customer Active Record model:

<form action="/customers" method="post">
  <input type="hidden" name="authenticity_token" value="Lda1K...">

  <div>
    <label for="customer_form_email">Email</label>
    <input type="text" name="customer_form[email]" id="customer_form_email">
  </div>

  <div>
    <label for="customer_form_first_name">First name</label>
    <input type="text" name="customer_form[first_name]" id="customer_form_first_name">
  </div>

  <div>
    <label for="customer_form_last_name">Last name</label>
    <input type="text" name="customer_form[last_name]" id="customer_form_last_name">
  </div>

  <div>
    <label for="customer_form_age">Age</label>
    <input type="number" name="customer_form[age]" id="customer_form_age">
  </div>

  <div>
    <input type="submit" name="commit" value="Create Customer">
  </div>
</form>

After clicking the "Create Customer" button, the request and response is logged to the Rails server output. Notice the customer_form parameter rather than customer due to the use of the CustomerForm ActiveModel.

Started POST "/customers"
Processing by CustomersController#create as TURBO_STREAM
  Parameters: {
    "authenticity_token"=>"[FILTERED]",
    "customer_form"=>{
      "email"=>"jane@example.com",
      "first_name"=>"Jane",
      "last_name"=>"Doe",
      "age"=>"3"
    },
    "commit"=>"Create Customer"
  }
  Customer Load (0.5ms)
    SELECT "customers".* FROM "customers" WHERE "customers"."email" = ? LIMIT ?
    [["email", "jane@example.com"], ["LIMIT", 1]]
  ↳ app/models/customer_form.rb:15:in `email_available?'
  Rendering customers/new.html.erb within layouts/application
  Rendered customers/_form_new.html.erb
Completed 422 Unprocessable Entity

Since there is an error with the age attribute in the customer_form instance variable set by the CustomersController, it will be displayed in the new form:

not all ar form error

Let's correct the error by setting an age greater than 18 and submitting the form again:

not all ar valid form

This time, the Rails server output shows a new customers record being inserted into the database, then redirecting to the customers show view at http://localhost/customers/{id}:

Started POST "/customers"
Processing by CustomersController#create as TURBO_STREAM
  Parameters: {
    "authenticity_token"=>"[FILTERED]",
    "customer_form"=>{
      "email"=>"jane@example.com",
      "first_name"=>"Jane",
      "last_name"=>"Doe",
      "age"=>"19"
    },
    "commit"=>"Create Customer"
  }
  Customer Load (9.8ms)
    SELECT "customers".* FROM "customers" WHERE "customers"."email" = ? LIMIT ?
    [["email", "jane@example.com"], ["LIMIT", 1]]
  ↳ app/models/customer_form.rb:15:in `email_available?'
  TRANSACTION (0.1ms)  begin transaction
  Customer Create (3.3ms)  INSERT INTO "customers"
    ("email", "first_name", "last_name", "created_at", "updated_at")
    VALUES (?, ?, ?, ?, ?)
    [["email", "jane@example.com"], ["first_name", "Jane"], ["last_name", "Doe"],
    ["created_at", "2023-03-24 11:22:45"], ["updated_at", "2023-03-24 11:22:45"]]
  ↳ app/models/customer_form.rb:25:in `save'
  TRANSACTION (1.7ms)  commit transaction
  ↳ app/models/customer_form.rb:25:in `save'
Redirected to http://localhost:3000/customers/12
Completed 302 Found

And here is the customer show view with the flash message that it was successfully created (this portion is directly from the scaffolded code):

not all ar success

Tests

Before we can conclude that we're done, the CustomerForm model should have automated tests. I'm using RSpec on this project. A really nice feature of including the ActiveModel module is the validation rules can be tested with shoulda matchers, just like an Active Record model. Here are the tests for the validation rules, and the save method:

require "rails_helper"

RSpec.describe CustomerForm, type: :model do
  subject(:customer_form) { described_class.new }

  describe "validations" do
    it { should validate_presence_of(:email) }
    it { should allow_value("email@example.com").for(:email) }
    it { should_not allow_value("email").for(:email) }
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_presence_of(:age) }
    it { should validate_numericality_of(:age).only_integer.is_greater_than(18) }

    context "when email is already taken" do
      let!(:customer) { create(:customer, email: "email@example.com") }

      before { customer_form.email = "email@example.com" }

      it { should_not be_valid }
      it "has an error on email" do
        customer_form.valid?
        expect(customer_form.errors[:email]).to include("already taken")
      end
    end
  end

  describe "#save" do
    context "when form is valid" do
      before do
        customer_form.email = "this_is_good@test.com"
        customer_form.first_name = "Fred"
        customer_form.last_name = "Flinstone"
        customer_form.age = 44
      end

      it "creates a new customer" do
        expect { subject.save }.to change(Customer, :count).by(1)
      end

      it "returns true" do
        expect(subject.save).to be_truthy
      end

      it "sets the customer attribute" do
        subject.save
        expect(subject.customer).to be_a(Customer)
      end
    end

    context "when form is invalid" do
      before do
        allow(subject).to receive(:valid?).and_return(false)
      end

      it "does not create a new customer" do
        expect { subject.save }.not_to change(Customer, :count)
      end

      it "returns false" do
        expect(subject.save).to be_falsey
      end

      it "does not set the customer attribute" do
        subject.save
        expect(subject.customer).to be_nil
      end
    end
  end
end

Conclusion

This post has demonstrated how to use Active Model in a Rails application when the form presented to the user does not exactly match what should be persisted in the database. We just scratched the surface of what's available from the ActiveModel module with regards to validation. Be sure to check out the Active Model Basics Rails Guides to learn about all the available features.