🇺🇸 English | 🇪🇸 Español

Nested attributes on a has_many :through polymorphic association with validations in Ruby on Rails

A very common functionality for websites is the possibility of adding tags or categories. In this post, I’m going to implement this functionality in a way that you can categorize different models. In this example, I’m only going to create a Post model but you can repeat this process for any other model you want to.

We’re going to take advantage of the Rails scaffold to generate a model and controller with all the methods at the same time.

Let’s create a Post scaffold:

$ bin/rails g scaffold posts title:string

Add t.string :title, null: false to the migration to add a null constraint to the database. We’ll add the model validation later.

We’re also going to need categories:

$ bin/rails g scaffold categories name:string

Update the migration as we did with the previous one, so the name is mandatory.

Now that we have both Post and Category we need an intermediate model to create the association. I named this model Categorization.

Create the migration explicitly referencing the category. Since we want to categorize any model, not just Post, this association has to be polymorphic. The model that can have categories will be named categorizable.

This is how the model generator code should look like:

$ bin/rails g model categorization category:references categorizable:references{polymorphic}

We don’t want the categorizable model to be associated many times with the same category. Let’s add an index to the database. We’ll add a friendlier error message later in the model.

This is the final code for the migration:

class CreateCategorizations < ActiveRecord::Migration[7.0]
  def change
    create_table :categorizations do |t|
      t.references :category, null: false, foreign_key: true
      t.references :categorizable, polymorphic: true, null: false
      t.index [:category_id, :categorizable_type, :categorizable_id], unique: true,  name: "unique_category_categorizable"

      t.timestamps
    end
  end
end

Run the migrations to create the database tables.

$ bin/rails db:migrate

Now is the time to add some code to the models.

Rails generated our Categorization model with the associations we need. The only need we have to add is the validation to avoid repeated categories on the same categorizable.

class Categorization < ApplicationRecord
  belongs_to :category
  belongs_to :categorizable, polymorphic: true

  validates :category, uniqueness: { scope: [:categorizable], message: "can't be assigned more than once." }
end

The Category has many categorizations. We also want to add a dependency on deletion. If a Category has associated categorizations we shouldn’t be able to destroy it. This way we make sure we won’t delete any category we’re using.

We also want to add a presence and unique name validation.

class Category < ApplicationRecord
  has_many :categorizations, dependent: :restrict_with_exception

  validates :name, presence: true, uniqueness: true
end

Post also has many categorizations but since a categorization is not associated with any specific model, we’re going to specify that Categorization expects a categorizable instead of a Post. Post also has categories through categorizable and adding the relation here will make it super easy to access them with ActiveRecord.

We also want to validate the presence of a title.

class Post < ApplicationRecord
  has_many :categorizations, as: :categorizable, dependent: :destroy
  has_many :categories, through: :categorizations

  validates_presence_of :title
end

With all the validations in place, we can start testing our code from the console.

There are different ways to add categories to a post:

# Create a new category.
Category.create(name: "test")

# Create a post and directly assign it a category. Because of the associations in the models, Rails knows how to do it.
p = Post.new(title: "Hola mundo")
p.categories << Category.last
p.save!


# You can also assign a category explicitly creating a categorization.
p = Post.new(title: "Hola mundo")
p.categorizations.build(category: Category.last)
p.save!

Try creating some categories without names or posts without titles. You should see some errors. Try adding the same category to a post as well. You shouldn’t be able to.

Forms and nested attributes

So far we’ve added posts and categories from the Rails console but on a real web app, the users will interact with the data through forms.

Rails scaffold generated the controllers with all the actions needed to create, show, edit, and destroy categories and posts independently but we want to create categories at the same time we create posts. How can we achieve that?

Rails has a feature called nested attributes that allow us to create associated records at the same time we create the parent.

To create categories at the same time we create posts we should also be able to create categorizations. Update the Post model:

class Post < ApplicationRecord
  has_many :categorizations, as: :categorizable, dependent: :destroy
  has_many :categories, through: :categorizations

  accepts_nested_attributes_for :categorizations

  validates_presence_of :title
end

We should do the same for Categorization but there’s a problem. Accepting nested attributes for Category will generate new categories all the time and we don’t want that. Mainly because we already set validation to impede the creation of more than one category with the same name.

