1. Problem:
Assume I have 3 following tables: questions, question_tags, question_question_tags
Table structure describe below:
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).
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:
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