Categories in a Rails App
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
- Set up models (with migration examples)
- Define our routes
- Controllers and strong parameters
- Category form
- Post form
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.
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.