The way accepts_nested_attributes_for works under the hood is by creating an attribute writer with the name of the association. For accepts_nested_attributes_for :category it will create category_attributes=(attributes).

The best option for us is to implement this method by ourselves inside of Categorization:

def category_attributes=(attributes)
  self.category = Category.find_or_create_by(attributes)
end

This way we’re trying to find an existing category. If the category doesn’t exist, we create it.

Now we’re ready to update the form. There are still a few things to improve but we’ll do that later.

Following the official guide for nested forms, add the following code to your app/views/posts/_form.html.erb file:

<%= form.fields_for :categorizations do |categorizations_form| %>
  <%= categorizations_form.fields_for :category do |category_form| %>
    <div>
      <%= category_form.label :name, "Category", style: "display: block" %>
      <%= category_form.text_field :name, disabled: category_form.object.name.present? %>
    </div>
  <% end %>
<% end %>

fields_for allows form rendering for an association that accepts nested attributes. For this example, we have to use it twice, nested, for categorizations and category.

If you reload the page you’ll see that nothing changed. This is because the form will only render fields if the object exists. We have to initialize a category inside the new action of the PostsController controller:

def new
  @post = Post.new
  @post.categorizations.build.build_category
end

It should work by now. We can create a post with associated categories all at once from the same form.

We have two problems, though:

The form is always rendering an empty input. This is where we add a new category, but if we don’t want to add a new category and we submit the form, the validation will trigger and an error will be raised. We want the validation to ignore empty categories.

If we try to add the same category twice we’ll see an error. This is the expected behavior. But we can also ignore the repeated category and leave the validation for safety.

accepts_nested_attributes_for accepts a reject_if option that accepts a function with the attributes as parameters.

Update your Post model:

accepts_nested_attributes_for :categorizations, reject_if: :empty_or_assigned_category

And create a private function:

private

def empty_or_assigned_category(attributes)
  category_name = attributes.dig(:category_attributes, :name)

  # reject if category name is empty:
  return true if !category_name.present?

  # reject if category is already assigned:
  if !attributes.dig(:id).present?
    assigned = self.categories.include? Category.find_by(name: category_name)
    return true if assigned
  end

  false
end

This function is executed for each categorizations_attributes in params.

If the category we try to create has no name, params will contain {"category_attributes"=>{"name"=>""}}. In this case, the function will return true and will reject the attribute.

If we create a new pizza category, it will be reflected as {"category_attributes"=>{"name"=>"pizza"}} inside params. First, we’ll check if the post has already been assigned to that category. Since we’re inside Post we can access this information with self.categories. If the association already exists, we reject the attribute returning true.

In case the association doesn’t exist, this param will make it to Categorization through the attribute writer category_attributes=(attributes) and the decision of creating or not the category will be made there.

If the category exists and is already assigned, params will contain something like {"id"=>"10", "category_attributes"=>{"id"=>"1"}}. Rails will take care of this one.

Categorize any model with concerns

Once the feature is developed, we can reuse it in as many models as we need to. To avoid code repetition, create a new file app/models/concerns/categorizable.rb with this content:

module Categorizable
  extend ActiveSupport::Concern

  included do
    has_many :categorizations, as: :categorizable, dependent: :destroy
    has_many :categories, through: :categorizations
  
    accepts_nested_attributes_for :categorizations, reject_if: :empty_or_assigned_category
  end

  private

    def empty_or_assigned_category(attributes)
      category_name = attributes.dig(:category_attributes, :name)

      # reject if category name is empty:
      return true if !category_name.present?

      # reject if category is already assigned:
      if !attributes.dig(:id).present?
        assigned = self.categories.include? Category.find_by(name: category_name)
        return true if assigned
      end

      false
    end
end

With the functionality code abstracted into a concern, we’re ready to include it wherever we want. Update your Post model removing all the code we moved to the concern and including the concern. Post should look like this now:

class Post < ApplicationRecord
  include Categorizable

  validates_presence_of :title
end

Latest posts

Projects

Get updates on my projects (in Spanish 🇪🇸)

    Name
    Email

    Where to find me