Minutiae
Minutiae

Reputation: 151

How to prevent duplicate records in a Join Table

I'm quite new to Ruby and Rails so please bear with me.

I have two models Player, and Reward joined via a has_many through relationship as below. My Player model has an attribute points. As a player accrues points they get rewards. What I want to do is put a method on the Player model that will run before update and give the appropriate reward(s) for the points they have like below.

However I want to do it in such a way that if the Player already has the reward it won't be duplicated, nor cause an error.

class Player < ActiveRecord::Base
  has_many :earned_rewards, -> { extending FirstOrBuild }
  has_many :rewards, :through => :earned_rewards

  before_update :assign_rewards, :if => :points_changed?

  def assign_rewards
    case self.points
    when 1000
      self.rewards << Reward.find_by(:name => "Bronze")
    when 2000
      self.rewards << Reward.find_by(:name => "Silver")
    end
end

class Reward < ActiveRecord::Base
  has_many :earned_rewards
  has_many :players, :through => :earned_rewards
end

class EarnedReward < ActiveRecord::Base
  belongs_to :player
  belongs_to :reward

  validates_uniqueness_of :reward_id, :scope => [:reward_id, :player_id]
end

module FirstOrBuild
  def first_or_build(attributes = nil, options = {}, &block)
    first || scoping{ proxy_association.build(attributes, &block) }
  end
end

Upvotes: 2

Views: 1378

Answers (2)

Abraham Chan
Abraham Chan

Reputation: 649

EDIT: I've realised that my previous answer wouldn't work, as the new Reward is not associated to the parent Player model.

In order to correctly associate the two, you need to use build. See https://stackoverflow.com/a/18724458/4073431

In short, we only want to build if it doesn't already exist, so we call first || build

Specifically:

class Player < ActiveRecord::Base
  has_many :earned_rewards
  has_many :rewards, -> { extending FirstOrBuild }, :through => :earned_rewards

  before_update :assign_rewards, :if => :points_changed?

  def assign_rewards
    case self.points
    when 1000...2000
      self.rewards.where(:name => "Bronze").first_or_build
    when 2000...3000
      self.rewards.where(:name => "Silver").first_or_build
    end
end

class Reward < ActiveRecord::Base
  has_many :earned_rewards
  has_many :players, :through => :earned_rewards
end

class EarnedReward < ActiveRecord::Base
  belongs_to :player
  belongs_to :reward

  validates_uniqueness_of :reward_id, :scope => [:reward_id, :player_id]
end

module FirstOrBuild
  def first_or_build(attributes = nil, options = {}, &block)
    first || scoping{ proxy_association.build(attributes, &block) }
  end
end

When you build an association, it adds it to the parent so that when the parent is saved, the child is also saved. E.g.

pry(main)> company.customers.where(:fname => "Bob")
  Customer Load (0.1ms)  SELECT "customers".* FROM "customers"
=> [] # No customer named Bob
pry(main)> company.customers.where(:fname => "Bob").first_or_build
=> #<Customer id: nil, fname: "Bob"> # returns you an unsaved Customer
pry(main)> company.save
=> true
pry(main)> company.reload.customers
=> [#<Customer id: 1035, fname: "Bob">] # Bob gets created when the company gets saved
pry(main)> company.customers.where(:fname => "Bob").first_or_build
=> #<Customer id: 1035, fname: "Bob"> # Calling first_or_build again will return the first Customer with name Bob

Since our code is running in a before_update hook, the Player will be saved as well as any newly built Rewards as well.

Upvotes: 2

Eugene Tkachenko
Eugene Tkachenko

Reputation: 232

You should validate it in db also

Add follwing in migrate file-

add_index :earnedrewards, [:reward_id, :player_id], unique: true

Upvotes: 3

Related Questions