Read more

Rails: Assigning associations via HTML forms

Emanuel
May 08, 2023Software engineer at makandra GmbH

Let's say we have posts with an attribute title that is mandatory.

Illustration book lover

Growing Rails Applications in Practice

Check out our e-book. Learn to structure large Ruby on Rails codebases with the tools you already know and love.

  • Introduce design conventions for controllers and user-facing models
  • Create a system for growth
  • Build applications to last
Read more Show archive.org snapshot

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

Image

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 of posts
    • Option 1: Using with_defaults! in the controller + include_hidden: false in the form (used in the example; requires to remove build_post form the edit action)
    • Option 2: Reject blank values before save e.g. before_validate { posts.reject!(&:blank) } in the post.rb model

Option 2: Array column with associated records

Image

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 a post_ids column of type string instead of integer
  • We need to take care of not assigning nil as value of post_ids
    • Option 1: Using with_defaults! in the controller + include_hidden: false in the form (used in the example; requires to remove build_post form the edit action)
    • Option 2: Reject blank values before save e.g. before_validate { post_ids.reject!(&:blank) } in the post.rb model

Option 3: Join model with ids_setter

Image

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

Image

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.
Emanuel
May 08, 2023Software engineer at makandra GmbH
Posted by Emanuel to makandra dev (2023-05-08 12:50)