Avoid duplicate associated records when use rails nested attribute many to many

Post by Lưu Đại at 31-03-2024

1. Problem:

Assume I have 3 following tables: questions, question_tags, question_question_tags
Table structure describe below: 
  create_table "questions", force: :cascade do |t|
    t.string "question", null: false
    t.boolean "require_flg", null: false
    t.integer "question_type", default: 0, null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  create_table "question_tags", force: :cascade do |t|
    t.string "tag_name", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["tag_name"], name: "index_question_tags_on_tag_name", unique: true
  end

  create_table "question_question_tags", force: :cascade do |t|
    t.integer "question_id", null: false
    t.integer "question_tag_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["question_id"], name: "index_question_question_tags_on_question_id"
    t.index ["question_tag_id"], name: "index_question_question_tags_on_question_tag_id"
  end
The customer wants question_tags.tag_name unique. And question_tags and question_question_tags records will be created along with the questions record

2. Struggle

If we use default nested attributes each time client create a question, it'll assume all question_tags attach to it is new records (Some may argue that we can select the question tag from a dropdown and attach an question_tag id with it, it work fine unless the customer need is an simple input box that can add tag each time they hit enter, fetching the question tag's id each time a question_tag added may hit server several times). 
For example: Client send a request to create a new question with 3 question_tag. Question tag name are 'a', 'b' and 'c' separately. 
In the DB, currently we have 2 records question_tag associated with name 'a', 'b' separately. 
After the request is serve successfully we expect to create 2 new records question_question_tag link the new born question with 2 old question_tag 'a', 'b' that we already have, and one new question_tag will be create with tag_name = 'c' along with it's question_question_tag to link it with question record.
By default because we not submit the id with each tag_name so Rails will create 3 new question_tag record with tag_name are 'a', 'b', 'c' separately. The db raising error because unique constrain is violated (or the rails app will raise error if we have validate in our model). 

3. Solution

We can not use default nested attribute behavior so we need to write custom function that check if question_tag with submitted tag_name is existed. 
I override question_tags_attributes= function, this is the function rails will call when attach question_tags to question. The logic is simple I query all question_tag with tag_name = submitted tag_name. Irritate through the question_tag params and detect if the question_tag is existed, if it not I'll build a new question_tag. Note that the params with _destroy will be ignored. 
Detail code as below: 

 class Question < ApplicationRecord
  ...

  def question_tags_attributes=(attributes)
    self.question_tags = BuildQuestionTagsAttributesService.run(attributes)
  end
end


class BuildQuestionTagsAttributesService < BaseService
  def initialize(attributes)
    @attributes = attributes
  end

  def run
    return [] if attributes.blank?

    tag_names = attributes.map do |_, attribute|
      attribute[:tag_name]
    end

    question_tags = get_question_tags(tag_names)

    question_tag_attributes = []
    attributes.each do |_, attribute|
      next if attribute[:_destroy] == 'true' || attribute[:_destroy] == '1'

      question_tag = question_tags.find do |qt|
        qt.tag_name == attribute[:tag_name]
      end

      question_tag = QuestionTag.new(attribute.except(:_destroy)) if question_tag.blank?
      question_tag_attributes << question_tag
    end

    question_tag_attributes
  end

  private

  attr_reader :attributes

  def get_question_tags(tag_names)
    QuestionTag.where(tag_name: tag_names)
  end
end

4. Demo