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
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.
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:
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With