Avoid duplicate records in poll answers

Until now, we've stored the text of the answer somebody replied to. The
idea was to handle the scenarios where the user voters for an option but
then that option is deleted and restored, or the texts of the options
are accidentally edited and so the option "Yes" is now "Now" and vice
versa.

However, since commit 3a6e99cb8, options can no longer be edited once
the poll starts, so there's no risk of the option changing once somebody
has voted.

This means we can now store the ID of the option that has been voted.
That'll also help us deal with a bug introduced int 673ec075e, since
answers in different locales are not counted as the same answer. Note we
aren't dealing with this bug right now.

We're still keeping (and storing) the answer as well. There are two
reasons for that.

First, we might add an "open answer" type of questions in the future and
use this column for it.

Second, we've still got logic depending on the answer, and we need to be
careful when changing it because there are existing installations where
the answer is present but the option_id is not.

Note that we're using `dependent: nullify`. The reasoning is that, since
we're storing both the option_id and the answer text, we can still use
the answer text when removing the option. In practice, this won't matter
much, though, since we've got a validation rule that makes it impossible
to destroy options once the poll has started.

Also note we're still allowing duplicate records when the option is nil.
We need to do that until we've removed every duplicate record in the
database.
This commit is contained in:
Javi Martín
2024-05-14 02:09:09 +02:00
parent 03f89c9ca2
commit 5033691666
10 changed files with 96 additions and 10 deletions

View File

@@ -11,7 +11,7 @@
"aria-pressed": true %> "aria-pressed": true %>
<% else %> <% else %>
<%= button_to question_option.title, <%= button_to question_option.title,
question_answers_path(question, answer: question_option.title), question_answers_path(question, option_id: question_option.id),
remote: true, remote: true,
title: t("poll_questions.show.vote_answer", answer: question_option.title), title: t("poll_questions.show.vote_answer", answer: question_option.title),
class: "button secondary hollow", class: "button secondary hollow",

View File

@@ -8,7 +8,7 @@ class Polls::AnswersController < ApplicationController
def create def create
authorize! :answer, @question authorize! :answer, @question
answer = @question.find_or_initialize_user_answer(current_user, params[:answer]) answer = @question.find_or_initialize_user_answer(current_user, params[:option_id])
answer.save_and_record_voter_participation answer.save_and_record_voter_participation
respond_to do |format| respond_to do |format|

View File

@@ -1,5 +1,6 @@
class Poll::Answer < ApplicationRecord class Poll::Answer < ApplicationRecord
belongs_to :question, -> { with_hidden }, inverse_of: :answers belongs_to :question, -> { with_hidden }, inverse_of: :answers
belongs_to :option, class_name: "Poll::Question::Option"
belongs_to :author, -> { with_hidden }, class_name: "User", inverse_of: :poll_answers belongs_to :author, -> { with_hidden }, class_name: "User", inverse_of: :poll_answers
delegate :poll, :poll_id, to: :question delegate :poll, :poll_id, to: :question
@@ -7,6 +8,7 @@ class Poll::Answer < ApplicationRecord
validates :question, presence: true validates :question, presence: true
validates :author, presence: true validates :author, presence: true
validates :answer, presence: true validates :answer, presence: true
validates :option, uniqueness: { scope: :author_id }, allow_nil: true
validate :max_votes validate :max_votes
validates :answer, inclusion: { in: ->(a) { a.question.possible_answers }}, validates :answer, inclusion: { in: ->(a) { a.question.possible_answers }},

View File

@@ -98,20 +98,23 @@ class Poll::Question < ApplicationRecord
end end
end end
def find_or_initialize_user_answer(user, title) def find_or_initialize_user_answer(user, option_id)
answer = answers.find_or_initialize_by(find_by_attributes(user, title)) option = question_options.find(option_id)
answer.answer = title
answer = answers.find_or_initialize_by(find_by_attributes(user, option))
answer.option = option
answer.answer = option.title
answer answer
end end
private private
def find_by_attributes(user, title) def find_by_attributes(user, option)
case vote_type case vote_type
when "unique", nil when "unique", nil
{ author: user } { author: user }
when "multiple" when "multiple"
{ author: user, answer: title } { author: user, answer: option.title }
end end
end end
end end

View File

@@ -11,6 +11,7 @@ class Poll::Question::Option < ApplicationRecord
accepts_nested_attributes_for :documents, allow_destroy: true accepts_nested_attributes_for :documents, allow_destroy: true
belongs_to :question, class_name: "Poll::Question" belongs_to :question, class_name: "Poll::Question"
has_many :answers, class_name: "Poll::Answer", dependent: :nullify
has_many :videos, class_name: "Poll::Question::Option::Video", has_many :videos, class_name: "Poll::Question::Option::Video",
dependent: :destroy, dependent: :destroy,
foreign_key: "answer_id", foreign_key: "answer_id",

View File

