RedRobin2202
RedRobin2202

Reputation: 87

Using ActiveRecord getters on an array of objects

How can I define custom getter functions for ActiveRecord objects in Ruby that will perform operations on an array of the ActiveRecord objects?

For example, I would like to return weighted averages on an array of objects. So if I have Loan objects (1,2,3) with fields amount (100, 200, 300) and default_rate (.1, .2, .3), then with the normal ActiveRecord functions Loan.find(1).amount should return 100, Loan.find(2).default_rate should return .2.

But if I had Loan.find(2,3).default_rate, I would like it to return the weighted average of the default rates, which is .26. I know I can do this using SQL select statements but how can I "overload" the ActiveRecord getter to allow be to define a function when I call the getter on an array of Loan objects rather than a single Loan object?

The loan table has fields amount id, amount, and default_rate

class Loan < ActiveRecord::Base
  module Collection
    def default_rate
      sum(:default_rate * :amount) / sum(:amount)
    end
  end
end

class LoanGroup
  has_many :loans, :extend => Loan::Collection
end

#Then I try
obj = LoanGroup.where('id < 10')

This gives me the error that has_many is undefined in LoanGroup

Upvotes: 4

Views: 610

Answers (4)

numbers1311407
numbers1311407

Reputation: 34072

To avoid polluting Array's namespace with methods specific to collections of Loan records, you might make a wrapper for such a collection and call your methods on that.

Something like:

class LoanArray < Array
  def initialize(*args)
    find(*args)
  end

  def find(*args)
    replace Array.wrap(Loan.find(*args))
    self
  end

  def default_rate
    if length == 1
      self[0].default_rate 
    else
      inject(0) {|mem,loan| mem + loan.defualt_rate } / count
    end
  end
end

# then
arr = LoanArray.new(2,3).default_rate #=> 0.26
arr.find(1,2,3).default_rate          #=> 0.2
arr.length                            #=> 3
arr.find(1)                           #=> [<Loan id=1>]
arr.default_rate                      #=> 0.1
arr.length                            #=> 1

Original answer below: using an association extension

Use an association extension. This way the method will be the same whether on a loan, or a collection of loans.

class MyClass
  has_many :loans do
    def default_rate
      sum(:default_rate) / count
    end
  end
end

obj = MyClass.find(1)
obj.loans.first.default_rate               #=> 0.1
obj.loans.default_rate                     #=> 0.2
obj.loans.where(:id => [2,3]).default_rate #=> 0.26

If you wanted to keep the logic for loans in the Loan class, you could also write the extension there, e.g.:

class Loan
  module Collection
    def default_rate
      sum(:default_rate) / count
    end

    # plus other methods, as needed, e.g.
    def average_amount
      sum(:amount) / count
    end
  end
end

class MyClass
  has_many :loans, :extend => Loan::Collection
end

Edit: as @Santosh points out association.find does not return a relation, so it will not work here. You'd have to use where or some other method which returns a relation.

Upvotes: 2

Santosh
Santosh

Reputation: 1261

If I understood your question correctly, you can write class method for this.

class Loan
  def self.default_rate
    sum = 0
    all.each do |loan|
      sum += loan.default_rate
    end
    sum / all.count
  end
end

Then

Loan.where(:id => [2, 3]).default_rate 

Other solution if you want to use Loan.find(2,3) then you need to override the Array class.

class Array   
  def default_rate
    sum = 0     
    self.each do |loan|
      sum += loan.default_rate
    end
    sum / self.count
  end
end

Loan.find(2, 3).default_rate 

Upvotes: 1

Anton
Anton

Reputation: 2483

If you accept introducing a named scope, than you can exploit the fact scopes have the methods of the classes they are defined on. This way, having

class Shirt < ActiveRecord::Base
  scope :red, where(:color => 'red')

  def self.sum
    # consider #all as the records you are processing - the scope will
    # make it return neccessary ones only
    all.map(&:id).sum
  end
end

Shirt.red.sum

will yield you a sum id red shirts' ids (as opposed to Shirt.sum will yield a sum of all ids).

Having your BL layed out this way not only makes it cleaner, but also enables it to be re-used (you can invoke the #sum method on any scope, no matter how complex it is and you will still get valid results). In addition to that, it appears to be easily readable and doesn't involve any magic (well, almost :)

Hope this would clean you code up a bit :)

Upvotes: 0

siame
siame

Reputation: 8657

You might have a bit more freedom by not doing this as a class method; you could have a method header of default_rate (*args) to allow multiple arguments to be passed in using the splat operator, then check the args length. It can be done quite nicely recursively:

def default_rate(*args)
  sum = 0
  if args.length == 1
    return array_name[arg-1] # guessing that's how you store the actual var
  else
    args.each do |arg|
      sum += default_rate arg
    end
  end
  return sum/args.length
end

Upvotes: 0

Related Questions