Inject by SQL
Background In a Rails application we sometimes need to execute complicated aggregation queries especially for admin screen. For example, in a Search User page in admin screen, we might want to show The last login time of each user. The number of times each user logged in. in the search ...
Background
In a Rails application we sometimes need to execute complicated aggregation queries especially for admin screen. For example, in a Search User page in admin screen, we might want to show
- The last login time of each user.
- The number of times each user logged in.
in the search result. These kind of queries often happens in a search result in admin screen or CSV download functionality. We need to implement it without N+1 problem.
These aggregation queries can be easily implemented if we use caching mechanisms like counter_cache. But having cache columns for the sake of admin screen's minor requirements and placing loads on user's page does not sound a good idea.
Another possible solution is to use find_by_sql. At first glance it works well, but we will soon realize that it's not easy to cooperate well with complicated search form implemented with ransack because it uses scope based query. And we will suffer from nasty bugs which only happens in combination with only some of the search conditions. We want things to be decoupled.
In such a case, we can use the following inject_by_sql method and can implement aggregation query as a reusable, independent query.
Solution
First we need to define inject_by_sql as a class method of all ActiveRecord model classes.
module ActiveRecord module InjectionExt extend ActiveSupport::Concern module ClassMethods def inject_by_sql( models, sql, options = {} ) sanitized = sanitize_sql( [sql, options.merge(ids: models.map(&:id))]) result = self.connection.execute sanitized models_by_id = models.index_by(&:id) fields = result.fields raise "Duplicate columns: #{fields.inspect}" if fields.uniq.size != fields.size raise "Blank column name is detected: #{fields.inspect}" if fields.any?(&:blank?) index_of_id = fields.index("id") raise "There is not `id` column in the result." if index_of_id.nil? result.each do |row| id = row[index_of_id] next if id.nil? model = models_by_id[id] unless model raise "Model could not find for id=#{row[index_of_id].inspect}" end fields.zip(row).each do |key,value| next if key == "id" model[key] = value end end models.each do |m| fields.each do |k| m[k] = nil if m[k].nil? # make key end end models end end end class Base include InjectionExt end end
It just execute the given query and then inject the result value to the models using []= method of ActiveRecord. Note that the SQL has to have a field named id to work.
With this method, we can inject last_logined_at and number_of_login as follows.
class User class << self def inject_login_stats( users ) sql = <<-SQL SELECT user_id AS id, max(created_at) as last_logined_at, count(*) AS number_of_login FROM login_logs GROUP BY user_id SQL User.inject_by_sql( users, sql ) end end end
And you can call this class method like this
@search = User.ransack(params[:search]).result @users = @search.to_a User.inject_login_stats( @users )
Now you can access these injected values like this.
- @users.each do |u| tr td= u.name td= u.last_logined_at td= u.number_of_login
TODO
It would be happier if we can call injection methods in a similar manner with like preloads. For example,
User.inject(:number_of_login).ransack(:params)