├── Rakefile ├── .gitignore ├── lib ├── includes-count │ └── version.rb └── includes-count.rb ├── Gemfile ├── includes-count.gemspec └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /lib/includes-count/version.rb: -------------------------------------------------------------------------------- 1 | module IncludesCount 2 | VERSION = "0.2" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in includes-count.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /includes-count.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "includes-count/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "includes-count" 7 | s.version = IncludesCount::VERSION 8 | s.authors = ["Santiago Palladino"] 9 | s.email = ["spalladino@manas.com.ar"] 10 | s.homepage = "" 11 | s.summary = %q{Adds includes_count method to active record queries} 12 | s.description = %q{The includes_count method executes a SQL count on an association to retrieve its number of records, optionally filtered by a set of conditions.} 13 | 14 | s.rubyforge_project = "includes-count" 15 | 16 | s.files = `git ls-files`.split("\n") 17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | s.require_paths = ["lib"] 20 | 21 | # specify any dependencies here; for example: 22 | # s.add_development_dependency "rspec" 23 | s.add_runtime_dependency "activerecord" 24 | end 25 | -------------------------------------------------------------------------------- /lib/includes-count.rb: -------------------------------------------------------------------------------- 1 | require "includes-count/version" 2 | 3 | module ActiveRecord 4 | 5 | module Associations 6 | 7 | class CountPreloader < Preloader 8 | 9 | class Count < ActiveRecord::Associations::Preloader::HasOne 10 | 11 | def initialize(klass, owners, reflection, preload_options) 12 | super 13 | @preload_options[:select] ||= "#{table.name}.#{association_key_name}, COUNT(id) AS #{count_name}" 14 | end 15 | 16 | def count_name 17 | preload_options[:count_name].try(:to_s) || "#{reflection.name}_count" 18 | end 19 | 20 | def preload 21 | associated_records_by_owner.each do |owner, associated_records| 22 | sum = associated_records.map{|r| r[count_name] || 0}.sum 23 | owner.instance_eval " 24 | def #{count_name} 25 | @#{count_name} ||= 0 26 | @#{count_name} += #{sum} 27 | end 28 | " 29 | end 30 | end 31 | 32 | def build_scope 33 | super.group(association_key) 34 | end 35 | 36 | end 37 | 38 | class CountHasMany < Count 39 | end 40 | 41 | class CountHasManyThrough < Count 42 | include ThroughAssociation 43 | 44 | def associated_records_by_owner 45 | through_records = through_records_by_owner 46 | 47 | ActiveRecord::Associations::CountPreloader.new( 48 | through_records.values.flatten, 49 | source_reflection.name, options.merge(@preload_options) 50 | ).run 51 | 52 | through_records 53 | 54 | end 55 | 56 | def through_records_by_owner 57 | ActiveRecord::Associations::Preloader.new( 58 | owners, through_reflection.name, 59 | through_options 60 | ).run 61 | 62 | Hash[owners.map do |owner| 63 | through_records = Array.wrap(owner.send(through_reflection.name)) 64 | 65 | # Dont cache the association - we would only be caching a subset 66 | if reflection.options[:source_type] && through_reflection.collection? 67 | owner.association(through_reflection.name).reset 68 | end 69 | 70 | [owner, through_records] 71 | end] 72 | end 73 | 74 | def through_options 75 | through_options = {} 76 | 77 | if options[:source_type] 78 | through_options[:conditions] = { reflection.foreign_type => options[:source_type] } 79 | else 80 | if options[:conditions] 81 | through_options[:include] = options[:include] || options[:source] 82 | through_options[:conditions] = options[:conditions] 83 | end 84 | 85 | through_options[:order] = options[:order] 86 | end 87 | 88 | if @preload_options[:through_options] 89 | through_preload_options = @preload_options[:through_options][through_reflection.name.to_sym] || {} 90 | through_options.merge!(through_preload_options) 91 | end 92 | 93 | through_options 94 | end 95 | 96 | end 97 | 98 | def preloader_for(reflection) 99 | case reflection.macro 100 | when :has_many 101 | reflection.options[:through] ? CountHasManyThrough : CountHasMany 102 | else 103 | raise "unsupported association kind #{reflection.macro}" 104 | end 105 | end 106 | 107 | end 108 | 109 | end 110 | 111 | class Relation 112 | 113 | attr_accessor :includes_counts_values 114 | 115 | def includes_count(*args) 116 | args.reject! {|a| a.blank? } 117 | 118 | return self if args.empty? 119 | 120 | relation = clone 121 | (relation.includes_counts_values ||= []) << args 122 | relation 123 | end 124 | 125 | def to_a_with_includes_count 126 | return @records if loaded? 127 | 128 | to_a_without_includes_count 129 | 130 | (includes_counts_values || []).each do |association_with_opts| 131 | association, opts = association_with_opts 132 | ActiveRecord::Associations::CountPreloader.new(@records, [association], opts).run 133 | end 134 | 135 | @records 136 | end 137 | 138 | alias_method_chain :to_a, :includes_count 139 | 140 | end 141 | 142 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Includes Count 2 | ============== 3 | 4 | This gem adds an `includes_count` method to active record queries, which adds the count of an association to a relation using a simple `SELECT` SQL query in a similar way as the `includes` method does, only that retrieving counts instead of the full records collection. 5 | 6 | This gem has been tested with ActiveRecord version 3.1.3. 7 | 8 | Usage 9 | ===== 10 | 11 | For example, in the following model: 12 | 13 | ```ruby 14 | class Blog 15 | has_many :posts 16 | has_many :comments, :through => :posts 17 | end 18 | 19 | class Post 20 | belongs_to :blog 21 | has_many :comments 22 | end 23 | 24 | class Comment 25 | belongs_to :post 26 | end 27 | ``` 28 | 29 | It is possible to retrieve the number of posts in every blog with the command: 30 | 31 | ```ruby 32 | blogs_with_posts_count = Blog.scoped.includes_count(:posts) 33 | ``` 34 | 35 | This will issue a simple `SELECT` query retrieving all counts and assigning them in memory, thus not requiring an `INNER JOIN` that could be expensive to handle in the database: 36 | 37 | ```sql 38 | SELECT SQL_NO_CACHE posts.blog_id, COUNT(id) AS posts_count 39 | FROM `posts` 40 | WHERE `posts`.`blog_id` IN (1, 2, 3, 4, 5, 6, 7, 8) 41 | GROUP BY `posts`.`blog_id` 42 | ``` 43 | 44 | The count is projected to a field named by default `association_name_count`: 45 | 46 | ```ruby 47 | blogs_with_posts_count.map(&:posts_count) 48 | ``` 49 | 50 | The name of the method can be changed by supplying the `count_name` option: 51 | 52 | ```ruby 53 | blogs_with_posts_count = Blog.scoped.includes_count(:posts, :count_name => 'number_of_posts') 54 | blogs_with_posts_count.map(&:posts_count) 55 | ``` 56 | 57 | The execution of the count is delayed until execution of the query, as happens with the `includes` clause, so further clauses, such as `where`, can be set to the relation: 58 | 59 | ```ruby 60 | latest_blogs_with_posts_count = Blog.scoped.includes_count(:posts).where('updated_at > ?', 1.week.ago) 61 | ``` 62 | 63 | This will retrieve only the blogs that have been updated since 1 week ago, along with their counts. Supposing there are only two blogs that match that condition (ids 3 and 5), the `SELECT` query issued will be the following: 64 | 65 | ```sql 66 | SELECT SQL_NO_CACHE posts.blog_id, COUNT(id) AS posts_count 67 | FROM `posts` 68 | WHERE `posts`.`blog_id` IN (3, 5) 69 | GROUP BY `posts`.`blog_id` 70 | ``` 71 | 72 | Conditions can be specified on the included association (using a string, a hash or a proc), in order to filter which records are to be counted: 73 | 74 | ```ruby 75 | blogs_with_rails_posts_count = Blog.scoped.includes_count(:posts, :count_name => 'rails_posts_count', :conditions => "category = 'rails'") 76 | ``` 77 | 78 | ```sql 79 | SELECT SQL_NO_CACHE posts.blog_id, COUNT(id) AS posts_count 80 | FROM `posts` 81 | WHERE `posts`.`blog_id` IN (3, 5) 82 | AND `posts`.`category` = 'rails' 83 | GROUP BY `posts`.`blog_id` 84 | ``` 85 | 86 | Through Associations 87 | -------------------- 88 | 89 | The `includes_count` method also supports through associations, and issues as many `SELECT` queries as needed to navigate the hierarchy and obtain the specified counts. 90 | 91 | ```ruby 92 | blogs_with_comments_count = Blog.scoped.includes_count(:comments) 93 | ``` 94 | 95 | ```sql 96 | SELECT SQL_NO_CACHE `posts`.* 97 | FROM `posts` 98 | WHERE `posts`.`blog_id` IN (1, 2, 3, 4, 5, 6, 7, 8) 99 | ``` 100 | 101 | ```sql 102 | SELECT SQL_NO_CACHE `comments`.post_id, COUNT(id) AS comments_count 103 | FROM `comments` 104 | WHERE `comments`.`post_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) 105 | GROUP BY `comments`.`post_id` 106 | ``` 107 | 108 | As usual, the value can be accessed via the method named after the association, and overridden via `count_name`: 109 | 110 | ```ruby 111 | blogs_with_posts_count.map(&:comments_count) 112 | ``` 113 | 114 | It is also possible to specify conditions at any of the intermediate associations in the `through` association: 115 | 116 | ```ruby 117 | blogs_with_comments_count_from_rails_posts = Blog.scoped.includes_count(:comments, :through_options => { :posts => { :conditions => "category = 'rails'"} }) 118 | ``` 119 | 120 | ```sql 121 | SELECT SQL_NO_CACHE `posts`.* 122 | FROM `posts` 123 | WHERE `posts`.`blog_id` IN (1, 2, 3, 4, 5, 6, 7, 8) 124 | AND `posts`.`category` = 'rails' 125 | ``` 126 | 127 | ```sql 128 | SELECT SQL_NO_CACHE `comments`.post_id, COUNT(id) AS comments_count 129 | FROM `comments` 130 | WHERE `comments`.`post_id` IN (5, 6, 10, 11, 12) 131 | GROUP BY `comments`.`post_id` 132 | ``` 133 | 134 | 135 | Known Issues 136 | ------------ 137 | 138 | * The `includes_count` method is included only in `ActiveRecord::Relation` objects, which means you cannot execute it straight on a model. As a workaround, supply the method `scoped` before executing `includes_count`: `Blog.scoped.includes_count(:posts)` 139 | --------------------------------------------------------------------------------