Cloning a relation in rails

1.3k views Asked by At

I'm getting deprecation errors on an upgrade to rails 4.2.1, Modifying already cached Relation. The cache will be reset. Use a cloned Relation to prevent this warning.

The action I'm trying to run gets number of users by month who have logged in.

My test is just simply:

get :page
expect(response).to be_success

Controller action:

def page
  @months = {}
  (0..11).each do |month|
     @months[month] = User.group(:usergroup).number_first_logged_in(Date.new(Date.today.year,month+1, 1))
  end
end

User model

class Model < ActiveRecord::Base
   ...
   def number_first_logged_in(month)
     where(first_logged_in_at: month.beginning_of_month..month.end_of_month).count
   end
end

I realize that I'm running almost the same query 12 times but with different parameters. This method is used elsewhere when the users are not grouped. How can I 'clone' a relation as suggested in the deprecation warning?

I don't want to simply ignore this as it's filling up my screen whilst running tests, which is not very helpful

4

There are 4 answers

0
marcus3006 On BEST ANSWER

In my case, it was the squeel gem which produces the deprecation warning. A simple monkeypatch fixes the warning.

module Squeel
  module Adapters
    module ActiveRecord
      module RelationExtensions

        def execute_grouped_calculation(operation, column_name, distinct)
          super
        end

      end
    end
  end
end

I'm not sure if it breaks squeel behaviour, but it works for me. It looks like to be a problem with Rails 4.2.x in combination with squeel. I also pushed this into the squeel issue tracker https://github.com/activerecord-hackery/squeel/issues/374.

1
Alejandro Babio On

Edited

First and foremost your code works. It do not raise ImmutableRelation or show deprecation messages running on rails 4.2.1.

The code on your page action doesn't need clone a relation, because it creates a new one on each month step (with: User...). Yes it does 12 queries, but it is not an issue for you.

It's not too important, but you have two typo errors at the question. Your model must change def number_first_logged_in(month) with def self.number_first_logged_in(month) and model name must to be User and not Model.

I test it at rails console (over Products instead of User, and using :created_at instead of :first_logged_in_at field), but it's the same and works fine. And I pretty sure that if you start a fresh rails 4.2.1 app and use the code at the question (with typos fixed) it will work.

alejandro@work-one [ruby-2.1.1@rails42]: ~/rails/r42example 
[09:14:04] $ rails c
Loading development environment (Rails 4.2.1)
~/rails/r42example (development) > @m = {};(0..11).each {|m| @m[m] = Product.group(:name).number_first_logged_in(Date.new(Date.today.year,m+1, 1)) }
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-01-01' AND '2015-01-31') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-02-01' AND '2015-02-28') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-03-01' AND '2015-03-31') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-04-01' AND '2015-04-30') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-05-01' AND '2015-05-31') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-06-01' AND '2015-06-30') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-07-01' AND '2015-07-31') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-08-01' AND '2015-08-31') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-09-01' AND '2015-09-30') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-10-01' AND '2015-10-31') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-11-01' AND '2015-11-30') GROUP BY "products"."name"
   (0.1ms)  SELECT COUNT(*) AS count_all, name AS name FROM "products" WHERE ("products"."created_at" BETWEEN '2015-12-01' AND '2015-12-31') GROUP BY "products"."name"
=> 0..11

But, you have an issue. This issue is not related to the public rails API, because it has no methods that could raise these error or deprecation. Rails team says that any #nodoc public method is not part of the public API. Much of the query methods (if not all), have a pair bang method (code), which modifies the relation (instead of return a clone) and raises InmutableRelation error (or deprecation message on previous version). These public methods are #nodoc and are not part of the public rails API.

What to do? It's not easy:

  1. search your code for these bang methods, perhaps a monkey patch done on AR.
  2. check the gems you are using. Perhaps, starting a fresh app with the code working, and add all the gems you have in your target app, and if fails, trial and error removing gems until it works again.

I refer to the bang methods, but it's also true for the method that modify the relation (this can't be done by the public API). You must look for monkey patch or extend over relation.

That must be enough to fix the issue.

I read your comment, and I got the point, I think these options:

  • Rails database independence works through arel. And arel has no methods to work directly with db functions (month of a date field). You can extend arel and write it, but you need to write one for PostgreSql one for MySql other for Sqlite. (Too expensive, at this point)

  • If you use the same db manager on dev/test/prod you can use a partial text query as I suggested. (This doesn't like you)

  • Keep the 12 queries (I think you can live with this one)

  • Add a dedicated field to group_by (year_month could be). (Very rigig, hard to change)

I keep the old answer, because: If I were you, I'll do something like this:

class User
  scope :for_current_year, -> { where(created_at: Date.today.beginning_of_year..Date.today.end_of_year }
end

On controller page action you can use: (I suggest use that)

User.for_current_year
  .group("date_trunc('month', users.created_at)", "usergroup").count

Which returns a hash with this pattern: (more of count here)

{
  [<first date of the month of created_at>, <usergroup>] => count,
  ...
}

But if you want to get the same @months you had before, you must map the result with ruby.

def page
  @months = User.for_current_year
    .group("date_trunc('month', users.created_at)", "usergroup").count
    .map { |k,v| {k[0].month => {k[1] => v}} }
end

Note1: this code works for PostgreSQL because it uses the function date_trunc(...), if you need to use with MySql you want to use month(users.created_at) instead. When mapping with MySql you need use k[0] instead of k[0].month.

Note2: The group call has separated parameters for its fields, because you want two values on the key of the returned hash.

0
AudioBubble On

I see two possible solutions:

You could clear the cache between queries: ActiveRecord::Base.connection.query_cache.clear from Clearing active Record Cache

You could use .dup to clone the relation before you manipulate it so you don't invalidate the cache.

1
Prakash Murthy On

Short answer:

Ignore it. The deprecation message has been removed from the master branch of rails. Rails 5 will not be complaining about it.

Long answer:

The following issues/commits seem to give an impression that the deprecation message was added by mistake, and has been removed:

Might be worth trying out your code with the master branch of rails.