Skip to content

Commit

Permalink
Extract preload_counts in a gem.
Browse files Browse the repository at this point in the history
  • Loading branch information
smathieu committed Sep 18, 2011
0 parents commit aa9b1ea
Show file tree
Hide file tree
Showing 10 changed files with 206 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
@@ -0,0 +1,7 @@
*.gem
.bundle
Gemfile.lock
pkg/*
tags
*.swp
*.swo
1 change: 1 addition & 0 deletions .rvmrc
@@ -0,0 +1 @@
rvm use 1.8.7@preload_counts
4 changes: 4 additions & 0 deletions Gemfile
@@ -0,0 +1,4 @@
source "http://rubygems.org"

# Specify your gem's dependencies in preload_counts.gemspec
gemspec
1 change: 1 addition & 0 deletions Rakefile
@@ -0,0 +1 @@
require "bundler/gem_tasks"
3 changes: 3 additions & 0 deletions lib/preload_counts.rb
@@ -0,0 +1,3 @@
require "preload_counts/version"
require "preload_counts/ar"

89 changes: 89 additions & 0 deletions lib/preload_counts/ar.rb
@@ -0,0 +1,89 @@
# This adds a scope to preload the counts of an association in one SQL query.
#
# Consider the following code:
# Service.all.each{|s| puts s.incidents.acknowledged.count}
#
# Each time count is called, a db query is made to fetch the count.
#
# Adding this to the Service class:
#
# preload_counts :incidents => [:acknowledged]
#
# will add a preload_incident_counts scope to preload the counts and add
# accessors to the class. So our codes becaumes
#
# Service.preload_incident_counts.all.each{|s| puts s.acknowledged_incidents_count}
#
# And only requires one DB query.
module PreloadCounts
module ClassMethods
def preload_counts(options)
options = Array(options).inject({}) {|h, v| h[v] = []; h} unless options.is_a?(Hash)
options.each do |association, scopes|
scopes = scopes + [nil]

# Define singleton metho to load all counts
name = "preload_#{association.to_s.singularize}_counts"
singleton = class << self; self end
singleton.send :define_method, name do
sql = ['*'] + scopes_to_select(association, scopes)
sql = sql.join(', ')
scoped(:select => sql)
end

scopes.each do |scope|
# Define accessor for each count
accessor_name = find_accessor_name(association, scope)
define_method accessor_name do
result = send(association)
result = result.send(scope) if scope
(self[accessor_name] || result.size).to_i
end
end

end
end

private
def scopes_to_select(association, scopes)
scopes.map do |scope|
scope_to_select(association, scope)
end
end

def scope_to_select(association, scope)
resolved_association = association.to_s.singularize.camelize.constantize
scope_sql = if scope
resolved_association.scopes[scope].call(resolved_association).send(:construct_finder_sql, {})
else
"1 = 1"
end
# FIXME This is a really hacking way of getting the named_scope condition.
# In Rails 3 we would have AREL to get to it.
condition = scope_sql.gsub(/^.*WHERE/, '')
sql = <<-SQL
(SELECT count(*)
FROM #{association}
WHERE #{association}.#{to_s.underscore}_id = #{table_name}.id AND
#{condition}) as #{find_accessor_name(association, scope)}
SQL
end

def find_accessor_name(association, scope)
accessor_name = "#{association}_count"
accessor_name = "#{scope}_" + accessor_name if scope
accessor_name
end
end

module InstanceMethods
end

def self.included(receiver)
receiver.extend ClassMethods
receiver.send :include, InstanceMethods
end
end

ActiveRecord::Base.class_eval { include PreloadCounts }

3 changes: 3 additions & 0 deletions lib/preload_counts/version.rb
@@ -0,0 +1,3 @@
module PreloadCounts
VERSION = "0.0.1"
end
24 changes: 24 additions & 0 deletions preload_counts.gemspec
@@ -0,0 +1,24 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require "preload_counts/version"

Gem::Specification.new do |s|
s.name = "preload_counts"
s.version = PreloadCounts::VERSION
s.authors = ["Simon Mathieu"]
s.email = ["simon.math@gmail.com"]
s.homepage = ""
s.summary = %q{Preload association or scope counts.}
s.description = %q{Preload association or scope counts. This can greatly reduce the number of queries you have to perform and thus yield great performance gains.}

s.rubyforge_project = "preload_counts"

s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]

s.add_development_dependency "rspec"
s.add_development_dependency "rails", "~> 2.3.12"
s.add_development_dependency "sqlite3"
end
65 changes: 65 additions & 0 deletions spec/active_record_spec.rb
@@ -0,0 +1,65 @@
require 'spec_helper'

ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")

def setup_db
ActiveRecord::Schema.define(:version => 1) do
create_table :posts do |t|
end

create_table :comments do |t|
t.integer :post_id, :nulll => false
end
end
end

setup_db

class Post < ActiveRecord::Base
has_many :comments
preload_counts :comments => [:with_even_id]
end

class Comment < ActiveRecord::Base
belongs_to :post

named_scope :with_even_id, lambda { {:conditions => "comments.id % 2 == 0"} }
end

def create_data
post = Post.create
10.times { post.comments.create }
end

create_data

describe Post do
it "should have a preload_comment_counts scope" do
Post.should respond_to(:preload_comment_counts)
end

describe 'instance' do
let(:post) { Post.first }

it "should have a comment_count accessor" do
post.should respond_to(:comments_count)
end

it "should be able to get count without preloading them" do
post.comments_count.should equal(10)
end
end

describe 'instance with preloaded count' do
let(:post) { Post.preload_comment_counts.first }

it "should be able to get the association count" do
post.comments_count.should equal(10)
end

it "should be able to get the association count with a named scope" do
post.with_even_id_comments_count.should equal(5)
end
end
end

9 changes: 9 additions & 0 deletions spec/spec_helper.rb
@@ -0,0 +1,9 @@
require 'rspec'
require 'active_record'
require 'preload_counts'

RSpec.configure do |config|
config.color_enabled = true
config.formatter = 'documentation'
end

0 comments on commit aa9b1ea

Please sign in to comment.