Let's say we have posts with an attribute title
that is mandatory.
Our example feature request is to tag these posts with a limited number of tags. The following chapters explain different approaches in Rails, how you can assign such an association via HTML forms. In most cases you want to use Option 4 with assignable values.
The basic setup for all options looks like this:
config/routes.rb
Rails.application.routes.draw do
root "posts#index"
resources :posts, except: [:show, :destroy]
end
db/migrate/20230510093740_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :title, null: false
end
end
end
app/models/post.rb
class Post < ApplicationRecord
validates :title, presence: true
end
app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
load_posts
end
def new
build_post
end
def create
build_post
if @post.save
redirect_to posts_path
else
render 'new'
end
end
def edit
load_post
build_post
end
def update
load_post
build_post
if @post.save
redirect_to posts_path
else
render 'edit'
end
end
private
def load_post
@post ||= Post.find(params[:id])
end
def build_post
@post ||= Post.new
@post.attributes = post_params
end
def post_params
params.fetch(:post, {}).permit(
:title,
)
end
def load_posts
@posts ||= Post.all.to_a
end
end
app/views/posts/index.haml
%h1 Posts
= link_to('New Post', new_post_path)
- if @posts.present?
%table
%thead
%tr
%th Title
%th
%tbody
- @posts.each do |post|
%tr
%td= post.title
%td= link_to('Edit post', edit_post_path(post))
- else
%p No posts yet
app/views/posts/new.haml
%h1 New post
= render ('posts/form')
app/views/posts/edit.haml
%h1 Edit post
= render ('posts/form')
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title, 'Title')
= form.text_field(:title)
= form.submit
Our example feature request is to tag these posts
with a limited number of tags
. The following chapters explain different approaches in Rails, how you can assign such an association via HTML forms.
Option 1: Array column
One way to solve the problem is using a database column with an array of strings.
db/migrate/20230510093740_create_posts.rb
def change
create_table :posts do |t|
t.string :title, null: false
+ t.string :tags, :string, array: true, null: false, default: []
t.timestamps
end
app/models/post.rb
class Post < ApplicationRecord
+ ASSIGNABLE_TAGS = ['food', 'programming', 'traveling']
+
validates :title, presence: true
end
app/controllers/posts_controller.rb
def edit
load_post
- build_post
end
def post_params
+ params.with_defaults!(post: { tags: [] })
params.fetch(:post, {}).permit(
:title,
+ tags: [],
)
end
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title)
= form.text_field(:title)
+
+ = field_set_tag('Tags') do
+ = form.collection_check_boxes(:tags, Post::ASSIGNABLE_TAGS, :to_s, :to_s, include_hidden: false)
= form.submit
<form action="/posts" accept-charset="UTF-8" method="post">
<label for="post_title">Title</label>
<input type="text" name="post[title]" id="post_title">
<fieldset>
<legend>Tags</legend>
<input type="checkbox" value="food" name="post[tags][]" id="post_tags_food">
<label for="post_tags_food">food</label>
<input type="checkbox" value="programming" name="post[tags][]" id="post_tags_programming">
<label for="post_tags_programming">programming</label>
<input type="checkbox" value="traveling" name="post[tags][]" id="post_tags_traveling">
<label for="post_tags_traveling">traveling</label>
</fieldset><input type="submit" name="commit" value="Create Post" data-disable-with="Create Post">
</form>
Pro
- Lightweight implementation
- No Associations required
- Out of the box support from Rails
- Supports localization of the labels (here just
.to_s
for value and label for simplicity)
Cons
- Adding new values requires a code change
- Changing existing values requires a data migration
- SQL queries on array columns have limitations
- We need to take care of not assigning
nil
as value ofposts
- Option 1: Using
with_defaults!
in the controller +include_hidden: false
in the form (used in the example; requires to removebuild_post
form the edit action) - Option 2: Reject blank values before save e.g.
before_validate { posts.reject!(&:blank) }
in thepost.rb
model
- Option 1: Using
Option 2: Array column with associated records
db/migrate/20230510093740_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :title, null: false
+ t.integer :tag_ids, array: true, null: false, default: []
t.timestamps
end
db/migrate/20230516064203_create_tags.rb
+class CreateTags < ActiveRecord::Migration[7.0]
+ def change
+ create_table :tags do |t|
+ t.string :name, null: false
+
+ t.timestamps
+ end
+ end
+end
app/models/tag.rb
+class Tag < ApplicationRecord
+ validates :name, presence: true
+end
app/controllers/posts_controller.rb
def edit
load_post
- build_post
end
def post_params
+ params.with_defaults!(post: { tag_ids: [] })
params.fetch(:post, {}).permit(
:title,
+ tag_ids: [],
)
end
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title)
= form.text_field(:title)
+ = field_set_tag('Tags') do
+ = form.collection_check_boxes(:tag_ids, Tag.all, :id, :name, include_hidden: false)
= form.submit
<form action="/posts" accept-charset="UTF-8" method="post">
<label for="post_title">Title</label>
<input type="text" name="post[title]" id="post_title">
<fieldset>
<legend>Tags</legend>
<input type="checkbox" value="1" name="post[tag_ids][]" id="post_tag_ids_1">
<label for="post_tag_ids_1">food</label>
<input type="checkbox" value="2" name="post[tag_ids][]" id="post_tag_ids_2">
<label for="post_tag_ids_2">programming</label>
<input type="checkbox" value="3" name="post[tag_ids][]" id="post_tag_ids_3">
<label for="post_tag_ids_3">traveling</label>
</fieldset>
<input type="submit" name="commit" value="Create Post" data-disable-with="Create Post">
</form>
Pro
- Separate model for tags
- Adding new tags does not need a data migration
Cons
- Dropping tags does need a data migration or custom code since we have no build-in
dependent: :destroy
support in ActiveRecord - SQL queries on array columns have limitations
Notes
- The
checked
option is not set in case we use apost_ids
column of typestring
instead ofinteger
- We need to take care of not assigning
nil
as value ofpost_ids
- Option 1: Using
with_defaults!
in the controller +include_hidden: false
in the form (used in the example; requires to removebuild_post
form the edit action) - Option 2: Reject blank values before save e.g.
before_validate { post_ids.reject!(&:blank) }
in thepost.rb
model
- Option 1: Using
Option 3: Join model with ids_setter
db/migrate/20230516064203_create_tags.rb
+class CreateTags < ActiveRecord::Migration[7.0]
+ def change
+ create_table :tags do |t|
+ t.string :name, null: false
+
+ t.timestamps
+ end
+ end
+end
db/migrate/20230516075502_create_taggings.rb
+class CreateTaggings < ActiveRecord::Migration[7.0]
+ def change
+ create_table :taggings do |t|
+ t.references :post, null: false, foreign_key: true
+ t.references :tag, null: false, foreign_key: true
+
+ t.timestamps
+ end
+
+ add_index :taggings, [:post_id, :tag_id], unique: true
+ end
+end
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title)
= form.text_field(:title)
+ = field_set_tag('Tags') do
+ = form.collection_check_boxes(:tag_ids, Tag.all, :id, :name)
= form.submit
app/models/post.rb
class Post < ApplicationRecord
validates :title, presence: true
+
+ has_many :taggings, dependent: :destroy
+ has_many :tags, through: :taggings
end
app/models/tag.rb
+class Tag < ApplicationRecord
+ validates :name, presence: true
+
+ has_many :taggings, dependent: :destroy
+ has_many :posts, through: :taggings
+end
app/models/tagging.rb
+class Tagging < ApplicationRecord
+ belongs_to :post
+ belongs_to :tag
+
+ validates :post, uniqueness: { scope: :tag }
+end
app/controllers/posts_controller.rb
def post_params
params.fetch(:post, {}).permit(
:title,
+ tag_ids: [],
)
end
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title)
= form.text_field(:title)
+ = field_set_tag('Tags') do
+ = form.collection_check_boxes(:tag_ids, Tag.all, :id, :name)
= form.submit
Pro
- Separate model for tags
- Dependent destroy for the join model
- Adding and removing tags does not need any data migration
Cons
-
post.tag_ids
is a ids_setter Show archive.org snapshot method, that saves immediately. So you will add or remove tags even if your form has errors (possible fix: Additional transaction in your model model).
Option 4: Join model with nested attributes
db/migrate/20230516064203_create_tags.rb
+class CreateTags < ActiveRecord::Migration[7.0]
+ def change
+ create_table :tags do |t|
+ t.string :name, null: false
+
+ t.timestamps
+ end
+ end
+end
db/migrate/20230516075502_create_taggings.rb
+class CreateTaggings < ActiveRecord::Migration[7.0]
+ def change
+ create_table :taggings do |t|
+ t.references :post, null: false, foreign_key: true
+ t.references :tag, null: false, foreign_key: true
+
+ t.timestamps
+ end
+
+ add_index :taggings, [:post_id, :tag_id], unique: true
+ end
+end
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title)
= form.text_field(:title)
+ = field_set_tag('Tags') do
+ = form.collection_check_boxes(:tag_ids, Tag.all, :id, :name)
= form.submit
app/models/post.rb
class Post < ApplicationRecord
validates :title, presence: true
+
+ has_many :taggings, dependent: :destroy
+ has_many :tags, through: :taggings
+
+ accepts_nested_attributes_for :taggings, allow_destroy: true
end
app/models/tag.rb
+class Tag < ApplicationRecord
+ validates :name, presence: true
+
+ has_many :taggings, dependent: :destroy
+ has_many :posts, through: :taggings
+end
app/models/tagging.rb
+class Tagging < ApplicationRecord
+ belongs_to :post
+ belongs_to :tag
+
+ validates :post, uniqueness: { scope: :tag }
+end
app/controllers/posts_controller.rb
def new
build_post
+ build_tags
end
def edit
load_post
build_post
+ build_tags
end
+ def build_tags
+ existing_tag_ids = @post.taggings.map { |tagging| tagging.tag_id }
+
+ Tag.find_each do |tag|
+ if existing_tag_ids.exclude?(tag.id)
+ @post.taggings.build(tag: tag).mark_for_destruction
+ end
+ end
+ end
+
def post_params
params.fetch(:post, {}).permit(
:title,
+ taggings_attributes: [
+ :id,
+ :post_id,
+ :tag_id,
+ :_destroy
+ ]
)
end
app/views/posts/_form.haml
= form_with(model: @post) do |form|
= form.label(:title)
= form.text_field(:title)
+ %table
+ %tbody
+ = form.fields_for(:taggings) do |taggings_form|
+ %tr
+ %td= taggings_form.object.tag.name
+ %td= taggings_form.hidden_field(:id, value: taggings_form.object.id)
+ %td= taggings_form.hidden_field(:tag_id, value: taggings_form.object.tag.id)
+ %td
+ = form.label(:_destroy, 'Destroy?')
+ = taggings_form.check_box(:_destroy)
= form.submit
Pro
- Separate model for tags
- Dependent destroy for the join model
- Adding and removing tags does not need any data migration
- All operations are wrapped into one single transaction
Cons
- Custom code to build the nested form associations
- Requires UI extensions to make the form human readable
Notes
- It makes sense to add CSS or JS to invert the
Destroy?
checkboxes. So a user can choose the tags that should be selected and not vice versa.