views:

955

answers:

1

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.

+1  A: 

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
EmFi
That works, more or less. It's not quite an answer to the question, though it is an answer to the problem, and I'm crediting it as such.I've cleaned up your code and put it in the question.
Sai Emrys
I know. What you were asking seemed to go beyond what's easily done with Rails. Have you looked for plugins? At a glance, http://compositekeys.rubyforge.org/ seems like it might do what you're looking for.
EmFi