From 58f88d6805fc570d3ff26edd5b95787fb14acc89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javi=20Mart=C3=ADn?= Date: Wed, 15 May 2024 18:49:27 +0200 Subject: [PATCH] Add task to add option_id to existing answers Note: to avoid confusion, "answer" will mean a row in the poll_answers table and "choice" will mean whatever is in the "answer" column of that table (I'm applying the same convention in the code of the task). In order make this task perform reasonably on installations with millions of votes, we're using `update_all` to update all the answers with the same choice at once. In order to do that, we first need to check the existing choices and what are the possible option_ids for those choices. Note that, in order for this task to work, we need to remote the duplicate answers first. Otherwise, we will run into a RecordNotUnique exception when trying to add the same option_id to two duplicate answers. So we're making this task depend on the one that removes duplicate answers. That means we no longer need to specify the task to remove duplicate answers in the release tasks; it will automatically be executed when running the task to add an option_id. --- lib/tasks/consul.rake | 2 +- lib/tasks/polls.rake | 44 ++++++++++++++++ spec/lib/tasks/polls_spec.rb | 97 ++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/lib/tasks/consul.rake b/lib/tasks/consul.rake index 0e89a360f..64225f60b 100644 --- a/lib/tasks/consul.rake +++ b/lib/tasks/consul.rake @@ -9,6 +9,6 @@ namespace :consul do task "execute_release_2.2.0_tasks": [ "db:mask_ips", "polls:remove_duplicate_voters", - "polls:remove_duplicate_answers" + "polls:populate_option_id" ] end diff --git a/lib/tasks/polls.rake b/lib/tasks/polls.rake index bda3058eb..7b9472145 100644 --- a/lib/tasks/polls.rake +++ b/lib/tasks/polls.rake @@ -61,4 +61,48 @@ namespace :polls do end end end + + desc "populates the poll answers option_id column" + task populate_option_id: :remove_duplicate_answers do + logger = ApplicationLogger.new + logger.info "Updating option_id column in poll_answers" + + Tenant.run_on_each do + questions = Poll::Question.select do |question| + # Change this if you've added a custom votation type + # to your Consul Democracy installation that implies + # choosing among a few given options + question.unique? || question.multiple? + end + + questions.each do |question| + options = question.question_options.joins(:translations).reorder(:id) + existing_choices = question.answers.where(option_id: nil).distinct.pluck(:answer) + + choices_map = existing_choices.to_h do |choice| + [choice, options.where("lower(title) = lower(?)", choice).distinct.ids] + end + + manageable_choices, unmanageable_choices = choices_map.partition { |choice, ids| ids.count == 1 } + + manageable_choices.each do |choice, ids| + question.answers.where(option_id: nil, answer: choice).update_all(option_id: ids.first) + end + + unmanageable_choices.each do |choice, ids| + tenant_info = " on tenant #{Tenant.current_schema}" unless Tenant.default? + + if ids.count == 0 + logger.warn "Skipping poll_answers with the text \"#{choice}\" for the poll_question " \ + "with ID #{question.id}. This question has no poll_question_answers " \ + "containing the text \"#{choice}\"" + tenant_info.to_s + else + logger.warn "Skipping poll_answers with the text \"#{choice}\" for the poll_question " \ + "with ID #{question.id}. The text \"#{choice}\" could refer to any of these " \ + "IDs in the poll_question_answers table: #{ids.join(", ")}" + tenant_info.to_s + end + end + end + end + end end diff --git a/spec/lib/tasks/polls_spec.rb b/spec/lib/tasks/polls_spec.rb index da5050564..22e6ca409 100644 --- a/spec/lib/tasks/polls_spec.rb +++ b/spec/lib/tasks/polls_spec.rb @@ -120,4 +120,101 @@ describe "polls tasks" do end end end + + describe "polls:populate_option_id" do + before do + Rake::Task["polls:remove_duplicate_answers"].reenable + Rake::Task["polls:populate_option_id"].reenable + end + + it "populates the option_id column of existing answers when there's one valid answer" do + yes_no_question = create(:poll_question, :yes_no, poll: poll) + abc_question = create(:poll_question_multiple, :abc, poll: poll) + option_a = abc_question.question_options.find_by(title: "Answer A") + option_b = abc_question.question_options.find_by(title: "Answer B") + + answer = create(:poll_answer, question: yes_no_question, author: user, answer: "Yes", option: nil) + abc_answer = create(:poll_answer, question: abc_question, author: user, answer: "Answer A", option: nil) + answer_with_inconsistent_option = create(:poll_answer, question: abc_question, + author: user, + answer: "Answer A", + option: option_b) + answer_with_invalid_option = build(:poll_answer, question: abc_question, + author: user, + answer: "Non existing", + option: nil) + answer_with_invalid_option.save!(validate: false) + + Rake.application.invoke_task("polls:populate_option_id") + answer.reload + abc_answer.reload + answer_with_inconsistent_option.reload + answer_with_invalid_option.reload + + expect(answer.option_id).to eq yes_no_question.question_options.find_by(title: "Yes").id + expect(abc_answer.option_id).to eq option_a.id + expect(answer_with_inconsistent_option.option_id).to eq option_b.id + expect(answer_with_invalid_option.option_id).to be nil + end + + it "does not populate the option_id column when there are several valid options" do + question = create(:poll_question, title: "How do you pronounce it?") + + create(:poll_question_option, question: question, title_en: "A", title_es: "EI") + create(:poll_question_option, question: question, title_en: "E", title_es: "I") + create(:poll_question_option, question: question, title_en: "I", title_es: "AI") + + answer = create(:poll_answer, question: question, author: user, answer: "I", option: nil) + + Rake.application.invoke_task("polls:populate_option_id") + answer.reload + + expect(answer.option_id).to be nil + end + + it "removes duplicate answers before populating the option_id column" do + user = create(:user, :level_two) + question = create(:poll_question_multiple, :abc) + + answer_attributes = { + question_id: question.id, + author_id: user.id, + answer: "Answer A", + option_id: nil + } + answer = create(:poll_answer, answer_attributes) + insert(:poll_answer, answer_attributes) + + answer.reload + expect(answer.option_id).to be nil + + Rake.application.invoke_task("polls:populate_option_id") + answer.reload + + expect(Poll::Answer.count).to eq 1 + expect(answer.option_id).to eq question.question_options.find_by(title: "Answer A").id + end + + it "populates the option_id column on tenants" do + create(:tenant, schema: "answers") + + Tenant.switch("answers") do + question = create(:poll_question_multiple, :abc) + + create(:poll_answer, question: question, answer: "Answer A", option: nil) + end + + Rake.application.invoke_task("polls:populate_option_id") + + Tenant.switch("answers") do + expect(Poll::Question.count).to eq 1 + expect(Poll::Answer.count).to eq 1 + + question = Poll::Question.first + option_a = question.question_options.find_by(title: "Answer A") + + expect(Poll::Answer.first.option_id).to eq option_a.id + end + end + end end