Tránh duplicate bản ghi con khi dùng quan hệ many to many trong nested attribute

Đăng bởi Lưu Đại vào ngày 31-03-2024

1. Bài toán

Giả sử ta có 1 bảng question và question_tag quan hệ nhiều nhiều với nhau thông qua bảng trung gian question_question_tags. 
Cấu trúc từng bảng như sau: 
  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
Trong đó question_tags.tag_name không được trùng nhau và question_tags, question_question_tags sẽ được tạo cùng với questions

2. Vấn đề

Nếu như dùng nested attribute thông thường mỗi khi tạo 1 bản ghi question với question_tag thì sẽ tạo mới toàn bộ question_tag
VD: tạo mới 1 bản ghi question với 3 question_tag. Question tag có tag_name lần lượt là 'a', 'b', 'c'
Trong db lúc này đã có sẵn 2 bản ghi question tag với tag_name lần lượt là 'a', 'b'
Như vậy expect là sẽ tạo 2 bản ghi question_question_tag liên kết question mới tạo và 2 bản ghi question_tag đã có sẵn trong db ('a', 'b')
và tạo mới 1 bản ghi question_tag (tag_name = 'c') và liên kết tiếp nó với question 
Nhưng thực tế rails sẽ cố tạo ra 3 bản ghi question_tag mới với tag_name = 'a', 'b', 'c'. Như vậy sẽ bị lỗi do bản question_tag đã có 2 record tag_name = 'a' và 'b' rồi

3. Giải quyết

Đoạn này không thể dùng default của nested attribute được mà sẽ phải xử lý viết tay trong model 
Ta viết chèn vào hàm question_tags_attributes= đây là hàm mà nested attribute sẽ gọi vào khi tạo model thông thường có bao nhiêu bản ghi question_tag mà không có gắn id thì nó sẽ gán thêm cho self.question_tags bấy nhiêu bản ghi mới. Ta sửa lại bằng cách check trước trong db xem đã có bản ghi với tag_name trùng chưa nếu có thì đẩy bản ghi đó vào self.question_tags nếu chưa có thì tạo 1 bản ghi new với attribute người dùng truyền lên rồi đẩy vào self.question_tags. Các attributes có _destroy sẽ bị lọc ra, chỉ cần bản ghi không có trong self.question_tags nested attribute sẽ xóa nó cho chúng ta
Detail code ở dưới đây: 
 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