Leon
Leon

Reputation: 881

How to add a method to an activerecord collection?

I would like to add a method to all collections for a specific model. Let's say I want to add the method my_complicated_averaging_method to the WeatherData collections:

WeatherData.all.limit(3).my_complicated_averaging_method()
Station.first.weatherdata.my_complicated_averaging_method()

What is the best way to do this? At the moment the only way I've found is like this:

class WeatherData < ActiveRecord::Base
  def self.my_complicated_averaging_method
    weighted_average = 0
    @relation.each do |post|
      # do something complicated
      # weighted_average = 
    end
    return weighted_average
  end
end

Is this a good way for adding a method to a collection? Is there a better / supported way to do this?

Upvotes: 20

Views: 15435

Answers (3)

el_quick
el_quick

Reputation: 4756

On Rails >= 4 you can to use where(nil) inplace of scoped

class Foo < ActiveRecord::Base  
  def self.bar
    where(nil).pluck(:id)
  end
end

Foo.where(id: [1, 2, 3]).order(:id).bar

And further, you can use #scope, for example:

class Foo < ActiveRecord::Base
  scope :bar, -> {where(nil).pluck(:id)}
end

Finally, You can write code like Foo.all.bar

Upvotes: 5

Edgars Jekabsons
Edgars Jekabsons

Reputation: 2853

There a lot of ways how to do it, and Yours is completely valid (though I personally prefer to wrap class methods into separate block check this out ), but as people are adding more business logic to their models and blindly follow "skinny controllers, fat models" concept, models turn into complete mess.

To avoid this mess it's a good idea to introduce service objects, in Your case it would be like this:

class AverageWeatherData
  class << self
    def data(collection)
      new(collection).data
    end
  end

  def initialize(collection)
    @collection = collection
  end

  def data
    @collection.reduce do |avg, post|
      # reduce goes through every post, each next iteration receives in avg a value of the last line of iteration
      # do something with avg and post 
    end
    # no need for explicit return, every line of Ruby code returns it's value
    # so this method would return result of the reduce
    # more on reduce: http://ruby-doc.org/core-2.0.0/Enumerable.html#method-i-reduce
  end
end

Now You can call this class directly by passing Your collection to it. But You can also proxy the call like this:

def self.my_complicated_averaging_method
  AverageWeatherData.data(@relation)
end

I encourage You to lear more of this approach by reading this blog: http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/

UPD

You are right using instance variable is a possible way to mess up object internals(plus it's not a public interface and it might change in future). My proposal here is to use method scoped. Basically replace @relation with scoped.

Check this example. I have used a model from my own project to show that it actually works

2.0.0p247 :001 > Tracking # just asking console to load this class before modifying it
# => Tracking(id: integer, action: string, cookie_id: string, ext_object_id: integer, created_at: datetime, updated_at: datetime)
2.0.0p247 :002 > class Tracking
2.0.0p247 :003?>     def self.fetch_ids
2.0.0p247 :004?>         scoped.map(&:id)
2.0.0p247 :005?>       end
2.0.0p247 :006?>   end
# => nil
2.0.0p247 :007 >
2.0.0p247 :008 >   Tracking.where(id: (1..100)).fetch_ids
#  Tracking Load (2.0ms)  SELECT "trackings".* FROM "trackings" WHERE ("trackings"."id" BETWEEN 1 AND 100)
# => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

UPD

In Rails 4 scoped is deprecated, so it's correct to use all.

all.map(&:id)

Upvotes: 17

Richard Peck
Richard Peck

Reputation: 76774

It looks good to get things working, but for more finesse I'm sure there is something much better. Admittedly, you weren't too specific in describing what you wanted to achieve with this, so I can only give you this broad suggestion

You may want to look into "Observer Classes"


I wrote a post about them here

Observer Classes basically monitor a particular model function & extend it. I think they're only for the before_filter, etc functions, but I don't see why you can't extend indvidual functions that you create

You'll have to use the rails-observers gem in rails 4.0+ to get them working, as they've been depreciated from the rails core

Upvotes: 0

Related Questions