A fun little challenge that arose recently while working on the idea of ranking for a ‘team competition’ was how to represent the rank itself. A team is ‘ahead’ in rank if they have more activity.
Initially, to find the rank of a particular team I wrote a query to return all the teams in the order of teams that had the most activity to the least and then found the position of the current team in that array.
most_activity_desc is a scope that makes the above query. There was another method to turn that index into a ordinal representation of the rank.
Class Team < ActiveRecord::Base belongs_to :challenge has_many :activities scope :most_activity_desc, -> # Returns teams in order of most activity desc def rank rank_index ? (rank_index + 1).ordinalize : '' end private def rank_index challenge.teams.most_activity_desc.index(self) end end
This works, but
Team now has knowledge of what it means to be a rank. It knows that to create a rank from a zero base index we need to add one. It knows that if the index is
nil, then the rank is an empty string; otherwise it needs to be the result of calling rails’
#ordinalize method on the rank value. In addition we need that same knowledge in another place ranking individuals on a team. It sure would be nice to extract what it means to be a rank so that we can share it around and make it easy to test. One possible solution would be to introduce a
Rank ‘value object’.
What is a value object exactly? Martin Fowler’s site succinctly describes them this way:
A small simple object, like money or a date range, whose equality isn’t based on identity.
So if you have two objects that have the same values, then
obj1 == obj2 # true
Additionally, the objects should be immutable; if two objects were once equal… they should always be equal. ~ c2.com
Great! Let’s make the
Rank value object. Here’s the first pass:
# Rank is a value object that represents an ordinal ranking class Rank # Initialize a Rank from a zero base index def self.from_index(index) new(index&.+ 1) end def initialize(value) @value = value end def to_s value ? value.ordinalize : '' end private attr_reader :value end
This is kind of a crazy use of the new Ruby 2.3
#&., but none the less I was excited to sneak it in there. If
index is nil, it will call
.new with nil; otherwise it will add 1 and initialize a new
Rank with the result.
With this, we can now initialize a Rank from an index
Rank.from_index(2) => #<Rank:0x007fb611f4dcf0 @value=3>
and output the appropriate string representation
r = Rank.from_index(2) r.to_s # "3rd"
and test ranks for equality
r = Rank.new(2) r2 = Rank.new(2) r == r2 # false
Err, or maybe we have more work to do. We are still comparing for equality using the objects themselves. To be a value object, we need override the
#eql? methods so that we are comparing equality using the value of the objects.
# Rank is a value object that represents an ordinal ranking class Rank attr_reader :value # Initialize a Rank from a zero base index def self.from_index(index) new(index&.+ 1) end def initialize(value) @value = value end def ==(other) value == other.value end alias :eql? :== def to_s value ? value.ordinalize : '' end end
r = Rank.new(2) r2 = Rank.new(2) r == r2 # true r.eql? r2 # true
Awesome! Really this is all we need for our current case; but we can’t compare two grade for anything but equality. What if we want to see which rank is greater than the other so that we could do things like sort based on rank? We are just a Ruby mixin away :-)
# Rank is a value object that represents an ordinal ranking class Rank include Comparable attr_reader :value alias :eql? :== # Initialize a Rank from a zero base index def self.from_index(index) new(index&.+ 1) end def initialize(value) @value = value end def <=>(other) value <=> other.value end def to_s value ? value.ordinalize : '' end end
In order to use
Comparable, all we have to do is define
#<=> and then we get all the comparison operators and the method
#between?. Also, I left the alias for
Comparable, we can do all kinds of cool things now:
r = Rank.new(1) r2 = Rank.new(2) a = [r2, r] r == r2 # false r < r2 # true r >= r2 # false a # [#<Rank:0x007fb6112fbda8 @value=2>, #<Rank:0x007fb611333ac8 @value=1>] a.sort # [#<Rank:0x007fb611333ac8 @value=1>, #<Rank:0x007fb6112fbda8 @value=2>]
So there it is… a fun little value object for
Rank that we can now use anywhere we need something that acts like a rank! Also, it makes testing much faster and easier because a
Rank can now be tested just like any other object. Here’s the start of a simple test in the context of Rails:
require 'test_helper' class RankTest < ActiveSupport::TestCase describe '.from_index' do it 'initializes a Rank from a zero base index 0' do Rank.from_index(0).value.must_equal 1 end it 'initializes a Rank from a zero base index 5' do Rank.from_index(5).value.must_equal 6 end it 'initializes a Rank from a nil index' do Rank.from_index(nil).value.must_equal nil end end describe '#to_s' do it "returns the Rank as '1st' for a value of 1" do Rank.new(1).to_s.must_equal '1st' end it "returns the Rank as '2nd' for a value of 2" do Rank.new(2).to_s.must_equal '2nd' end it "returns the Rank as '3rd' for a value of 3" do Rank.new(3).to_s.must_equal '3rd' end it "returns the Rank as '4th' for a value of 4" do Rank.new(4).to_s.must_equal '4th' end it "returns the Rank as '' for a value of nil" do Rank.new(nil).to_s.must_equal '' end end end
Happy 4th of July!