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