I have badges (sorta like StackOverflow).
Some of them can be attached to badgeable things (e.g. a badge for >X comments on a post is attached to the post). Almost all come in multiple levels (e.g. >20, >100, >200), and you can only have one level per badgeable x badge type (= badgeset_id).
To make it easier to enforce the one-level-per-badge constraint, I want badgings to specify their badge by a two-column foreign key - badgeset_id and level - rather than by primary key (badge_id), though badges does have a standard primary key too.
In code:
class Badge < ActiveRecord::Base
  has_many :badgings, :dependent => :destroy
  # integer: badgeset_id, level
  validates_uniqueness_of :badgeset_id, :scope => :level
end
class Badging < ActiveRecord::Base
  belongs_to :user
  # integer: badgset_id, level instead of badge_id
  #belongs_to :badge # <-- how to specify? 
  belongs_to :badgeable, :polymorphic => true
  validates_uniqueness_of :badgeset_id, :scope => [:user_id, :badgeable_id]
  validates_presence_of :badgeset_id, :level, :user_id  
  # instead of this:
  def badge
    Badge.first(:conditions => {:badgeset_id => self.badgeset_id, :level => self.level})
  end
end
class User < ActiveRecord::Base
  has_many :badgings, :dependent => :destroy do
    def grant badgeset, level, badgeable = nil
      b = Badging.first(:conditions => {:user_id => proxy_owner.id, :badgeset_id => badgeset,
        :badgeable_id => badgeable.try(:id), :badgeable_type => badgeable.try(:class)}) ||
        Badging.new(:user => proxy_owner, :badgeset_id => badgeset, :badgeable => badgeable)
      b.level = level
      b.save
    end
  end
  has_many :badges, :through => :badgings
  # ....
end
How I can specify a belongs_to association that does that (and doesn't try to use a badge_id), so that I can use the has_many :through?
ETA: This partially works (i.e. @badging.badge works), but feels dirty:
belongs_to :badge, :foreign_key => :badgeset_id, :primary_key => :badgeset_id, :conditions => 'badges.level = #{level}'
Note that the conditions is in single quotes, not double, which makes it interpreted at runtime rather than loadtime.
However, when trying to use this with the :through association, I get the error undefined local variable or method 'level' for #<User:0x3ab35a8>. And nothing obvious (e.g. 'badges.level = #{badgings.level}') seems to work...
ETA 2: Taking EmFi's code and cleaning it up a bit works. It requires adding badge_set_id to Badge, which is redundant, but oh well.
The code:
class Badge < ActiveRecord::Base
  has_many :badgings
  belongs_to :badge_set
  has_friendly_id :name
  validates_uniqueness_of :badge_set_id, :scope => :level
  default_scope :order => 'badge_set_id, level DESC'
  named_scope :with_level, lambda {|level| { :conditions => {:level => level}, :limit => 1 } }
  def self.by_ids badge_set_id, level
    first :conditions => {:badge_set_id => badge_set_id, :level => level} 
  end
  def next_level
    Badge.first :conditions => {:badge_set_id => badge_set_id, :level => level + 1}
  end
end
class Badging < ActiveRecord::Base
  belongs_to :user
  belongs_to :badge 
  belongs_to :badge_set
  belongs_to :badgeable, :polymorphic => true
  validates_uniqueness_of :badge_set_id, :scope => [:user_id, :badgeable_id]
  validates_presence_of :badge_set_id, :badge_id, :user_id  
  named_scope :with_badge_set, lambda {|badge_set|
    {:conditions => {:badge_set_id => badge_set} }
  }
  def level_up level = nil
    self.badge = level ? badge_set.badges.with_level(level).first : badge.next_level
  end
  def level_up! level = nil
    level_up level
    save
  end
end
class User < ActiveRecord::Base
  has_many :badgings, :dependent => :destroy do
    def grant! badgeset_id, level, badgeable = nil
      b = self.with_badge_set(badgeset_id).first || 
         Badging.new(
            :badge_set_id => badgeset_id,
            :badge => Badge.by_ids(badgeset_id, level), 
            :badgeable => badgeable,
            :user => proxy_owner
         )
      b.level_up(level) unless b.new_record?
      b.save
    end
    def ungrant! badgeset_id, badgeable = nil
      Badging.destroy_all({:user_id => proxy_owner.id, :badge_set_id => badgeset_id,
        :badgeable_id => badgeable.try(:id), :badgeable_type => badgeable.try(:class)})
    end
  end
  has_many :badges, :through => :badgings
end
While this works - and it's probably a better solution - I don't consider this an actual answer to the question of how to do a) multi-key foreign keys, or b) dynamic-condition associations that work with :through associations. So if anyone has a solution for that, please speak up.
Seems like it might workout best if you separate Badge into two models. Here's how I'd break it down to achieve the functionality you want. I threw in some named scopes to keep the code that actually does things clean.
class BadgeSet
  has_many :badges
end
class Badge
  belongs_to :badge_set
  validates_uniqueness_of :badge_set_id, :scope => :level
  named_scope :with_level, labmda {|level
    { :conditions => {:level => level} }
  }
  named_scope :next_levels, labmda {|level
    { :conditions => ["level > ?", level], :order => :level }
  }
  def next_level 
    Badge.next_levels(level).first
  end
end
class Badging < ActiveRecord::Base
  belongs_to :user
  belongs_to :badge 
  belongs_to :badge_set
  belongs_to :badgeable, :polymorphic => true
  validates_uniqueness_of :badge_set_id, :scope => [:user_id, :badgeable_id]
  validates_presence_of :badge_set_id, :badge_id, :user_id  
  named_scope :with_badge_set, lambda {|badge_set|
    {:conditions => {:badge_set_id => badge_set} }
  }
  def level_up(level = nil)
    self.badge = level ? badge_set.badges.with_level(level).first 
      : badge.next_level
    save
  end
end
class User < ActiveRecord::Base
  has_many :badgings, :dependent => :destroy do
    def grant badgeset, level, badgeable = nil
      b = badgings.with_badgeset(badgeset).first() || 
         badgings.build(
            :badge_set => :badgeset,
            :badge => badgeset.badges.level(level), 
            :badgeable => badgeable
         )
      b.level_up(level) unless b.new_record?
      b.save
    end
  end
  has_many :badges, :through => :badgings
  # ....
end
                        If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With