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.
This commit is contained in:
Javi Martín
2024-05-15 18:49:27 +02:00
parent d2ec73e92c
commit 58f88d6805
3 changed files with 142 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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