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 UI/UX Design

UI/UX Design by makandra brand

We make sure that your target audience has the best possible experience with your digital product. You get:

  • Design tailored to your audience
  • Proven processes customized to your needs
  • An expert team of experienced designers
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)