2

Summary:

I need to validate uniqueness of two columns given four other columns as scope:

validates :preference, uniqueness: { scope: [:voter_id, :term_id, :in_transaction, :transaction_destroy], message: 'must not have the same preference as another vote' }
validates :candidate,  uniqueness: { scope: [:voter_id, :term_id, :in_transaction, :transaction_destroy], message: 'can only be voted for once' }

This is to ensure only unique preferences and candidates for the same voter, in the same term, and within the same transaction states.

The problem is, in_transaction and transaction_destroy are booleans, meaning rails validations do not work.

How can I write a workaround?


Background:

I'm working on an STV Election backend.

The entirety of the project is already finished -- frontend site, database, results generation, fancy animated results diff, etc. The only thing I've been unable to do is the uniqueness validations.

With how STV works, each voter may enter a preference (int) for any number of candidates. If their first preference gets eliminated, their vote transfers to their second preference, and so on. All of these are stored in the council_votes table, with columns voter_id, candidate_id, and preference.

Users need to be able to swap preferences, too. However, given the preference uniqueness constraint, individual updates break validation. To solve this, and to prevent data loss on network timeouts, I added transactions.

The client app sends a begin transaction message, sends its preference changes, and finally sends a commit message. During a transaction, all changes create a record with in_transaction: true; destroys create a record with in_transcation: true, transaction_destroy: true. Committing the transaction destroys records first, then recreates the records with the correct preferences. In the event of an error, it rolls back the changes and notifies the client.

Given how this works, there are essentially three sets of 'votes':

  • normal vote
  • in_transaction vote
  • in_transaction && transaction_destroy vote

To prevent duplicate candidates/preferences, I must ensure they are unique across these three sets. but given both state columns are boolean, how can I do this?

Or would it be easier to alter the schema and replace in_transaction and transaction_destroy with transaction_state (null|create|destroy) and scope that instead? That seems to be a more sane option.

2 Answers 2

3

Use a custom validation to check the presence existence of an existing record:

validate :is_new_preference

def is_new_preference
  !Item.exists?(preference: preference, voter_id: voter_id, term_id: term_id, in_transaction: in_transaction, transaction_destroy: transaction_destroy)
end
Sign up to request clarification or add additional context in comments.

1 Comment

Aha, this should work. I've since refactored my code to use the more sane transaction_state instead, so I cannot test this. I'll mark it as the accepted answer. ^^
3

You might be a state where its a bit late to fix this but this seems very brittle and prone to race conditions.

class Voter < ActiveRecord::Base
  has_many :council_votes
end

class Candidate < ActiveRecord::Base
  has_many :candidacies
  has_many :terms, through: :candidacies
end

class Candidacy < ActiveRecord::Base
  enum status: [:running, :dropped]
  belongs_to :candidate
  belongs_to :term
  validates_uniqueness_of :candidate_id, scope: :term_id
end

class Term < ActiveRecord::Base
  has_many :candidacies,
  has_many :candidates, through: :candidacies
end

class CouncilVote < ActiveRecord::Base
  belongs_to :voter
  belongs_to :candidacy
  has_one :candidate, through: :candidacy
  has_one :term, through: :candidacy
  validates_uniqueness_of :voter_id, scope: :candidacy_id
end 

Here we add a candidacy m-2-m join table between between Candidate and Term. With an enum bitmask column that lets us set the status. This means that we only need to ensure the uniqueness on two columns.

Lets add some db constraints to prevent race conditions and improve performance:

class AddUniqenessToCandidacy < ActiveRecord::Migration
  def change
    add_index :candidacies, [:candidate_id, :term_id], unique: true
  end
end

class AddUniqenessToCouncilVote < ActiveRecord::Migration
  def change
    add_index :council_votes, [:candidacy_id, :voter_id], unique: true
  end
end

The big difference here is the we just update the candidacies table when a candidate is eliminated.

@canditate = Canditate.find_by(name: 'Berny')
@canditate.candidacies.last.dropped! # sorry Berny

This works as a sort of soft delete. Instead of pulling and reinserting and that whole transactional Schrödingers Cat dilemma we leave everything in place and use the rating column to order the results.

@term = Term.find_by(year: 2016)
@votes = CouncilVote.joins(:candidacy, :candidate, :term)
           .where(candidacy: { status: :running })
           .where(voter: @voter)
           .where(term: @term)
           .order(rating: :desc)

2 Comments

This code is more of a theoretical approach than actual working code - it most likely riddled with typos and is not tested.
This needs some critical features (such as bumping voters' preferences when a candidate withdraws), but it is definitely a good start. However, it still has a uniqueness issue when swapping two preferences (rating in yours) between two candidates. This code would actually allow duplicate ratings. :(

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.