Categorizing things in a Rails app is a common need, but it’s a non-trivial lift to get it all implemented. Database relationships need to be right. How in the world should you set up your forms? What’s going on in my controllers? I’m going to do my best to show you how I implement categories.

Here’s our little todo list which can serve as a little mini table of contents

Setting up the models

I’ll be unoriginal here and pretend we’re building a blog. This means we’re going to have: posts, images, users, and categories. However, I probably won’t spend too much time on the User model as it’s a bit tangential. We should have a few goals as we model our data. For instance, we want to be able to access a post’s categories:

@post.categories 

#<ActiveRecord::Associations::CollectionProxy [...]>

We’d also very much like to see what type of data belongs to a specific category. Something like:

@category.items

#<ActiveRecord::Associations::CollectionProxy [#<Item id: 206, category_id: 4, item_type: "Post", item_id: 200, created_at: "2018-04-21 16:55:46", updated_at: "2018-04-21 16:55:46">]> 

In order to establish this type of relationship we’re going to be setting up a polymorphic has_many :through relationship. Let’s look at a diagram.

Categories database model

Through this illustration you can see that an item is capable of representing just about any other model you’d like it to. This is nice because it allows you to access many different kinds of data that could potentially belong to a specific category. Let’s get going.

Post model

rails g model Post title:string body:text

That gives us a migration file and we should see something like this.

# Resulting migration file
class CreatePosts < ActiveRecord::Migration[5.2]
  def change
    create_table :posts do |t|
      t.string :title, null: false
      t.text :body

      t.timestamps
    end
  end
end
# app/models/post.rb
class Post < ApplicationRecord
  has_many :items, as: :itemable
  has_many :categories, through: :items
end

Category model

rails g model Category name:string:unique

Which gives us the following migration file.

class CreateCategories < ActiveRecord::Migration[5.2]
  def change
    create_table :category do |t|
      t.string :name, null: false

      t.timestamps
    end
    add_index :categories, :name, unique: true
  end
end
# app/models/category.rb
class Category < ApplicationRecord
  has_many :items
  has_many :posts, through: :items, source: :itemable, source_type: 'Post'
  has_many :images, through: :items, source: :itemable, source_type: 'Image'
end

Item model

rails g model Item category_id:integer itemable:references

We’ll need to adjust the resulting migration file slightly.

class CreateItems < ActiveRecord::Migration[5.2]
  def change
    create_table :items do |t|
      t.bigint :category_id, null: false
      t.references :itemable, polymorphic: true, index: true

      t.timestamps
    end
  end
end
class Item < ApplicationRecord
  belongs_to :category
  belongs_to :itemable, polymorphic: true
end

Image model

Last but not least, let’s add an Image model. This isn’t a great example for a model handling images, but I wanted to use it as a conceptual example that could be related back to a Category.

rails g model Image filename:string
class CreateImages < ActiveRecord::Migration[5.2]
  def change
    create_table :images do |t|
      t.string :filename

      t.timestamps
    end
  end
end
# app/models/image.rb
class Image < ApplicationRecord
  has_many :items, as: :itemable
  has_many :categories, through: :items
end

Migrate everything

Hopefully we did everything right and are able to migrate our database.

rails db:migrate

Now we can start associating things. Hooray! Let’s give it a shot and play around a little.

post = Post.create(title: 'I love Rails', body: "Seriously, it's awesome")
image = Image.create(filename: 'https://cool.jpg')
category = Category.create(name: 'Ruby on Rails')
post.categories << category # Assigns our category to our post
image.categories << category # Assigns our category to our image

# List the post's categories
post.categories

# List all items in a category
category.items

Define our routes

Before we get to our controllers, let’s set up our routes.

Rails.application.routes.draw do
  resources :posts
  resources :images
  resources :categories
end

Setting up the controllers

While you can now zoom around in a terminal and create records, you probably want to hook things up for control through the views. That brings us to our controllers.

I’m going to make the assumption that you’re capable of setting up CRUD actions in a controller and won’t do that part here. If you’re following this post like a tutorial and actually building something you’ll want to take the time to build out controllers for posts, categories, and images. I just want to highlight what you’ll need to add to your strong parameters to get things working.

# app/controllers/posts_controller.rb
def post_params
  params.require(:post).permit(:title, :body, :category_ids)
end

# app/controllers/images_controller.rb
def image_params
  params.require(:image).permit(:filename, :category_ids)
end

# app/controllers/categories_controller.rb
def category_params
  params.require(:category).permit(:name)
end

Category form

Nothing fancy here. Just a form for us to create a category and save it.

<%= form_with model: @category, local: true do |f| %>
  <div id="formErrors">
    <%= render "/shared/form_errors", object: f.object %>
  </div>

  <div class="form-group">
    <%= f.label :name %>
    <%= f.text_field :name %>
  </div>

  <div class="form-group">
    <%= f.submit 'Save' %>
  </div>
<% end %>

Post form

I’m going to use a select box at the manner in which I associate a category with a post.

<%= form_with model: @post, local: true do |f| %>
  <div id="formErrors">
    <%= render "/shared/form_errors", object: f.object %>
  </div>

  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title %>
  </div>

  <div class="form-group">
    <%= f.label :body %>
    <%= f.text_area :body, rows: 10 %>
  </div>

  <div class="form-group">
    <%= f.label :categories %>
    <%= f.collection_select(:category_ids, Category.all, :id, :name, { prompt: 'Select', include_blank: '--' }) %>
  </div>

  <div class="form-group">
    <%= f.submit 'Save' %>
  </div>
<% end %>

A couple things to note here. include_blank is a good idea to include here so that you can disassociate the post from a category should you choose to do so down the road. Also, my select field is currently only allowing you to select a single category. That’s just a preference of mine, but the models are set up for a post to have many categories. Here’s how you would allow multiple selections

<%= f.collection_select(:category_ids, Category.all, :id, :name, { prompt: 'Select', include_blank: '--' }, { multiple: true }) %>

Also, if you think Category.all is a code smell (probably is) feel free to move that into the controller or maybe make a scope, your call.

Conclusion

That about wraps it up. This was an example of how to setup a polymorphic has_many :through relationship that allows you to associate different items (posts or images) with a category. Happy coding.

Written by Matt Haliski

The First of His Name, Consumer of Tacos, Operator of Computers, Mower of Grass, Father of the Unsleeper, King of Bad Function Names, Feeder of AI Overlords.