2

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
Minutiae
  • 151
  • 6

2 Answers2

3

You should validate it in db also

Add follwing in migrate file-

add_index :earnedrewards, [:reward_id, :player_id], unique: true
  • Definitely good addition, because two requests at the same time in two different parallel processes can still create two entries, e.g. when an inpatient user clicks 'add' a second time while the first is still processing... – Raymundus Jul 18 '18 at 13:06
2

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.

Community
  • 1
  • 1
Abraham Chan
  • 629
  • 3
  • 12
  • I'm am trying your method, but it is not working. It is always creating a new Reward instead of finding the one that already exists and creating an EarnedReward record associating it to the Player record. – Minutiae Sep 24 '14 at 12:41
  • I've added a clearer example. I also tweaked the when conditions so that they don't need exactly 1000 points to receive the award. – Abraham Chan Sep 24 '14 at 23:30
  • I've updated my post to include the example you linked to. Could you take a look and tell me if I'm doing it correctly? I'm pretty new to Rails and Ruby, do you think you could explain what is happening? – Minutiae Sep 25 '14 at 20:46
  • I've added more code and an explanatory example. The only difference between your code and mine is the position of the -> { extending FirstOrBuild }, as we're calling it on Rewards rather than EarnedRewards. – Abraham Chan Sep 26 '14 at 03:18