@@ -0,0 +1,9 @@
class AddQuestionAnswerIdToPollAnswers < ActiveRecord::Migration[7.0]
def change
change_table :poll_answers do |t|
t.references :option, index: true, foreign_key: { to_table: :poll_question_answers }
t.index [:option_id, :author_id], unique: true
end
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_05_11_141119) do ActiveRecord::Schema[7.0].define(version: 2024_05_13_135357) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm" enable_extension "pg_trgm"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -1024,7 +1024,10 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_11_141119) do
t.string "answer" t.string "answer"
t.datetime "created_at", precision: nil t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil t.datetime "updated_at", precision: nil
t.bigint "option_id"
t.index ["author_id"], name: "index_poll_answers_on_author_id" t.index ["author_id"], name: "index_poll_answers_on_author_id"
t.index ["option_id", "author_id"], name: "index_poll_answers_on_option_id_and_author_id", unique: true
t.index ["option_id"], name: "index_poll_answers_on_option_id"
t.index ["question_id", "answer"], name: "index_poll_answers_on_question_id_and_answer" t.index ["question_id", "answer"], name: "index_poll_answers_on_question_id_and_answer"
t.index ["question_id"], name: "index_poll_answers_on_question_id" t.index ["question_id"], name: "index_poll_answers_on_question_id"
end end
@@ -1798,6 +1801,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_05_11_141119) do
add_foreign_key "moderators", "users" add_foreign_key "moderators", "users"
add_foreign_key "notifications", "users" add_foreign_key "notifications", "users"
add_foreign_key "organizations", "users" add_foreign_key "organizations", "users"
add_foreign_key "poll_answers", "poll_question_answers", column: "option_id"
add_foreign_key "poll_answers", "poll_questions", column: "question_id" add_foreign_key "poll_answers", "poll_questions", column: "question_id"
add_foreign_key "poll_booth_assignments", "polls" add_foreign_key "poll_booth_assignments", "polls"
add_foreign_key "poll_officer_assignments", "poll_booth_assignments", column: "booth_assignment_id" add_foreign_key "poll_officer_assignments", "poll_booth_assignments", column: "booth_assignment_id"

View File

@@ -0,0 +1,25 @@
require "rails_helper"
describe Polls::AnswersController do
describe "POST create" do
it "doesn't create duplicate records on simultaneous requests", :race_condition do
question = create(:poll_question_multiple, :abc)
sign_in(create(:user, :level_two))
2.times.map do
Thread.new do
begin
post :create, params: {
question_id: question.id,
option_id: question.question_options.find_by(title: "Answer A").id,
format: :js
}
rescue ActionDispatch::IllegalStateError, ActiveRecord::RecordInvalid
end
end
end.each(&:join)
expect(Poll::Answer.count).to eq 1
end
end
end

View File

@@ -205,6 +205,7 @@ FactoryBot.define do
question factory: [:poll_question, :yes_no] question factory: [:poll_question, :yes_no]
author factory: [:user, :level_two] author factory: [:user, :level_two]
answer { question.question_options.sample.title } answer { question.question_options.sample.title }
option { question.question_options.find_by(title: answer) }
end end
factory :poll_partial_result, class: "Poll::PartialResult" do factory :poll_partial_result, class: "Poll::PartialResult" do

View File

@@ -51,6 +51,44 @@ describe Poll::Answer do
expect(answer).not_to be_valid expect(answer).not_to be_valid
end end
it "is not valid when there are two identical answers" do
author = create(:user)
question = create(:poll_question_multiple, :abc)
option = question.question_options.first
create(:poll_answer, author: author, question: question, option: option, answer: "Answer A")
answer = build(:poll_answer, author: author, question: question, option: option, answer: "Answer A")
expect(answer).not_to be_valid
expect { answer.save(validate: false) }.to raise_error ActiveRecord::RecordNotUnique
end
it "is not valid when there are two answers with the same option and different answer" do
author = create(:user)
question = create(:poll_question_multiple, :abc)
option = question.question_options.first
create(:poll_answer, author: author, question: question, option: option, answer: "Answer A")
answer = build(:poll_answer, author: author, question: question, option: option, answer: "Answer B")
expect(answer).not_to be_valid
expect { answer.save(validate: false) }.to raise_error ActiveRecord::RecordNotUnique
end
it "is valid when there are two identical answers and the option is nil" do
author = create(:user)
question = create(:poll_question_multiple, :abc)
create(:poll_answer, author: author, question: question, option: nil, answer: "Answer A")
answer = build(:poll_answer, author: author, question: question, option: nil, answer: "Answer A")
expect(answer).to be_valid
expect { answer.save }.not_to raise_error
end
it "is valid for answers included in the Poll::Question's question_options list" do it "is valid for answers included in the Poll::Question's question_options list" do
question = create(:poll_question) question = create(:poll_question)
create(:poll_question_option, title: "One", question: question) create(:poll_question_option, title: "One", question: question)
@@ -119,7 +157,10 @@ describe Poll::Answer do
expect(poll.reload.voters.size).to eq(1) expect(poll.reload.voters.size).to eq(1)
updated_answer = answer.question.find_or_initialize_user_answer(answer.author, "No") updated_answer = answer.question.find_or_initialize_user_answer(
answer.author,
answer.question.question_options.excluding(answer.option).sample.id
)
updated_answer.save_and_record_voter_participation updated_answer.save_and_record_voter_participation
expect(poll.reload.voters.size).to eq(1) expect(poll.reload.voters.size).to eq(1)