├── rails └── init.rb ├── .rspec ├── spec ├── models │ ├── page.rb │ ├── folder.rb │ ├── writer.rb │ ├── person.rb │ ├── author.rb │ ├── city.rb │ ├── company.rb │ ├── country.rb │ ├── entry.rb │ ├── address.rb │ ├── reply.rb │ ├── pet.rb │ ├── student.rb │ ├── teacher.rb │ ├── mongoid │ │ ├── user.rb │ │ ├── comment.rb │ │ ├── company.rb │ │ ├── address.rb │ │ ├── entry.rb │ │ ├── category.rb │ │ └── post.rb │ ├── user.rb │ ├── newspaper.rb │ ├── submission.rb │ ├── relationship.rb │ ├── firm.rb │ ├── base_user.rb │ ├── client.rb │ ├── comment.rb │ ├── document.rb │ ├── category.rb │ └── post.rb ├── bullet │ ├── detector │ │ ├── base_spec.rb │ │ ├── association_spec.rb │ │ ├── counter_cache_spec.rb │ │ ├── unused_eager_loading_spec.rb │ │ └── n_plus_one_query_spec.rb │ ├── notification │ │ ├── counter_cache_spec.rb │ │ ├── unused_eager_loading_spec.rb │ │ ├── n_plus_one_query_spec.rb │ │ └── base_spec.rb │ ├── ext │ │ ├── string_spec.rb │ │ └── object_spec.rb │ ├── registry │ │ ├── object_spec.rb │ │ ├── association_spec.rb │ │ └── base_spec.rb │ ├── notification_collector_spec.rb │ └── rack_spec.rb ├── support │ ├── rack_double.rb │ ├── bullet_ext.rb │ ├── mongo_seed.rb │ └── sqlite_seed.rb ├── bullet_spec.rb ├── integration │ ├── counter_cache_spec.rb │ └── mongoid │ │ └── association_spec.rb └── spec_helper.rb ├── lib ├── bullet │ ├── version.rb │ ├── detector │ │ ├── base.rb │ │ ├── counter_cache.rb │ │ ├── association.rb │ │ ├── unused_eager_loading.rb │ │ └── n_plus_one_query.rb │ ├── ext │ │ ├── string.rb │ │ └── object.rb │ ├── registry.rb │ ├── notification │ │ ├── counter_cache.rb │ │ ├── unused_eager_loading.rb │ │ ├── n_plus_one_query.rb │ │ └── base.rb │ ├── registry │ │ ├── object.rb │ │ ├── association.rb │ │ └── base.rb │ ├── detector.rb │ ├── notification.rb │ ├── notification_collector.rb │ ├── mongoid2x.rb │ ├── mongoid3x.rb │ ├── mongoid4x.rb │ ├── mongoid5x.rb │ ├── dependency.rb │ ├── rack.rb │ ├── active_record4.rb │ ├── active_record41.rb │ ├── active_record3x.rb │ ├── active_record3.rb │ └── active_record42.rb └── bullet.rb ├── .gitignore ├── tasks └── bullet_tasks.rake ├── Gemfile.mongoid ├── Guardfile ├── Gemfile.rails-4.1 ├── Gemfile.rails-4.2 ├── Gemfile.mongoid-2.4 ├── Gemfile.mongoid-2.5 ├── Gemfile.mongoid-2.6 ├── Gemfile.mongoid-2.7 ├── Gemfile.mongoid-2.8 ├── Gemfile.mongoid-3.0 ├── Gemfile.mongoid-3.1 ├── Gemfile.mongoid-4.0 ├── Gemfile.rails-3.0 ├── Gemfile.rails-3.1 ├── Gemfile.rails-3.2 ├── Gemfile.rails-4.0 ├── Gemfile.mongoid-5.0 ├── Gemfile ├── update.sh ├── bullet.gemspec ├── MIT-LICENSE ├── .travis.yml ├── Rakefile ├── test.sh ├── CHANGELOG.md ├── Hacking.md ├── perf └── benchmark.rb └── README.md /rails/init.rb: -------------------------------------------------------------------------------- 1 | require 'bullet' 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format progress 3 | -------------------------------------------------------------------------------- /spec/models/page.rb: -------------------------------------------------------------------------------- 1 | class Page < Document 2 | end 3 | -------------------------------------------------------------------------------- /spec/models/folder.rb: -------------------------------------------------------------------------------- 1 | class Folder < Document 2 | end 3 | -------------------------------------------------------------------------------- /spec/models/writer.rb: -------------------------------------------------------------------------------- 1 | class Writer < BaseUser 2 | end 3 | -------------------------------------------------------------------------------- /spec/models/person.rb: -------------------------------------------------------------------------------- 1 | class Person < ActiveRecord::Base 2 | has_many :pets 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/author.rb: -------------------------------------------------------------------------------- 1 | class Author < ActiveRecord::Base 2 | has_many :documents 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/city.rb: -------------------------------------------------------------------------------- 1 | class City < ActiveRecord::Base 2 | belongs_to :country 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/company.rb: -------------------------------------------------------------------------------- 1 | class Company < ActiveRecord::Base 2 | has_one :address 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/country.rb: -------------------------------------------------------------------------------- 1 | class Country < ActiveRecord::Base 2 | has_many :cities 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/entry.rb: -------------------------------------------------------------------------------- 1 | class Entry < ActiveRecord::Base 2 | belongs_to :category 3 | end 4 | -------------------------------------------------------------------------------- /lib/bullet/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Bullet 3 | VERSION = "4.14.8" 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/address.rb: -------------------------------------------------------------------------------- 1 | class Address < ActiveRecord::Base 2 | belongs_to :company 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/reply.rb: -------------------------------------------------------------------------------- 1 | class Reply < ActiveRecord::Base 2 | belongs_to :submission 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/pet.rb: -------------------------------------------------------------------------------- 1 | class Pet < ActiveRecord::Base 2 | belongs_to :person, counter_cache: true 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/student.rb: -------------------------------------------------------------------------------- 1 | class Student < ActiveRecord::Base 2 | has_and_belongs_to_many :teachers 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/teacher.rb: -------------------------------------------------------------------------------- 1 | class Teacher < ActiveRecord::Base 2 | has_and_belongs_to_many :students 3 | end 4 | -------------------------------------------------------------------------------- /lib/bullet/detector/base.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Detector 3 | class Base 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/models/mongoid/user.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::User 2 | include Mongoid::Document 3 | 4 | field :name 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | has_one :submission 3 | belongs_to :category 4 | end 5 | -------------------------------------------------------------------------------- /lib/bullet/ext/string.rb: -------------------------------------------------------------------------------- 1 | class String 2 | def bullet_class_name 3 | self.sub(/:[^:]*?$/, "") 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/newspaper.rb: -------------------------------------------------------------------------------- 1 | class Newspaper < ActiveRecord::Base 2 | has_many :writers, class_name: "BaseUser" 3 | end 4 | -------------------------------------------------------------------------------- /spec/models/submission.rb: -------------------------------------------------------------------------------- 1 | class Submission < ActiveRecord::Base 2 | belongs_to :user 3 | has_many :replies 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/relationship.rb: -------------------------------------------------------------------------------- 1 | class Relationship < ActiveRecord::Base 2 | belongs_to :firm 3 | belongs_to :client 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/firm.rb: -------------------------------------------------------------------------------- 1 | class Firm < ActiveRecord::Base 2 | has_many :relationships 3 | has_many :clients, through: :relationships 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/base_user.rb: -------------------------------------------------------------------------------- 1 | class BaseUser < ActiveRecord::Base 2 | has_many :comments 3 | has_many :posts 4 | belongs_to :newspaper 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/client.rb: -------------------------------------------------------------------------------- 1 | class Client < ActiveRecord::Base 2 | has_many :relationships 3 | has_many :firms, through: :relationships 4 | end 5 | -------------------------------------------------------------------------------- /spec/bullet/detector/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Detector 5 | describe Base do 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/mongoid/comment.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::Comment 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | belongs_to :post, :class_name => "Mongoid::Post" 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/mongoid/company.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::Company 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | has_one :address, :class_name => "Mongoid::Address" 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/mongoid/address.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::Address 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | belongs_to :company, :class_name => "Mongoid::Company" 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/mongoid/entry.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::Entry 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | belongs_to :category, :class_name => "Mongoid::Category" 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment < ActiveRecord::Base 2 | belongs_to :post, inverse_of: :comments 3 | belongs_to :author, class_name: "BaseUser" 4 | 5 | validates :post, presence: true 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | log/** 2 | pkg/** 3 | .DS_Store 4 | lib/.DS_Store 5 | .*.swp 6 | coverage.data 7 | tags 8 | .bundle 9 | *.gem 10 | benchmark_profile* 11 | /nbproject/private/ 12 | coverage/ 13 | .coveralls.yml 14 | Gemfile*.lock 15 | -------------------------------------------------------------------------------- /spec/models/mongoid/category.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::Category 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | has_many :posts, :class_name => "Mongoid::Post" 7 | has_many :entries, :class_name => "Mongoid::Entry" 8 | end 9 | -------------------------------------------------------------------------------- /lib/bullet/registry.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Registry 3 | autoload :Base, 'bullet/registry/base' 4 | autoload :Object, 'bullet/registry/object' 5 | autoload :Association, 'bullet/registry/association' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/document.rb: -------------------------------------------------------------------------------- 1 | class Document < ActiveRecord::Base 2 | has_many :children, class_name: "Document", foreign_key: 'parent_id' 3 | belongs_to :parent, class_name: "Document", foreign_key: 'parent_id' 4 | belongs_to :author 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/category.rb: -------------------------------------------------------------------------------- 1 | class Category < ActiveRecord::Base 2 | has_many :posts, inverse_of: :category 3 | has_many :entries 4 | 5 | has_many :users 6 | 7 | def draft_post 8 | posts.draft.first_or_create 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /tasks/bullet_tasks.rake: -------------------------------------------------------------------------------- 1 | namespace :bullet do 2 | namespace :log do 3 | desc "Truncates the bullet log file to zero bytes" 4 | task :clear do 5 | f = File.open("log/bullet.log", "w") 6 | f.close 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/bullet/notification/counter_cache.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Notification 3 | class CounterCache < Base 4 | def body 5 | klazz_associations_str 6 | end 7 | 8 | def title 9 | "Need Counter Cache" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Gemfile.mongoid: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', github: 'mongoid/mongoid' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | -------------------------------------------------------------------------------- /lib/bullet/registry/object.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Registry 3 | class Object < Base 4 | def add(bullet_key) 5 | super(bullet_key.bullet_class_name, bullet_key) 6 | end 7 | 8 | def include?(bullet_key) 9 | super(bullet_key.bullet_class_name, bullet_key) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard 'rspec', :version => 2, :all_after_pass => false, :all_on_start => false, :cli => "--color --format nested --fail-fast" do 5 | watch(%r{^spec/.+_spec\.rb$}) 6 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 7 | watch('spec/spec_helper.rb') { "spec" } 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/mongoid/post.rb: -------------------------------------------------------------------------------- 1 | class Mongoid::Post 2 | include Mongoid::Document 3 | 4 | field :name 5 | 6 | has_many :comments, :class_name => "Mongoid::Comment" 7 | belongs_to :category, :class_name => "Mongoid::Category" 8 | 9 | embeds_many :users, :class_name => "Mongoid::User" 10 | 11 | scope :preload_comments, lambda { includes(:comments) } 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile.rails-4.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 4.1.0' 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.rails-4.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 4.2.0' 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /lib/bullet/detector.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Detector 3 | autoload :Base, 'bullet/detector/base' 4 | autoload :Association, 'bullet/detector/association' 5 | autoload :NPlusOneQuery, 'bullet/detector/n_plus_one_query' 6 | autoload :UnusedEagerLoading, 'bullet/detector/unused_eager_loading' 7 | autoload :CounterCache, 'bullet/detector/counter_cache' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/bullet/notification/unused_eager_loading.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Notification 3 | class UnusedEagerLoading < Base 4 | def body 5 | "#{klazz_associations_str}\n Remove from your finder: #{associations_str}" 6 | end 7 | 8 | def title 9 | "Unused Eager Loading #{@path ? "in #{@path}" : 'detected'}" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/bullet/notification/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Notification 5 | describe CounterCache do 6 | subject { CounterCache.new(Post, [:comments, :votes]) } 7 | 8 | it { expect(subject.body).to eq(" Post => [:comments, :votes]") } 9 | it { expect(subject.title).to eq("Need Counter Cache") } 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile.mongoid-2.4: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 2.4.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-2.5: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 2.5.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-2.6: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 2.6.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-2.7: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 2.7.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-2.8: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 2.8.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-3.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 3.0.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-3.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 3.1.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.mongoid-4.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 4.0.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 4.0.0' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.rails-3.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.0.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.rails-3.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.1.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.rails-3.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 3.2.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.rails-4.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 4.0.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /lib/bullet/notification.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Notification 3 | autoload :Base, 'bullet/notification/base' 4 | autoload :UnusedEagerLoading, 'bullet/notification/unused_eager_loading' 5 | autoload :NPlusOneQuery, 'bullet/notification/n_plus_one_query' 6 | autoload :CounterCache, 'bullet/notification/counter_cache' 7 | 8 | class UnoptimizedQueryError < StandardError; end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/bullet/ext/string_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe String do 4 | context "bullet_class_name" do 5 | it "should only return class name" do 6 | expect("Post:1".bullet_class_name).to eq("Post") 7 | end 8 | 9 | it "should return class name with namespace" do 10 | expect("Mongoid::Post:1234567890".bullet_class_name).to eq("Mongoid::Post") 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Gemfile.mongoid-5.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 4.0.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 5.0.0', github: 'mongoid' 9 | 10 | gem "rspec" 11 | 12 | gem 'coveralls', require: false 13 | 14 | platforms :rbx do 15 | gem 'rubysl', '~> 2.0' 16 | gem 'rubinius-developer_tools' 17 | end 18 | -------------------------------------------------------------------------------- /lib/bullet/registry/association.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Registry 3 | class Association < Base 4 | def merge(base, associations) 5 | @registry.merge!(base => associations) 6 | end 7 | 8 | def similarly_associated(base, associations) 9 | @registry.select { |key, value| key.include?(base) && value == associations }.collect(&:first).flatten 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'rails', github: 'rails/rails' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem 'rspec' 11 | gem 'guard' 12 | gem 'guard-rspec' 13 | 14 | gem 'coveralls', require: false 15 | 16 | platforms :rbx do 17 | gem 'rubysl', '~> 2.0' 18 | gem 'rubinius-developer_tools' 19 | end 20 | -------------------------------------------------------------------------------- /lib/bullet/notification_collector.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Bullet 4 | class NotificationCollector 5 | attr_reader :collection 6 | 7 | def initialize 8 | reset 9 | end 10 | 11 | def reset 12 | @collection = Set.new 13 | end 14 | 15 | def add(value) 16 | @collection << value 17 | end 18 | 19 | def notifications_present? 20 | !@collection.empty? 21 | end 22 | end 23 | end 24 | 25 | -------------------------------------------------------------------------------- /spec/bullet/notification/unused_eager_loading_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Notification 5 | describe UnusedEagerLoading do 6 | subject { UnusedEagerLoading.new(Post, [:comments, :votes], "path") } 7 | 8 | it { expect(subject.body).to eq(" Post => [:comments, :votes]\n Remove from your finder: :includes => [:comments, :votes]") } 9 | it { expect(subject.title).to eq("Unused Eager Loading in path") } 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ActiveRecord::Base 2 | belongs_to :category, inverse_of: :posts 3 | belongs_to :writer 4 | has_many :comments, inverse_of: :post 5 | 6 | validates :category, presence: true 7 | 8 | scope :preload_comments, -> { includes(:comments) } 9 | scope :in_category_name, ->(name) { where(['categories.name = ?', name]).includes(:category) } 10 | scope :draft, -> { where(active: false) } 11 | 12 | def link=(*) 13 | comments.new 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bullet/ext/object.rb: -------------------------------------------------------------------------------- 1 | class Object 2 | def bullet_key 3 | "#{self.class}:#{self.primary_key_value}" 4 | end 5 | 6 | def primary_key_value 7 | if self.class.respond_to?(:primary_keys) && self.class.primary_keys 8 | self.class.primary_keys.map { |primary_key| self.send primary_key }.join(',') 9 | elsif self.class.respond_to?(:primary_key) && self.class.primary_key 10 | self.send self.class.primary_key 11 | else 12 | self.id 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/bullet/registry/object_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Registry 5 | describe Object do 6 | let(:post) { Post.first } 7 | let(:another_post) { Post.last } 8 | subject { Object.new.tap { |object| object.add(post.bullet_key) } } 9 | 10 | context "#include?" do 11 | it "should include the object" do 12 | expect(subject).to be_include(post.bullet_key) 13 | end 14 | end 15 | 16 | context "#add" do 17 | it "should add an object" do 18 | subject.add(another_post.bullet_key) 19 | expect(subject).to be_include(another_post.bullet_key) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | BUNDLE_GEMFILE=Gemfile.rails-4.2 bundle update 2 | BUNDLE_GEMFILE=Gemfile.rails-4.1 bundle update 3 | BUNDLE_GEMFILE=Gemfile.rails-4.0 bundle update 4 | BUNDLE_GEMFILE=Gemfile.rails-3.2 bundle update 5 | BUNDLE_GEMFILE=Gemfile.rails-3.1 bundle update 6 | BUNDLE_GEMFILE=Gemfile.rails-3.0 bundle update 7 | BUNDLE_GEMFILE=Gemfile.mongoid-5.0 bundle update 8 | BUNDLE_GEMFILE=Gemfile.mongoid-4.0 bundle update 9 | BUNDLE_GEMFILE=Gemfile.mongoid-3.1 bundle update 10 | BUNDLE_GEMFILE=Gemfile.mongoid-3.0 bundle update 11 | BUNDLE_GEMFILE=Gemfile.mongoid-2.8 bundle update 12 | BUNDLE_GEMFILE=Gemfile.mongoid-2.7 bundle update 13 | BUNDLE_GEMFILE=Gemfile.mongoid-2.6 bundle update 14 | BUNDLE_GEMFILE=Gemfile.mongoid-2.5 bundle update 15 | BUNDLE_GEMFILE=Gemfile.mongoid-2.4 bundle update 16 | -------------------------------------------------------------------------------- /lib/bullet/notification/n_plus_one_query.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Notification 3 | class NPlusOneQuery < Base 4 | def initialize(callers, base_class, associations, path = nil) 5 | super(base_class, associations, path) 6 | 7 | @callers = callers 8 | end 9 | 10 | def body 11 | "#{klazz_associations_str}\n Add to your finder: #{associations_str}" 12 | end 13 | 14 | def title 15 | "N+1 Query #{@path ? "in #{@path}" : 'detected'}" 16 | end 17 | 18 | def notification_data 19 | super.merge( 20 | :backtrace => @callers 21 | ) 22 | end 23 | 24 | protected 25 | def call_stack_messages 26 | (['N+1 Query method call stack'] + @callers).join( "\n " ) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/bullet/registry/association_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Registry 5 | describe Association do 6 | subject { Association.new.tap { |association| association.add(["key1", "key2"], "value") } } 7 | 8 | context "#merge" do 9 | it "should merge key/value" do 10 | subject.merge("key0", "value0") 11 | expect(subject["key0"]).to be_include("value0") 12 | end 13 | end 14 | 15 | context "#similarly_associated" do 16 | it "should return similarly associated keys" do 17 | expect(subject.similarly_associated("key1", Set.new(["value"]))).to eq(["key1", "key2"]) 18 | end 19 | 20 | it "should return empty if key does not exist" do 21 | expect(subject.similarly_associated("key3", Set.new(["value"]))).to be_empty 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/bullet/registry/base.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Registry 3 | class Base 4 | attr_reader :registry 5 | 6 | def initialize 7 | @registry = {} 8 | end 9 | 10 | def [](key) 11 | @registry[key] 12 | end 13 | 14 | def each(&block) 15 | @registry.each(&block) 16 | end 17 | 18 | def delete(base) 19 | @registry.delete(base) 20 | end 21 | 22 | def select(*args, &block) 23 | @registry.select(*args, &block) 24 | end 25 | 26 | def add(key, value) 27 | @registry[key] ||= Set.new 28 | if value.is_a? Array 29 | @registry[key] += value 30 | else 31 | @registry[key] << value 32 | end 33 | end 34 | 35 | def include?(key, value) 36 | !@registry[key].nil? && @registry[key].include?(value) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/bullet/detector/association_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Detector 5 | describe Association do 6 | before :all do 7 | @post1 = Post.first 8 | @post2 = Post.last 9 | end 10 | 11 | context ".add_object_association" do 12 | it "should add object, associations pair" do 13 | Association.add_object_associations(@post1, :associations) 14 | expect(Association.send(:object_associations)).to be_include(@post1.bullet_key, :associations) 15 | end 16 | end 17 | 18 | context ".add_call_object_associations" do 19 | it "should add call object, associations pair" do 20 | Association.add_call_object_associations(@post1, :associations) 21 | expect(Association.send(:call_object_associations)).to be_include(@post1.bullet_key, :associations) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/bullet/notification_collector_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | describe NotificationCollector do 5 | subject { NotificationCollector.new.tap { |collector| collector.add("value") } } 6 | 7 | context "#add" do 8 | it "should add a value" do 9 | subject.add("value1") 10 | expect(subject.collection).to be_include("value1") 11 | end 12 | end 13 | 14 | context "#reset" do 15 | it "should reset collector" do 16 | subject.reset 17 | expect(subject.collection).to be_empty 18 | end 19 | end 20 | 21 | context "#notifications_present?" do 22 | it "should be true if collection is not empty" do 23 | expect(subject).to be_notifications_present 24 | end 25 | 26 | it "should be false if collection is empty" do 27 | subject.reset 28 | expect(subject).not_to be_notifications_present 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /bullet.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib/', __FILE__) 2 | $:.unshift lib unless $:.include?(lib) 3 | 4 | require "bullet/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "bullet" 8 | s.version = Bullet::VERSION 9 | s.platform = Gem::Platform::RUBY 10 | s.authors = ["Richard Huang"] 11 | s.email = ["flyerhzm@gmail.com"] 12 | s.homepage = "http://github.com/flyerhzm/bullet" 13 | s.summary = "help to kill N+1 queries and unused eager loading." 14 | s.description = "help to kill N+1 queries and unused eager loading." 15 | 16 | s.license = 'MIT' 17 | 18 | s.required_rubygems_version = ">= 1.3.6" 19 | 20 | s.add_runtime_dependency "activesupport", ">= 3.0.0" 21 | s.add_runtime_dependency "uniform_notifier", "~> 1.9.0" 22 | 23 | s.files = `git ls-files`.split("\n") 24 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 25 | s.require_paths = ["lib"] 26 | end 27 | 28 | -------------------------------------------------------------------------------- /spec/bullet/notification/n_plus_one_query_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Notification 5 | describe NPlusOneQuery do 6 | subject { NPlusOneQuery.new([["caller1", "caller2"]], Post, [:comments, :votes], "path") } 7 | 8 | it { expect(subject.body_with_caller).to eq(" Post => [:comments, :votes]\n Add to your finder: :includes => [:comments, :votes]\nN+1 Query method call stack\n caller1\n caller2\n") } 9 | it { expect([subject.body_with_caller, subject.body_with_caller]).to eq([ " Post => [:comments, :votes]\n Add to your finder: :includes => [:comments, :votes]\nN+1 Query method call stack\n caller1\n caller2\n", " Post => [:comments, :votes]\n Add to your finder: :includes => [:comments, :votes]\nN+1 Query method call stack\n caller1\n caller2\n" ]) } 10 | it { expect(subject.body).to eq(" Post => [:comments, :votes]\n Add to your finder: :includes => [:comments, :votes]") } 11 | it { expect(subject.title).to eq("N+1 Query in path") } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/rack_double.rb: -------------------------------------------------------------------------------- 1 | module Support 2 | class AppDouble 3 | def call env 4 | env = @env 5 | [ status, headers, response ] 6 | end 7 | 8 | def status= status 9 | @status = status 10 | end 11 | 12 | def headers= headers 13 | @headers = headers 14 | end 15 | 16 | def headers 17 | @headers ||= {"Content-Type" => "text/html"} 18 | @headers 19 | end 20 | 21 | def response= response 22 | @response = response 23 | end 24 | 25 | private 26 | def status 27 | @status || 200 28 | end 29 | 30 | def response 31 | @response || ResponseDouble.new 32 | end 33 | end 34 | 35 | class ResponseDouble 36 | def initialize actual_body = nil 37 | @actual_body = actual_body 38 | end 39 | 40 | def body 41 | @body ||= "" 42 | end 43 | 44 | def body= body 45 | @body = body 46 | end 47 | 48 | def each 49 | yield body 50 | end 51 | 52 | def close 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 - 2010 Richard Huang (flyerhzm@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0 4 | - 2.1 5 | - 2.2 6 | gemfile: 7 | - Gemfile.rails-4.2 8 | - Gemfile.rails-4.1 9 | - Gemfile.rails-4.0 10 | - Gemfile.rails-3.2 11 | - Gemfile.rails-3.1 12 | - Gemfile.rails-3.0 13 | - Gemfile.mongoid-5.0 14 | - Gemfile.mongoid-4.0 15 | - Gemfile.mongoid-3.1 16 | - Gemfile.mongoid-3.0 17 | - Gemfile.mongoid-2.8 18 | - Gemfile.mongoid-2.7 19 | - Gemfile.mongoid-2.6 20 | - Gemfile.mongoid-2.5 21 | - Gemfile.mongoid-2.4 22 | env: 23 | - DB=sqlite 24 | services: 25 | - mongodb 26 | matrix: 27 | exclude: 28 | - rvm: 2.2 29 | gemfile: Gemfile.rails-3.0 30 | - rvm: 2.2 31 | gemfile: Gemfile.rails-3.1 32 | - rvm: 2.2 33 | gemfile: Gemfile.rails-3.2 34 | - rvm: 2.2 35 | gemfile: Gemfile.mongoid-3.1 36 | - rvm: 2.2 37 | gemfile: Gemfile.mongoid-3.0 38 | - rvm: 2.2 39 | gemfile: Gemfile.mongoid-2.8 40 | - rvm: 2.2 41 | gemfile: Gemfile.mongoid-2.7 42 | - rvm: 2.2 43 | gemfile: Gemfile.mongoid-2.6 44 | - rvm: 2.2 45 | gemfile: Gemfile.mongoid-2.5 46 | - rvm: 2.2 47 | gemfile: Gemfile.mongoid-2.4 48 | -------------------------------------------------------------------------------- /spec/bullet_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Bullet, focused: true do 4 | subject { Bullet } 5 | 6 | describe '#enable' do 7 | 8 | context 'enable Bullet' do 9 | before do 10 | # Bullet.enable 11 | # Do nothing. Bullet has already been enabled for the whole test suite. 12 | end 13 | 14 | it 'should be enabled' do 15 | expect(subject).to be_enable 16 | end 17 | 18 | context 'disable Bullet' do 19 | before do 20 | Bullet.enable = false 21 | end 22 | 23 | it 'should be disabled' do 24 | expect(subject).to_not be_enable 25 | end 26 | 27 | context 'enable Bullet again without patching again the orms' do 28 | before do 29 | expect(Bullet::Mongoid).not_to receive(:enable) if defined? Bullet::Mongoid 30 | expect(Bullet::ActiveRecord).not_to receive(:enable) if defined? Bullet::ActiveRecord 31 | Bullet.enable = true 32 | end 33 | 34 | it 'should be enabled again' do 35 | expect(subject).to be_enable 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/bullet/ext/object_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Object do 4 | context "bullet_key" do 5 | it "should return class and id composition" do 6 | post = Post.first 7 | expect(post.bullet_key).to eq("Post:#{post.id}") 8 | end 9 | 10 | if mongoid? 11 | it "should return class with namesapce and id composition" do 12 | post = Mongoid::Post.first 13 | expect(post.bullet_key).to eq("Mongoid::Post:#{post.id}") 14 | end 15 | end 16 | end 17 | 18 | context "primary_key_value" do 19 | it "should return id" do 20 | post = Post.first 21 | expect(post.primary_key_value).to eq(post.id) 22 | end 23 | 24 | it "should return primary key value" do 25 | post = Post.first 26 | Post.primary_key = 'name' 27 | expect(post.primary_key_value).to eq(post.name) 28 | Post.primary_key = 'id' 29 | end 30 | 31 | it "should return value for multiple primary keys" do 32 | post = Post.first 33 | allow(Post).to receive(:primary_keys).and_return([:category_id, :writer_id]) 34 | expect(post.primary_key_value).to eq("#{post.category_id},#{post.writer_id}") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/bullet/registry/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Registry 5 | describe Base do 6 | subject { Base.new.tap { |base| base.add("key", "value") } } 7 | 8 | context "#[]" do 9 | it "should get value by key" do 10 | expect(subject["key"]).to eq(Set.new(["value"])) 11 | end 12 | end 13 | 14 | context "#delete" do 15 | it "should delete key" do 16 | subject.delete("key") 17 | expect(subject["key"]).to be_nil 18 | end 19 | end 20 | 21 | context "#add" do 22 | it "should add value with string" do 23 | subject.add("key", "new_value") 24 | expect(subject["key"]).to eq(Set.new(["value", "new_value"])) 25 | end 26 | 27 | it "should add value with array" do 28 | subject.add("key", ["value1", "value2"]) 29 | expect(subject["key"]).to eq(Set.new(["value", "value1", "value2"])) 30 | end 31 | end 32 | 33 | context "#include?" do 34 | it "should include key/value" do 35 | expect(subject.include?("key", "value")).to eq true 36 | end 37 | 38 | it "should not include wrong key/value" do 39 | expect(subject.include?("key", "val")).to eq false 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 2 | require "bundler" 3 | Bundler.setup 4 | 5 | require "rake" 6 | require "rspec" 7 | require "rspec/core/rake_task" 8 | 9 | require "bullet/version" 10 | 11 | task :build do 12 | system "gem build bullet.gemspec" 13 | end 14 | 15 | task :install => :build do 16 | system "sudo gem install bullet-#{Bullet::VERSION}.gem" 17 | end 18 | 19 | task :release => :build do 20 | puts "Tagging #{Bullet::VERSION}..." 21 | system "git tag -a #{Bullet::VERSION} -m 'Tagging #{Bullet::VERSION}'" 22 | puts "Pushing to Github..." 23 | system "git push --tags" 24 | puts "Pushing to rubygems.org..." 25 | system "gem push bullet-#{Bullet::VERSION}.gem" 26 | end 27 | 28 | RSpec::Core::RakeTask.new(:spec) do |spec| 29 | spec.pattern = "spec/**/*_spec.rb" 30 | end 31 | 32 | RSpec::Core::RakeTask.new('spec:progress') do |spec| 33 | spec.rspec_opts = %w(--format progress) 34 | spec.pattern = "spec/**/*_spec.rb" 35 | end 36 | 37 | 38 | begin 39 | require 'rdoc/task' 40 | 41 | desc "Generate documentation for the plugin." 42 | Rake::RDocTask.new do |rdoc| 43 | rdoc.rdoc_dir = "rdoc" 44 | rdoc.title = "bullet #{Bullet::VERSION}" 45 | rdoc.rdoc_files.include("README*") 46 | rdoc.rdoc_files.include("lib/**/*.rb") 47 | end 48 | rescue LoadError 49 | puts 'RDocTask is not supported for this platform' 50 | end 51 | 52 | task :default => :spec 53 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #bundle update rails && bundle exec rspec spec 2 | #BUNDLE_GEMFILE=Gemfile.mongoid bundle update mongoid && BUNDLE_GEMFILE=Gemfile.mongoid bundle exec rspec spec 3 | BUNDLE_GEMFILE=Gemfile.rails-4.2 bundle && BUNDLE_GEMFILE=Gemfile.rails-4.2 bundle exec rspec spec 4 | BUNDLE_GEMFILE=Gemfile.rails-4.1 bundle && BUNDLE_GEMFILE=Gemfile.rails-4.1 bundle exec rspec spec 5 | BUNDLE_GEMFILE=Gemfile.rails-4.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-4.0 bundle exec rspec spec 6 | BUNDLE_GEMFILE=Gemfile.rails-3.2 bundle && BUNDLE_GEMFILE=Gemfile.rails-3.2 bundle exec rspec spec 7 | BUNDLE_GEMFILE=Gemfile.rails-3.1 bundle && BUNDLE_GEMFILE=Gemfile.rails-3.1 bundle exec rspec spec 8 | BUNDLE_GEMFILE=Gemfile.rails-3.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-3.0 bundle exec rspec spec 9 | BUNDLE_GEMFILE=Gemfile.mongoid-5.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-5.0 bundle exec rspec spec 10 | BUNDLE_GEMFILE=Gemfile.mongoid-4.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-4.0 bundle exec rspec spec 11 | BUNDLE_GEMFILE=Gemfile.mongoid-3.1 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-3.1 bundle exec rspec spec 12 | BUNDLE_GEMFILE=Gemfile.mongoid-3.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-3.0 bundle exec rspec spec 13 | BUNDLE_GEMFILE=Gemfile.mongoid-2.8 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-2.8 bundle exec rspec spec 14 | BUNDLE_GEMFILE=Gemfile.mongoid-2.7 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-2.7 bundle exec rspec spec 15 | BUNDLE_GEMFILE=Gemfile.mongoid-2.6 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-2.6 bundle exec rspec spec 16 | BUNDLE_GEMFILE=Gemfile.mongoid-2.5 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-2.5 bundle exec rspec spec 17 | BUNDLE_GEMFILE=Gemfile.mongoid-2.4 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-2.4 bundle exec rspec spec 18 | -------------------------------------------------------------------------------- /lib/bullet/mongoid2x.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Mongoid 3 | def self.enable 4 | require 'mongoid' 5 | 6 | ::Mongoid::Contexts::Mongo.class_eval do 7 | alias_method :origin_first, :first 8 | alias_method :origin_last, :last 9 | alias_method :origin_iterate, :iterate 10 | alias_method :origin_eager_load, :eager_load 11 | 12 | def first 13 | result = origin_first 14 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 15 | result 16 | end 17 | 18 | def last 19 | result = origin_last 20 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 21 | result 22 | end 23 | 24 | def iterate(&block) 25 | records = execute.to_a 26 | if records.size > 1 27 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 28 | elsif records.size == 1 29 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 30 | end 31 | origin_iterate(&block) 32 | end 33 | 34 | def eager_load(docs) 35 | associations = criteria.inclusions.map(&:name) 36 | docs.each do |doc| 37 | Bullet::Detector::Association.add_object_associations(doc, associations) 38 | end 39 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 40 | origin_eager_load(docs) 41 | end 42 | end 43 | 44 | ::Mongoid::Relations::Accessors.class_eval do 45 | alias_method :origin_set_relation, :set_relation 46 | 47 | def set_relation(name, relation) 48 | if relation && relation.metadata.macro !~ /embed/ 49 | Bullet::Detector::NPlusOneQuery.call_association(self, name) 50 | end 51 | origin_set_relation(name, relation) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/bullet/mongoid3x.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Mongoid 3 | def self.enable 4 | require 'mongoid' 5 | ::Mongoid::Contextual::Mongo.class_eval do 6 | alias_method :origin_first, :first 7 | alias_method :origin_last, :last 8 | alias_method :origin_each, :each 9 | alias_method :origin_eager_load, :eager_load 10 | 11 | def first 12 | result = origin_first 13 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 14 | result 15 | end 16 | 17 | def last 18 | result = origin_last 19 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 20 | result 21 | end 22 | 23 | def each(&block) 24 | records = query.map{ |doc| ::Mongoid::Factory.from_db(klass, doc) } 25 | if records.length > 1 26 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 27 | elsif records.size == 1 28 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 29 | end 30 | origin_each(&block) 31 | end 32 | 33 | def eager_load(docs) 34 | associations = criteria.inclusions.map(&:name) 35 | docs.each do |doc| 36 | Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) 37 | end 38 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 39 | origin_eager_load(docs) 40 | end 41 | end 42 | 43 | ::Mongoid::Relations::Accessors.class_eval do 44 | alias_method :origin_get_relation, :get_relation 45 | 46 | def get_relation(name, metadata, reload = false) 47 | result = origin_get_relation(name, metadata, reload) 48 | if metadata.macro !~ /embed/ 49 | Bullet::Detector::NPlusOneQuery.call_association(self, name) 50 | end 51 | result 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/bullet/mongoid4x.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Mongoid 3 | def self.enable 4 | require 'mongoid' 5 | ::Mongoid::Contextual::Mongo.class_eval do 6 | alias_method :origin_first, :first 7 | alias_method :origin_last, :last 8 | alias_method :origin_each, :each 9 | alias_method :origin_eager_load, :eager_load 10 | 11 | def first 12 | result = origin_first 13 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 14 | result 15 | end 16 | 17 | def last 18 | result = origin_last 19 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 20 | result 21 | end 22 | 23 | def each(&block) 24 | records = query.map{ |doc| ::Mongoid::Factory.from_db(klass, doc) } 25 | if records.length > 1 26 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 27 | elsif records.size == 1 28 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 29 | end 30 | origin_each(&block) 31 | end 32 | 33 | def eager_load(docs) 34 | associations = criteria.inclusions.map(&:name) 35 | docs.each do |doc| 36 | Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) 37 | end 38 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 39 | origin_eager_load(docs) 40 | end 41 | end 42 | 43 | ::Mongoid::Relations::Accessors.class_eval do 44 | alias_method :origin_get_relation, :get_relation 45 | 46 | def get_relation(name, metadata, object, reload = false) 47 | result = origin_get_relation(name, metadata, object, reload) 48 | if metadata.macro !~ /embed/ 49 | Bullet::Detector::NPlusOneQuery.call_association(self, name) 50 | end 51 | result 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/bullet/mongoid5x.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Mongoid 3 | def self.enable 4 | require 'mongoid' 5 | ::Mongoid::Contextual::Mongo.class_eval do 6 | alias_method :origin_first, :first 7 | alias_method :origin_last, :last 8 | alias_method :origin_each, :each 9 | alias_method :origin_eager_load, :eager_load 10 | 11 | def first 12 | result = origin_first 13 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 14 | result 15 | end 16 | 17 | def last 18 | result = origin_last 19 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 20 | result 21 | end 22 | 23 | def each(&block) 24 | records = view.map{ |doc| ::Mongoid::Factory.from_db(klass, doc) } 25 | if records.length > 1 26 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 27 | elsif records.size == 1 28 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 29 | end 30 | origin_each(&block) 31 | end 32 | 33 | def eager_load(docs) 34 | associations = criteria.inclusions.map(&:name) 35 | docs.each do |doc| 36 | Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) 37 | end 38 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 39 | origin_eager_load(docs) 40 | end 41 | end 42 | 43 | ::Mongoid::Relations::Accessors.class_eval do 44 | alias_method :origin_get_relation, :get_relation 45 | 46 | def get_relation(name, metadata, object, reload = false) 47 | result = origin_get_relation(name, metadata, object, reload) 48 | if metadata.macro !~ /embed/ 49 | Bullet::Detector::NPlusOneQuery.call_association(self, name) 50 | end 51 | result 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/integration/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | if !mongoid? && active_record? 4 | describe Bullet::Detector::CounterCache do 5 | before(:each) do 6 | Bullet.start_request 7 | end 8 | 9 | after(:each) do 10 | Bullet.end_request 11 | end 12 | 13 | it "should need counter cache with all cities" do 14 | Country.all.each do |country| 15 | country.cities.size 16 | end 17 | expect(Bullet.collected_counter_cache_notifications).not_to be_empty 18 | end 19 | 20 | it "should not need counter cache if already define counter_cache" do 21 | Person.all.each do |person| 22 | person.pets.size 23 | end 24 | expect(Bullet.collected_counter_cache_notifications).to be_empty 25 | end 26 | 27 | it "should not need counter cache with only one object" do 28 | Country.first.cities.size 29 | expect(Bullet.collected_counter_cache_notifications).to be_empty 30 | end 31 | 32 | it "should not need counter cache with part of cities" do 33 | Country.all.each do |country| 34 | country.cities.where(:name => 'first').size 35 | end 36 | expect(Bullet.collected_counter_cache_notifications).to be_empty 37 | end 38 | 39 | context "disable" do 40 | before { Bullet.counter_cache_enable = false } 41 | after { Bullet.counter_cache_enable = true } 42 | 43 | it "should not detect counter cache" do 44 | Country.all.each do |country| 45 | country.cities.size 46 | end 47 | expect(Bullet.collected_counter_cache_notifications).to be_empty 48 | end 49 | end 50 | 51 | context "whitelist" do 52 | before { Bullet.add_whitelist :type => :counter_cache, :class_name => "Country", :association => :cities } 53 | after { Bullet.reset_whitelist } 54 | 55 | it "should not detect counter cache" do 56 | Country.all.each do |country| 57 | country.cities.size 58 | end 59 | expect(Bullet.collected_counter_cache_notifications).to be_empty 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/bullet/notification/base.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Notification 3 | class Base 4 | attr_accessor :notifier, :url 5 | attr_reader :base_class, :associations, :path 6 | 7 | def initialize(base_class, association_or_associations, path = nil) 8 | @base_class = base_class 9 | @associations = association_or_associations.is_a?(Array) ? association_or_associations : [association_or_associations] 10 | @path = path 11 | end 12 | 13 | def title 14 | raise NoMethodError.new("no method title defined") 15 | end 16 | 17 | def body 18 | raise NoMethodError.new("no method body defined") 19 | end 20 | 21 | def call_stack_messages 22 | "" 23 | end 24 | 25 | def whoami 26 | @user ||= ENV['USER'].presence || (`whoami`.chomp rescue "") 27 | if @user.present? 28 | "user: #{@user}" 29 | else 30 | "" 31 | end 32 | end 33 | 34 | def body_with_caller 35 | "#{body}\n#{call_stack_messages}\n" 36 | end 37 | 38 | def notify_inline 39 | self.notifier.inline_notify(notification_data) 40 | end 41 | 42 | def notify_out_of_channel 43 | self.notifier.out_of_channel_notify(notification_data) 44 | end 45 | 46 | def short_notice 47 | [whoami.presence, url, title, body].compact.join(" ") 48 | end 49 | 50 | def notification_data 51 | { 52 | :user => whoami, 53 | :url => url, 54 | :title => title, 55 | :body => body_with_caller, 56 | } 57 | end 58 | 59 | def eql?(other) 60 | klazz_associations_str == other.klazz_associations_str 61 | end 62 | 63 | def hash 64 | klazz_associations_str.hash 65 | end 66 | 67 | protected 68 | def klazz_associations_str 69 | " #{@base_class} => [#{@associations.map(&:inspect).join(', ')}]" 70 | end 71 | 72 | def associations_str 73 | ":includes => #{@associations.map{ |a| a.to_s.to_sym unless a.is_a? Hash }.inspect}" 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/support/bullet_ext.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | def self.collected_notifications_of_class(notification_class) 3 | Bullet.notification_collector.collection.select do |notification| 4 | notification.is_a? notification_class 5 | end 6 | end 7 | 8 | def self.collected_counter_cache_notifications 9 | collected_notifications_of_class Bullet::Notification::CounterCache 10 | end 11 | 12 | def self.collected_n_plus_one_query_notifications 13 | collected_notifications_of_class Bullet::Notification::NPlusOneQuery 14 | end 15 | 16 | def self.collected_unused_eager_association_notifications 17 | collected_notifications_of_class Bullet::Notification::UnusedEagerLoading 18 | end 19 | end 20 | 21 | module Bullet 22 | module Detector 23 | class Association 24 | class < true 40 | config.run_all_when_everything_filtered = true 41 | end 42 | 43 | if active_record? 44 | ActiveRecord::Migration.verbose = false 45 | 46 | # Autoload every active_record model for the test suite that sits in spec/models. 47 | Dir[ File.join(MODELS, "*.rb") ].sort.each do |filename| 48 | name = File.basename(filename, ".rb") 49 | autoload name.camelize.to_sym, name 50 | end 51 | require File.join(SUPPORT, "sqlite_seed.rb") 52 | 53 | RSpec.configure do |config| 54 | config.before(:suite) do 55 | Support::SqliteSeed.setup_db 56 | Support::SqliteSeed.seed_db 57 | end 58 | 59 | config.before(:example) do 60 | Bullet.start_request 61 | end 62 | 63 | config.after(:example) do 64 | Bullet.end_request 65 | end 66 | end 67 | 68 | if ENV["BULLET_LOG"] 69 | require 'logger' 70 | ActiveRecord::Base.logger = Logger.new(STDOUT) 71 | end 72 | end 73 | 74 | if mongoid? 75 | # Autoload every mongoid model for the test suite that sits in spec/models. 76 | Dir[ File.join(MODELS, "mongoid", "*.rb") ].sort.each { |file| require file } 77 | require File.join(SUPPORT, "mongo_seed.rb") 78 | 79 | RSpec.configure do |config| 80 | config.before(:suite) do 81 | Support::MongoSeed.setup_db 82 | Support::MongoSeed.seed_db 83 | end 84 | 85 | config.after(:suite) do 86 | Support::MongoSeed.setup_db 87 | Support::MongoSeed.teardown_db 88 | end 89 | 90 | config.before(:each) do 91 | Bullet.start_request 92 | end 93 | 94 | config.after(:each) do 95 | Bullet.end_request 96 | end 97 | end 98 | 99 | if ENV["BULLET_LOG"] 100 | Mongoid.logger = Logger.new(STDOUT) 101 | Moped.logger = Logger.new(STDOUT) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/bullet/dependency.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Dependency 3 | def mongoid? 4 | @mongoid ||= defined? ::Mongoid 5 | end 6 | 7 | def active_record? 8 | @active_record ||= defined? ::ActiveRecord 9 | end 10 | 11 | def rails? 12 | @rails ||= defined? ::Rails 13 | end 14 | 15 | def active_record_version 16 | @active_record_version ||= begin 17 | if active_record30? 18 | 'active_record3' 19 | elsif active_record31? || active_record32? 20 | 'active_record3x' 21 | elsif active_record40? 22 | 'active_record4' 23 | elsif active_record41? 24 | 'active_record41' 25 | elsif active_record42? 26 | 'active_record42' 27 | end 28 | end 29 | end 30 | 31 | def mongoid_version 32 | @mongoid_version ||= begin 33 | if mongoid2x? 34 | 'mongoid2x' 35 | elsif mongoid3x? 36 | 'mongoid3x' 37 | elsif mongoid4x? 38 | 'mongoid4x' 39 | elsif mongoid5x? 40 | 'mongoid5x' 41 | end 42 | end 43 | end 44 | 45 | def active_record3? 46 | active_record? && ::ActiveRecord::VERSION::MAJOR == 3 47 | end 48 | 49 | def active_record4? 50 | active_record? && ::ActiveRecord::VERSION::MAJOR == 4 51 | end 52 | 53 | def active_record30? 54 | active_record3? && ::ActiveRecord::VERSION::MINOR == 0 55 | end 56 | 57 | def active_record31? 58 | active_record3? && ::ActiveRecord::VERSION::MINOR == 1 59 | end 60 | 61 | def active_record32? 62 | active_record3? && ::ActiveRecord::VERSION::MINOR == 2 63 | end 64 | 65 | def active_record40? 66 | active_record4? && ::ActiveRecord::VERSION::MINOR == 0 67 | end 68 | 69 | def active_record41? 70 | active_record4? && ::ActiveRecord::VERSION::MINOR == 1 71 | end 72 | 73 | def active_record42? 74 | active_record4? && ::ActiveRecord::VERSION::MINOR == 2 75 | end 76 | 77 | def mongoid2x? 78 | mongoid? && ::Mongoid::VERSION =~ /\A2\.[4-8]/ 79 | end 80 | 81 | def mongoid3x? 82 | mongoid? && ::Mongoid::VERSION =~ /\A3/ 83 | end 84 | 85 | def mongoid4x? 86 | mongoid? && ::Mongoid::VERSION =~ /\A4/ 87 | end 88 | 89 | def mongoid5x? 90 | mongoid? && ::Mongoid::VERSION =~ /\A5/ 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/support/mongo_seed.rb: -------------------------------------------------------------------------------- 1 | module Support 2 | module MongoSeed 3 | def seed_db 4 | category1 = Mongoid::Category.create(:name => 'first') 5 | category2 = Mongoid::Category.create(:name => 'second') 6 | 7 | post1 = category1.posts.create(:name => 'first') 8 | post1a = category1.posts.create(:name => 'like first') 9 | post2 = category2.posts.create(:name => 'second') 10 | 11 | post1.users << Mongoid::User.create(:name => 'first') 12 | post1.users << Mongoid::User.create(:name => 'another') 13 | post2.users << Mongoid::User.create(:name => 'second') 14 | 15 | comment1 = post1.comments.create(:name => 'first') 16 | comment2 = post1.comments.create(:name => 'first2') 17 | comment3 = post1.comments.create(:name => 'first3') 18 | comment4 = post1.comments.create(:name => 'second') 19 | comment8 = post1a.comments.create(:name => "like first 1") 20 | comment9 = post1a.comments.create(:name => "like first 2") 21 | comment5 = post2.comments.create(:name => 'third') 22 | comment6 = post2.comments.create(:name => 'fourth') 23 | comment7 = post2.comments.create(:name => 'fourth') 24 | 25 | entry1 = category1.entries.create(:name => 'first') 26 | entry2 = category1.entries.create(:name => 'second') 27 | 28 | company1 = Mongoid::Company.create(:name => 'first') 29 | company2 = Mongoid::Company.create(:name => 'second') 30 | 31 | Mongoid::Address.create(:name => 'first', :company => company1) 32 | Mongoid::Address.create(:name => 'second', :company => company2) 33 | end 34 | 35 | def setup_db 36 | if Mongoid::VERSION =~ /\A2\.[4-8]/ 37 | Mongoid.configure do |config| 38 | config.master = Mongo::Connection.new.db("bullet") 39 | end 40 | elsif Mongoid::VERSION =~ /\A3/ 41 | Mongoid.configure do |config| 42 | config.connect_to("bullet") 43 | end 44 | elsif Mongoid::VERSION =~ /\A4/ 45 | Mongoid.configure do |config| 46 | config.load_configuration( 47 | sessions: { 48 | default: { 49 | database: "bullet", 50 | hosts: [ "localhost:27017" ] 51 | } 52 | } 53 | ) 54 | end 55 | elsif Mongoid::VERSION =~ /\A5/ 56 | Mongoid.configure do |config| 57 | config.load_configuration( 58 | clients: { 59 | default: { 60 | database: "bullet", 61 | hosts: [ "localhost:27017" ] 62 | } 63 | } 64 | ) 65 | end 66 | # Increase the level from DEBUG in order to avoid excessive logging to the screen 67 | Mongo::Logger.logger.level = Logger::WARN 68 | end 69 | end 70 | 71 | def teardown_db 72 | Mongoid.purge! 73 | Mongoid::IdentityMap.clear if Mongoid.const_defined?(:IdentityMap) 74 | end 75 | 76 | extend self 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/bullet/rack.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | class Rack 3 | include Dependency 4 | 5 | def initialize(app) 6 | @app = app 7 | end 8 | 9 | def call(env) 10 | return @app.call(env) unless Bullet.enable? 11 | Bullet.start_request 12 | status, headers, response = @app.call(env) 13 | 14 | response_body = nil 15 | if Bullet.notification? 16 | if !file?(headers) && !sse?(headers) && !empty?(response) && 17 | status == 200 && !response_body(response).frozen? && html_request?(headers, response) 18 | response_body = response_body(response) 19 | append_to_html_body(response_body, footer_note) if Bullet.add_footer 20 | append_to_html_body(response_body, Bullet.gather_inline_notifications) 21 | headers['Content-Length'] = response_body.bytesize.to_s 22 | end 23 | Bullet.perform_out_of_channel_notifications(env) 24 | end 25 | [status, headers, response_body ? [response_body] : response] 26 | ensure 27 | Bullet.end_request 28 | end 29 | 30 | # fix issue if response's body is a Proc 31 | def empty?(response) 32 | # response may be ["Not Found"], ["Move Permanently"], etc. 33 | if rails? 34 | (response.is_a?(Array) && response.size <= 1) || 35 | !response.respond_to?(:body) || 36 | !response_body(response).respond_to?(:empty?) || 37 | response_body(response).empty? 38 | else 39 | body = response_body(response) 40 | body.nil? || body.empty? 41 | end 42 | end 43 | 44 | def append_to_html_body(response_body, content) 45 | if response_body.include?('') 46 | position = response_body.rindex('') 47 | response_body.insert(position, content) 48 | else 49 | response_body << content 50 | end 51 | end 52 | 53 | def footer_note 54 | "
" + Bullet.footer_info.uniq.join("
") + "
" 55 | end 56 | 57 | def file?(headers) 58 | headers["Content-Transfer-Encoding"] == "binary" 59 | end 60 | 61 | def sse?(headers) 62 | headers["Content-Type"] == "text/event-stream" 63 | end 64 | 65 | def html_request?(headers, response) 66 | headers['Content-Type'] && headers['Content-Type'].include?('text/html') && response_body(response).include?(" ["Post:1", "Post:2"] } 26 | def possible_objects 27 | Thread.current[:bullet_possible_objects] 28 | end 29 | 30 | # impossible_objects keep the class to objects relationships 31 | # that the objects may not cause N+1 query. 32 | # e.g. { Post => ["Post:1", "Post:2"] } 33 | # if find collection returns only one object, then the object is impossible object, 34 | # impossible_objects are used to avoid treating 1+1 query to N+1 query. 35 | def impossible_objects 36 | Thread.current[:bullet_impossible_objects] 37 | end 38 | 39 | private 40 | # object_associations keep the object relationships 41 | # that the object has many associations. 42 | # e.g. { "Post:1" => [:comments] } 43 | # the object_associations keep all associations that may be or may no be 44 | # unpreload associations or unused preload associations. 45 | def object_associations 46 | Thread.current[:bullet_object_associations] 47 | end 48 | 49 | # call_object_associations keep the object relationships 50 | # that object.associations is called. 51 | # e.g. { "Post:1" => [:comments] } 52 | # they are used to detect unused preload associations. 53 | def call_object_associations 54 | Thread.current[:bullet_call_object_associations] 55 | end 56 | 57 | # inversed_objects keeps object relationships 58 | # that association is inversed. 59 | # e.g. { "Comment:1" => ["post"] } 60 | def inversed_objects 61 | Thread.current[:bullet_inversed_objects] 62 | end 63 | 64 | # eager_loadings keep the object relationships 65 | # that the associations are preloaded by find :include. 66 | # e.g. { ["Post:1", "Post:2"] => [:comments, :user] } 67 | def eager_loadings 68 | Thread.current[:bullet_eager_loadings] 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Next Release 2 | 3 | ## 4.14.8 4 | 5 | * compatible with `composite_primary_keys` gem 6 | 7 | ## 4.14.7 8 | 9 | * Fix AR 4.2 SingularAssociation#reader result can be nil 10 | * `perform_out_of_channel_notifications` should always be triggered 11 | 12 | ## 4.14.6 13 | 14 | * Fix false positive with `belongs_to` -> `belongs_to` for active\_record 4.2 15 | * Activate active\_record hacks only when Bullet already start 16 | 17 | ## 4.14.5 18 | 19 | * Don't execute query when running `to_sql` 20 | * Send backtrace to `uniform_notifier` 21 | * Fix sse response check 22 | * Dynamically delegate available notifiers to UniformNotifier 23 | 24 | ## 4.14.4 25 | 26 | * Fix false N + 1 warnings on Rails 4.2 27 | 28 | ## 4.14.3 29 | 30 | * Fix false positive on create 31 | 32 | ## 4.14.2 33 | 34 | * Hotfix nil object when `add_impossible_object` 35 | 36 | ## 4.14.1 37 | 38 | * Fix `has_one` then `has_many` associations in rails 4.2 39 | * Append js and dom to html body in proper position 40 | 41 | ## 4.14.0 (10/03/2014) 42 | 43 | * Support rails 4.2 44 | * Polish notification output 45 | * Fix warning: `*' interpreted as argument prefix 46 | 47 | ## 4.13.0 (07/19/2014) 48 | 49 | * Support include? call on ar associations 50 | 51 | ## 4.12.0 (07/13/2014) 52 | 53 | * Fix false n+1 queries caused by inversed objects. 54 | * Replace .id with .primary_key_value 55 | * Rename bullet_ar_key to bullet_key 56 | * Fix rails sse detect 57 | * Fix bullet using in test environment 58 | * Memoize whoami 59 | 60 | ## 4.11.0 (06/24/2014) 61 | 62 | * Support empty? call on ar associations 63 | * Skip detecting if object is a new record 64 | 65 | ## 4.10.0 (06/06/2014) 66 | 67 | * Handle join query smarter 68 | * Support mongoid 4.0 69 | * Thread safe 70 | * Add debug mode 71 | 72 | ## 4.9.0 (04/30/2014) 73 | 74 | * Add Bullet.stacktrace_includes option 75 | * Applied keyword argument fixes on Ruby 2.2.0 76 | * Add bugsnag notifier 77 | * Support rails 4.1.0 78 | 79 | ## 4.8.0 (02/16/2014) 80 | 81 | * Support rails 4.1.0.beta1 82 | * Update specs to be RSpec 3.0 compatible 83 | * Update latest minor version activerecord and mongoid on travis 84 | 85 | ## 4.7.0 (11/03/2013) 86 | 87 | * Add coverall support 88 | * Add helper to profile code outside a request 89 | * Add activesupport dependency 90 | * Add Bullet.raise notification 91 | * Add Bullet.add_footer notification 92 | * Fix activerecord4 warnings in test code 93 | 94 | ## 4.6.0 (04/18/2013) 95 | 96 | * Fix Bullet::Rack to support sinatra 97 | 98 | ## 4.5.0 (03/24/2013) 99 | 100 | * Add api way to access captured associatioin 101 | * Allow disable n_plus_one_query, unused_eager_loading and counter_cache respectively 102 | * Add whitelist 103 | 104 | ## 4.4.0 (03/15/2013) 105 | 106 | * Remove disable_browser_cache option 107 | * Compatible with Rails 4.0.0.beta1 108 | 109 | ## 4.3.0 (12/28/2012) 110 | 111 | * Fix content-length for non ascii html 112 | * Add mongoid 2.5.x support 113 | 114 | ## 4.2.0 (09/29/2012) 115 | 116 | * Add Bullet::Dependency to check AR and mongoid version 117 | * Add Rails 4 support 118 | * Add airbrake notifier support 119 | 120 | ## 4.1.0 (05/30/2012) 121 | 122 | * Add mongoid 3 support 123 | 124 | ## 4.0.0 (05/09/2012) 125 | 126 | * Add mongoid support 127 | -------------------------------------------------------------------------------- /spec/bullet/notification/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Notification 5 | describe Base do 6 | subject { Base.new(Post, [:comments, :votes]) } 7 | 8 | context "#title" do 9 | it "should raise NoMethodError" do 10 | expect { subject.title }.to raise_error(NoMethodError) 11 | end 12 | end 13 | 14 | context "#body" do 15 | it "should raise NoMethodError" do 16 | expect { subject.body }.to raise_error(NoMethodError) 17 | end 18 | end 19 | 20 | context "#whoami" do 21 | it "should display user name" do 22 | user = `whoami`.chomp 23 | expect(subject.whoami).to eq("user: #{user}") 24 | end 25 | 26 | it "should leverage ENV parameter" do 27 | temp_env_variable("USER", "bogus") do 28 | expect(subject.whoami).to eq("user: bogus") 29 | end 30 | end 31 | 32 | it "should return blank if no user available" do 33 | temp_env_variable("USER","") do 34 | expect(subject).to receive(:`).with("whoami").and_return("") 35 | expect(subject.whoami).to eq("") 36 | end 37 | end 38 | 39 | it "should return blank if whoami is not available" do 40 | temp_env_variable("USER","") do 41 | expect(subject).to receive(:`).with("whoami").and_raise(Errno::ENOENT) 42 | expect(subject.whoami).to eq("") 43 | end 44 | end 45 | 46 | def temp_env_variable(name, value) 47 | old_value = ENV[name] 48 | ENV[name] = value 49 | yield 50 | ensure 51 | ENV[name] = old_value 52 | end 53 | 54 | end 55 | 56 | context "#body_with_caller" do 57 | it "should return body" do 58 | allow(subject).to receive(:body).and_return("body") 59 | allow(subject).to receive(:call_stack_messages).and_return("call_stack_messages") 60 | expect(subject.body_with_caller).to eq("body\ncall_stack_messages\n") 61 | end 62 | end 63 | 64 | context "#notification_data" do 65 | it "should return notification data" do 66 | allow(subject).to receive(:whoami).and_return("whoami") 67 | allow(subject).to receive(:url).and_return("url") 68 | allow(subject).to receive(:title).and_return("title") 69 | allow(subject).to receive(:body_with_caller).and_return("body_with_caller") 70 | expect(subject.notification_data).to eq(:user => "whoami", :url => "url", :title => "title", :body => "body_with_caller") 71 | end 72 | end 73 | 74 | context "#notify_inline" do 75 | it "should send full_notice to notifier" do 76 | notifier = double 77 | allow(subject).to receive(:notifier).and_return(notifier) 78 | allow(subject).to receive(:notification_data).and_return(:foo => :bar) 79 | expect(notifier).to receive(:inline_notify).with(:foo => :bar) 80 | subject.notify_inline 81 | end 82 | end 83 | 84 | context "#notify_out_of_channel" do 85 | it "should send full_out_of_channel to notifier" do 86 | notifier = double 87 | allow(subject).to receive(:notifier).and_return(notifier) 88 | allow(subject).to receive(:notification_data).and_return(:foo => :bar) 89 | expect(notifier).to receive(:out_of_channel_notify).with(:foo => :bar) 90 | subject.notify_out_of_channel 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /Hacking.md: -------------------------------------------------------------------------------- 1 | # Bullet Overview for Developers 2 | 3 | This file aims to give developers a quick tour of the bullet internals, making 4 | it (hopefully) easier to extend or enhance the Bullet gem. 5 | 6 | ## General Control Flow aka. 10000 Meter View 7 | 8 | When Rails is initialized, Bullet will extend ActiveRecord (and if you're using 9 | Rails 2.x ActiveController too) with the relevant modules and methods found 10 | in lib/bullet/active_recordX.rb and lib/bullet/action_controller2.rb. If you're 11 | running Rails 3, Bullet will integrate itself as a middleware into the Rack 12 | stack, so ActionController does not need to be extended. 13 | 14 | The ActiveRecord extensions will call methods in a given detector class, when 15 | certain methods are called. 16 | 17 | Detector classes contain all the logic to recognize 18 | a noteworthy event. If such an event is detected, an instance of the 19 | corresponding Notification class is created and stored in a Set instance in the 20 | main Bullet module (the 'notification collector'). 21 | 22 | Notification instances contain the message that will be displayed, and will 23 | use a Presenter class to display their message to the user. 24 | 25 | So the flow of a request goes like this: 26 | 1. Bullet.start_request is called, which resets all the detectors and empties 27 | the notification collector 28 | 2. The request is handled by Rails, and the installed ActiveRecord extensions 29 | trigger Detector callbacks 30 | 3. Detectors once called, will determine whether something noteworthy happend. 31 | If yes, then a Notification is created and stored in the notification collector. 32 | 4. Rails finishes handling the request 33 | 5. For each notification in the collector, Bullet will iterate over each 34 | Presenter and will try to generate an inline message that will be appended to 35 | the generated response body. 36 | 6. The response is returned to the client. 37 | 7. Bullet will try to generate an out-of-channel message for each notification. 38 | 8. Bullet calls end_request for each detector. 39 | 9. Goto 1. 40 | 41 | ## Adding Notification Types 42 | 43 | If you want to add more kinds of things that Bullet can detect, a little more 44 | work is needed than if you were just adding a Presenter, but the concepts are 45 | similar. 46 | 47 | * Add the class to the DETECTORS constant in the main Bullet module 48 | * Add (if needed) Rails monkey patches to Bullet.enable 49 | * Add an autoload directive to lib/bullet/detector.rb 50 | * Create a corresponding notification class in the Bullet::Notification namespace 51 | * Add an autoload directive to lib/bullet/notification.rb 52 | 53 | As a rule of thumb, you can assume that each Detector will have its own 54 | Notification class. If you follow the principle of Separation of Concerns I 55 | can't really think of an example where one would deviate from this rule. 56 | 57 | Since the detection of pathological associations is a bit hairy, I'd recommend 58 | having a look at the counter cache detector and associated notification to get 59 | a feel for what is needed to get off the ground. 60 | 61 | ### Detectors 62 | 63 | The only things you'll need to consider when building your Detector class is 64 | that it will need to supply the .start_request, .end_request and .clear class 65 | methods. 66 | 67 | Simple implementations are provided by Bullet::Detector::Base for start_request 68 | and end_request, you will have to supply your own clear method. 69 | 70 | ### Notifications 71 | 72 | For notifications you will want to supply a #title and #body instance method, 73 | and check to see if the #initialize and #full_notice methods in the 74 | Bullet::Notification::Base class fit your needs. 75 | -------------------------------------------------------------------------------- /lib/bullet/detector/unused_eager_loading.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Detector 3 | class UnusedEagerLoading < Association 4 | class < association - call_object_association is ununsed preload assocations 9 | def check_unused_preload_associations 10 | return unless Bullet.start? 11 | return unless Bullet.unused_eager_loading_enable? 12 | 13 | object_associations.each do |bullet_key, associations| 14 | object_association_diff = diff_object_associations bullet_key, associations 15 | next if object_association_diff.empty? 16 | 17 | Bullet.debug("detect unused preload", "object: #{bullet_key}, associations: #{object_association_diff}") 18 | create_notification bullet_key.bullet_class_name, object_association_diff 19 | end 20 | end 21 | 22 | def add_eager_loadings(objects, associations) 23 | return unless Bullet.start? 24 | return unless Bullet.unused_eager_loading_enable? 25 | return if objects.map(&:primary_key_value).compact.empty? 26 | 27 | Bullet.debug("Detector::UnusedEagerLoading#add_eager_loadings", "objects: #{objects.map(&:bullet_key).join(', ')}, associations: #{associations}") 28 | bullet_keys = objects.map(&:bullet_key) 29 | 30 | to_add = nil 31 | to_merge, to_delete = [], [] 32 | eager_loadings.each do |k, v| 33 | key_objects_overlap = k & bullet_keys 34 | 35 | next if key_objects_overlap.empty? 36 | 37 | if key_objects_overlap == k 38 | to_add = [k, associations] 39 | break 40 | else 41 | to_merge << [key_objects_overlap, ( eager_loadings[k].dup << associations )] 42 | 43 | keys_without_objects = k - bullet_keys 44 | to_merge << [keys_without_objects, eager_loadings[k]] 45 | to_delete << k 46 | bullet_keys = bullet_keys - k 47 | end 48 | end 49 | 50 | eager_loadings.add(*to_add) if to_add 51 | to_merge.each { |k,val| eager_loadings.merge k, val } 52 | to_delete.each { |k| eager_loadings.delete k } 53 | 54 | eager_loadings.add bullet_keys, associations unless bullet_keys.empty? 55 | end 56 | 57 | private 58 | def create_notification(klazz, associations) 59 | notify_associations = Array(associations) - Bullet.get_whitelist_associations(:unused_eager_loading, klazz) 60 | 61 | if notify_associations.present? 62 | notice = Bullet::Notification::UnusedEagerLoading.new(klazz, notify_associations) 63 | Bullet.notification_collector.add(notice) 64 | end 65 | end 66 | 67 | def call_associations(bullet_key, associations) 68 | all = Set.new 69 | eager_loadings.similarly_associated(bullet_key, associations).each do |related_bullet_key| 70 | coa = call_object_associations[related_bullet_key] 71 | next if coa.nil? 72 | all.merge coa 73 | end 74 | all.to_a 75 | end 76 | 77 | def diff_object_associations(bullet_key, associations) 78 | potential_associations = associations - call_associations(bullet_key, associations) 79 | potential_associations.reject { |a| a.is_a?(Hash) } 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /perf/benchmark.rb: -------------------------------------------------------------------------------- 1 | $: << 'lib' 2 | require 'benchmark' 3 | require 'rails' 4 | require 'active_record' 5 | require 'activerecord-import' 6 | require 'bullet' 7 | 8 | begin 9 | require 'perftools' 10 | rescue LoadError 11 | puts "Could not load perftools.rb, profiling won't be possible" 12 | end 13 | 14 | class Post < ActiveRecord::Base 15 | belongs_to :user 16 | has_many :comments 17 | end 18 | 19 | class Comment < ActiveRecord::Base 20 | belongs_to :user 21 | belongs_to :post 22 | end 23 | 24 | class User < ActiveRecord::Base 25 | has_many :posts 26 | has_many :comments 27 | end 28 | 29 | # create database bullet_benchmark; 30 | ActiveRecord::Base.establish_connection(:adapter => 'mysql2', :database => 'bullet_benchmark', :server => '/tmp/mysql.socket', :username => 'root') 31 | 32 | ActiveRecord::Base.connection.tables.each do |table| 33 | ActiveRecord::Base.connection.drop_table(table) 34 | end 35 | 36 | ActiveRecord::Schema.define(:version => 1) do 37 | create_table :posts do |t| 38 | t.column :title, :string 39 | t.column :body, :string 40 | t.column :user_id, :integer 41 | end 42 | 43 | create_table :comments do |t| 44 | t.column :body, :string 45 | t.column :post_id, :integer 46 | t.column :user_id, :integer 47 | end 48 | 49 | create_table :users do |t| 50 | t.column :name, :string 51 | end 52 | end 53 | 54 | users_size = 100 55 | posts_size = 1000 56 | comments_size = 10000 57 | users = [] 58 | users_size.times do |i| 59 | users << User.new(:name => "user#{i}") 60 | end 61 | User.import users 62 | users = User.all 63 | 64 | posts = [] 65 | posts_size.times do |i| 66 | posts << Post.new(:title => "Title #{i}", :body => "Body #{i}", :user => users[i%100]) 67 | end 68 | Post.import posts 69 | posts = Post.all 70 | 71 | comments = [] 72 | comments_size.times do |i| 73 | comments << Comment.new(:body => "Comment #{i}", :post => posts[i%1000], :user => users[i%100]) 74 | end 75 | Comment.import comments 76 | 77 | puts "Start benchmarking..." 78 | 79 | 80 | Bullet.enable = true 81 | 82 | Benchmark.bm(70) do |bm| 83 | bm.report("Querying & Iterating #{posts_size} Posts with #{comments_size} Comments and #{users_size} Users") do 84 | 10.times do 85 | Bullet.start_request 86 | Post.select("SQL_NO_CACHE *").includes(:user, :comments => :user).each do |p| 87 | p.title 88 | p.user.name 89 | p.comments.each do |c| 90 | c.body 91 | c.user.name 92 | end 93 | end 94 | Bullet.end_request 95 | end 96 | end 97 | end 98 | 99 | puts "End benchmarking..." 100 | 101 | 102 | # Run benchmark with bundler 103 | # 104 | # bundle exec ruby perf/benchmark.rb 105 | # 106 | # bullet 2.3.0 with rails 3.2.2 107 | # user system total real 108 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 16.460000 0.190000 16.650000 ( 16.968246) 109 | # 110 | # bullet 2.3.0 with rails 3.1.4 111 | # user system total real 112 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 14.600000 0.130000 14.730000 ( 14.937590) 113 | # 114 | # bullet 2.3.0 with rails 3.0.12 115 | # user system total real 116 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 26.120000 0.430000 26.550000 ( 27.179304) 117 | # 118 | # 119 | # bullet 2.2.1 with rails 3.0.12 120 | # user system total real 121 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 29.970000 0.270000 30.240000 ( 30.452083) 122 | -------------------------------------------------------------------------------- /spec/bullet/rack_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module Bullet 5 | describe Rack do 6 | let(:middleware) { Bullet::Rack.new app } 7 | let(:app) { Support::AppDouble.new } 8 | 9 | context "#html_request?" do 10 | it "should be true if Content-Type is text/html and http body contains html tag" do 11 | headers = {"Content-Type" => "text/html"} 12 | response = double(:body => "") 13 | expect(middleware).to be_html_request(headers, response) 14 | end 15 | 16 | it "should be true if Content-Type is text/html and http body contains html tag with attributes" do 17 | headers = {"Content-Type" => "text/html"} 18 | response = double(:body => "") 19 | expect(middleware).to be_html_request(headers, response) 20 | end 21 | 22 | it "should be false if there is no Content-Type header" do 23 | headers = {} 24 | response = double(:body => "") 25 | expect(middleware).not_to be_html_request(headers, response) 26 | end 27 | 28 | it "should be false if Content-Type is javascript" do 29 | headers = {"Content-Type" => "text/javascript"} 30 | response = double(:body => "") 31 | expect(middleware).not_to be_html_request(headers, response) 32 | end 33 | 34 | it "should be false if response body doesn't contain html tag" do 35 | headers = {"Content-Type" => "text/html"} 36 | response = double(:body => "
Partial
") 37 | expect(middleware).not_to be_html_request(headers, response) 38 | end 39 | end 40 | 41 | context "empty?" do 42 | it "should be false if response is a string and not empty" do 43 | response = double(:body => "") 44 | expect(middleware).not_to be_empty(response) 45 | end 46 | 47 | it "should be true if response is not found" do 48 | response = ["Not Found"] 49 | expect(middleware).to be_empty(response) 50 | end 51 | 52 | it "should be true if response body is empty" do 53 | response = double(:body => "") 54 | expect(middleware).to be_empty(response) 55 | end 56 | end 57 | 58 | context "#call" do 59 | context "when Bullet is enabled" do 60 | it "should return original response body" do 61 | expected_response = Support::ResponseDouble.new "Actual body" 62 | app.response = expected_response 63 | _, _, response = middleware.call([]) 64 | expect(response).to eq(expected_response) 65 | end 66 | 67 | it "should change response body if notification is active" do 68 | expect(Bullet).to receive(:notification?).and_return(true) 69 | expect(Bullet).to receive(:gather_inline_notifications).and_return("") 70 | expect(Bullet).to receive(:perform_out_of_channel_notifications) 71 | status, headers, response = middleware.call([200, {"Content-Type" => "text/html"}]) 72 | expect(headers["Content-Length"]).to eq("56") 73 | expect(response).to eq([""]) 74 | end 75 | 76 | it "should set the right Content-Length if response body contains accents" do 77 | response = Support::ResponseDouble.new 78 | response.body = "é" 79 | app.response = response 80 | expect(Bullet).to receive(:notification?).and_return(true) 81 | expect(Bullet).to receive(:gather_inline_notifications).and_return("") 82 | status, headers, response = middleware.call([200, {"Content-Type" => "text/html"}]) 83 | expect(headers["Content-Length"]).to eq("58") 84 | end 85 | end 86 | 87 | context "when Bullet is disabled" do 88 | before(:each) { allow(Bullet).to receive(:enable?).and_return(false) } 89 | 90 | it "should not call Bullet.start_request" do 91 | expect(Bullet).not_to receive(:start_request) 92 | middleware.call([]) 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/bullet/detector/n_plus_one_query.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module Detector 3 | class NPlusOneQuery < Association 4 | extend Dependency 5 | 6 | class < associations already exists in object_associations. 66 | def association?(object, associations) 67 | value = object_associations[object.bullet_key] 68 | if value 69 | value.each do |v| 70 | # associations == v comparision order is important here because 71 | # v variable might be a squeel node where :== method is redefined, 72 | # so it does not compare values at all and return unexpected results 73 | result = v.is_a?(Hash) ? v.has_key?(associations) : associations == v 74 | return true if result 75 | end 76 | end 77 | 78 | false 79 | end 80 | 81 | private 82 | def create_notification(callers, klazz, associations) 83 | notify_associations = Array(associations) - Bullet.get_whitelist_associations(:n_plus_one_query, klazz) 84 | 85 | if notify_associations.present? 86 | notice = Bullet::Notification::NPlusOneQuery.new(callers, klazz, notify_associations) 87 | Bullet.notification_collector.add(notice) 88 | end 89 | end 90 | 91 | def caller_in_project 92 | app_root = rails? ? Rails.root.to_s : Dir.pwd 93 | vendor_root = app_root + "/vendor" 94 | caller.select do |c| 95 | c.include?(app_root) && !c.include?(vendor_root) || 96 | Bullet.stacktrace_includes.any? { |include| c.include?(include) } 97 | end 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/bullet/detector/unused_eager_loading_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Detector 5 | describe UnusedEagerLoading do 6 | before(:all) do 7 | @post = Post.first 8 | @post2 = Post.last 9 | end 10 | 11 | context ".call_associations" do 12 | it "should get empty array if eager_loadings" do 13 | expect(UnusedEagerLoading.send(:call_associations, @post.bullet_key, Set.new([:association]))).to be_empty 14 | end 15 | 16 | it "should get call associations if object and association are both in eager_loadings and call_object_associations" do 17 | UnusedEagerLoading.add_eager_loadings([@post], :association) 18 | UnusedEagerLoading.add_call_object_associations(@post, :association) 19 | expect(UnusedEagerLoading.send(:call_associations, @post.bullet_key, Set.new([:association]))).to eq([:association]) 20 | end 21 | 22 | it "should not get call associations if not exist in call_object_associations" do 23 | UnusedEagerLoading.add_eager_loadings([@post], :association) 24 | expect(UnusedEagerLoading.send(:call_associations, @post.bullet_key, Set.new([:association]))).to be_empty 25 | end 26 | end 27 | 28 | context ".diff_object_associations" do 29 | it "should return associations not exist in call_association" do 30 | expect(UnusedEagerLoading.send(:diff_object_associations, @post.bullet_key, Set.new([:association]))).to eq([:association]) 31 | end 32 | 33 | it "should return empty if associations exist in call_association" do 34 | UnusedEagerLoading.add_eager_loadings([@post], :association) 35 | UnusedEagerLoading.add_call_object_associations(@post, :association) 36 | expect(UnusedEagerLoading.send(:diff_object_associations, @post.bullet_key, Set.new([:association]))).to be_empty 37 | end 38 | end 39 | 40 | context ".check_unused_preload_associations" do 41 | it "should create notification if object_association_diff is not empty" do 42 | UnusedEagerLoading.add_object_associations(@post, :association) 43 | expect(UnusedEagerLoading).to receive(:create_notification).with("Post", [:association]) 44 | UnusedEagerLoading.check_unused_preload_associations 45 | end 46 | 47 | it "should not create notification if object_association_diff is empty" do 48 | UnusedEagerLoading.add_object_associations(@post, :association) 49 | UnusedEagerLoading.add_eager_loadings([@post], :association) 50 | UnusedEagerLoading.add_call_object_associations(@post, :association) 51 | expect(UnusedEagerLoading.send(:diff_object_associations, @post.bullet_key, Set.new([:association]))).to be_empty 52 | expect(UnusedEagerLoading).not_to receive(:create_notification).with("Post", [:association]) 53 | UnusedEagerLoading.check_unused_preload_associations 54 | end 55 | end 56 | 57 | context ".add_eager_loadings" do 58 | it "should add objects, associations pair when eager_loadings are empty" do 59 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :associations) 60 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key, @post2.bullet_key], :associations) 61 | end 62 | 63 | it "should add objects, associations pair for existing eager_loadings" do 64 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association1) 65 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association2) 66 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key, @post2.bullet_key], :association1) 67 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key, @post2.bullet_key], :association2) 68 | end 69 | 70 | it "should merge objects, associations pair for existing eager_loadings" do 71 | UnusedEagerLoading.add_eager_loadings([@post], :association1) 72 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association2) 73 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association1) 74 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association2) 75 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key, @post2.bullet_key], :association2) 76 | end 77 | 78 | it "should delete objects, associations pair for existing eager_loadings" do 79 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association1) 80 | UnusedEagerLoading.add_eager_loadings([@post], :association2) 81 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association1) 82 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association2) 83 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post2.bullet_key], :association1) 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/bullet/active_record4.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module ActiveRecord 3 | def self.enable 4 | require 'active_record' 5 | ::ActiveRecord::Relation.class_eval do 6 | alias_method :origin_to_a, :to_a 7 | # if select a collection of objects, then these objects have possible to cause N+1 query. 8 | # if select only one object, then the only one object has impossible to cause N+1 query. 9 | def to_a 10 | records = origin_to_a 11 | if Bullet.start? 12 | if records.size > 1 13 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 14 | Bullet::Detector::CounterCache.add_possible_objects(records) 15 | elsif records.size == 1 16 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 17 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 18 | end 19 | end 20 | records 21 | end 22 | end 23 | 24 | ::ActiveRecord::Associations::Preloader.class_eval do 25 | # include query for one to many associations. 26 | # keep this eager loadings. 27 | alias_method :origin_initialize, :initialize 28 | def initialize(records, associations, preload_scope = nil) 29 | origin_initialize(records, associations, preload_scope) 30 | if Bullet.start? 31 | records = [records].flatten.compact.uniq 32 | return if records.empty? 33 | records.each do |record| 34 | Bullet::Detector::Association.add_object_associations(record, associations) 35 | end 36 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 37 | end 38 | end 39 | end 40 | 41 | ::ActiveRecord::FinderMethods.class_eval do 42 | # add includes in scope 43 | alias_method :origin_find_with_associations, :find_with_associations 44 | def find_with_associations 45 | records = origin_find_with_associations 46 | if Bullet.start? 47 | associations = (eager_load_values + includes_values).uniq 48 | records.each do |record| 49 | Bullet::Detector::Association.add_object_associations(record, associations) 50 | end 51 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 52 | end 53 | records 54 | end 55 | end 56 | 57 | ::ActiveRecord::Associations::JoinDependency.class_eval do 58 | alias_method :origin_instantiate, :instantiate 59 | alias_method :origin_construct_association, :construct_association 60 | 61 | def instantiate(rows) 62 | @bullet_eager_loadings = {} 63 | records = origin_instantiate(rows) 64 | 65 | if Bullet.start? 66 | @bullet_eager_loadings.each do |klazz, eager_loadings_hash| 67 | objects = eager_loadings_hash.keys 68 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 69 | end 70 | end 71 | records 72 | end 73 | 74 | # call join associations 75 | def construct_association(record, join, row) 76 | result = origin_construct_association(record, join, row) 77 | 78 | if Bullet.start? 79 | associations = join.reflection.name 80 | Bullet::Detector::Association.add_object_associations(record, associations) 81 | Bullet::Detector::NPlusOneQuery.call_association(record, associations) 82 | @bullet_eager_loadings[record.class] ||= {} 83 | @bullet_eager_loadings[record.class][record] ||= Set.new 84 | @bullet_eager_loadings[record.class][record] << associations 85 | end 86 | 87 | result 88 | end 89 | end 90 | 91 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 92 | # call one to many associations 93 | alias_method :origin_load_target, :load_target 94 | def load_target 95 | if Bullet.start? 96 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 97 | end 98 | origin_load_target 99 | end 100 | 101 | alias_method :origin_empty?, :empty? 102 | def empty? 103 | if Bullet.start? 104 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 105 | end 106 | origin_empty? 107 | end 108 | 109 | alias_method :origin_include?, :include? 110 | def include?(object) 111 | if Bullet.start? 112 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 113 | end 114 | origin_include?(object) 115 | end 116 | end 117 | 118 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 119 | # call has_one and belongs_to associations 120 | alias_method :origin_reader, :reader 121 | def reader(force_reload = false) 122 | result = origin_reader(force_reload) 123 | if Bullet.start? 124 | unless @inversed 125 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 126 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 127 | end 128 | end 129 | result 130 | end 131 | end 132 | 133 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 134 | alias_method :origin_has_cached_counter?, :has_cached_counter? 135 | 136 | def has_cached_counter?(reflection = reflection()) 137 | result = origin_has_cached_counter?(reflection) 138 | if Bullet.start? 139 | Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name) unless result 140 | end 141 | result 142 | end 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/bullet/active_record41.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module ActiveRecord 3 | def self.enable 4 | require 'active_record' 5 | ::ActiveRecord::Relation.class_eval do 6 | alias_method :origin_to_a, :to_a 7 | # if select a collection of objects, then these objects have possible to cause N+1 query. 8 | # if select only one object, then the only one object has impossible to cause N+1 query. 9 | def to_a 10 | records = origin_to_a 11 | if Bullet.start? 12 | if records.first.class.name !~ /^HABTM_/ 13 | if records.size > 1 14 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 15 | Bullet::Detector::CounterCache.add_possible_objects(records) 16 | elsif records.size == 1 17 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 18 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 19 | end 20 | end 21 | end 22 | records 23 | end 24 | end 25 | 26 | ::ActiveRecord::Associations::Preloader.class_eval do 27 | alias_method :origin_preloaders_on, :preloaders_on 28 | 29 | def preloaders_on(association, records, scope) 30 | if Bullet.start? 31 | records.compact! 32 | if records.first.class.name !~ /^HABTM_/ 33 | records.each do |record| 34 | Bullet::Detector::Association.add_object_associations(record, association) 35 | end 36 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, association) 37 | end 38 | end 39 | origin_preloaders_on(association, records, scope) 40 | end 41 | end 42 | 43 | ::ActiveRecord::FinderMethods.class_eval do 44 | # add includes in scope 45 | alias_method :origin_find_with_associations, :find_with_associations 46 | def find_with_associations 47 | return origin_find_with_associations { |r| yield r } if block_given? 48 | records = origin_find_with_associations 49 | if Bullet.start? 50 | associations = (eager_load_values + includes_values).uniq 51 | records.each do |record| 52 | Bullet::Detector::Association.add_object_associations(record, associations) 53 | end 54 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 55 | end 56 | records 57 | end 58 | end 59 | 60 | ::ActiveRecord::Associations::JoinDependency.class_eval do 61 | alias_method :origin_instantiate, :instantiate 62 | alias_method :origin_construct_model, :construct_model 63 | 64 | def instantiate(result_set, aliases) 65 | @bullet_eager_loadings = {} 66 | records = origin_instantiate(result_set, aliases) 67 | 68 | if Bullet.start? 69 | @bullet_eager_loadings.each do |klazz, eager_loadings_hash| 70 | objects = eager_loadings_hash.keys 71 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 72 | end 73 | end 74 | records 75 | end 76 | 77 | # call join associations 78 | def construct_model(record, node, row, model_cache, id, aliases) 79 | result = origin_construct_model(record, node, row, model_cache, id, aliases) 80 | 81 | if Bullet.start? 82 | associations = node.reflection.name 83 | Bullet::Detector::Association.add_object_associations(record, associations) 84 | Bullet::Detector::NPlusOneQuery.call_association(record, associations) 85 | @bullet_eager_loadings[record.class] ||= {} 86 | @bullet_eager_loadings[record.class][record] ||= Set.new 87 | @bullet_eager_loadings[record.class][record] << associations 88 | end 89 | 90 | result 91 | end 92 | end 93 | 94 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 95 | # call one to many associations 96 | alias_method :origin_load_target, :load_target 97 | def load_target 98 | if Bullet.start? 99 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) unless @inversed 100 | end 101 | origin_load_target 102 | end 103 | 104 | alias_method :origin_empty?, :empty? 105 | def empty? 106 | if Bullet.start? 107 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 108 | end 109 | origin_empty? 110 | end 111 | 112 | alias_method :origin_include?, :include? 113 | def include?(object) 114 | if Bullet.start? 115 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 116 | end 117 | origin_include?(object) 118 | end 119 | end 120 | 121 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 122 | # call has_one and belongs_to associations 123 | alias_method :origin_reader, :reader 124 | def reader(force_reload = false) 125 | result = origin_reader(force_reload) 126 | if Bullet.start? 127 | if @owner.class.name !~ /^HABTM_/ && !@inversed 128 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 129 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 130 | end 131 | end 132 | result 133 | end 134 | end 135 | 136 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 137 | alias_method :origin_has_cached_counter?, :has_cached_counter? 138 | 139 | def has_cached_counter?(reflection = reflection()) 140 | result = origin_has_cached_counter?(reflection) 141 | if Bullet.start? 142 | Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name) unless result 143 | end 144 | result 145 | end 146 | end 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/bullet/active_record3x.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module ActiveRecord 3 | def self.enable 4 | require 'active_record' 5 | ::ActiveRecord::Relation.class_eval do 6 | alias_method :origin_to_a, :to_a 7 | # if select a collection of objects, then these objects have possible to cause N+1 query. 8 | # if select only one object, then the only one object has impossible to cause N+1 query. 9 | def to_a 10 | records = origin_to_a 11 | if Bullet.start? 12 | if records.size > 1 13 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 14 | Bullet::Detector::CounterCache.add_possible_objects(records) 15 | elsif records.size == 1 16 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 17 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 18 | end 19 | end 20 | records 21 | end 22 | end 23 | 24 | ::ActiveRecord::Associations::Preloader.class_eval do 25 | # include query for one to many associations. 26 | # keep this eager loadings. 27 | alias_method :origin_initialize, :initialize 28 | def initialize(records, associations, preload_scope = nil) 29 | origin_initialize(records, associations, preload_scope) 30 | if Bullet.start? 31 | records = [records].flatten.compact.uniq 32 | return if records.empty? 33 | records.each do |record| 34 | Bullet::Detector::Association.add_object_associations(record, associations) 35 | end 36 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 37 | end 38 | end 39 | end 40 | 41 | ::ActiveRecord::FinderMethods.class_eval do 42 | # add includes in scope 43 | alias_method :origin_find_with_associations, :find_with_associations 44 | def find_with_associations 45 | records = origin_find_with_associations 46 | if Bullet.start? 47 | associations = (eager_load_values + includes_values).uniq 48 | records.each do |record| 49 | Bullet::Detector::Association.add_object_associations(record, associations) 50 | end 51 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 52 | end 53 | records 54 | end 55 | end 56 | 57 | ::ActiveRecord::Associations::JoinDependency.class_eval do 58 | alias_method :origin_instantiate, :instantiate 59 | alias_method :origin_construct_association, :construct_association 60 | 61 | def instantiate(rows) 62 | @bullet_eager_loadings = {} 63 | records = origin_instantiate(rows) 64 | 65 | if Bullet.start? 66 | @bullet_eager_loadings.each do |klazz, eager_loadings_hash| 67 | objects = eager_loadings_hash.keys 68 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 69 | end 70 | end 71 | records 72 | end 73 | 74 | # call join associations 75 | def construct_association(record, join, row) 76 | result = origin_construct_association(record, join, row) 77 | 78 | if Bullet.start? 79 | associations = join.reflection.name 80 | Bullet::Detector::Association.add_object_associations(record, associations) 81 | Bullet::Detector::NPlusOneQuery.call_association(record, associations) 82 | @bullet_eager_loadings[record.class] ||= {} 83 | @bullet_eager_loadings[record.class][record] ||= Set.new 84 | @bullet_eager_loadings[record.class][record] << associations 85 | end 86 | 87 | result 88 | end 89 | end 90 | 91 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 92 | # call one to many associations 93 | alias_method :origin_load_target, :load_target 94 | def load_target 95 | if Bullet.start? 96 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 97 | end 98 | origin_load_target 99 | end 100 | 101 | alias_method :origin_empty?, :empty? 102 | def empty? 103 | if Bullet.start? 104 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 105 | end 106 | origin_empty? 107 | end 108 | 109 | alias_method :origin_include?, :include? 110 | def include?(object) 111 | if Bullet.start? 112 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 113 | end 114 | origin_include?(object) 115 | end 116 | end 117 | 118 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 119 | # call has_one and belongs_to associations 120 | alias_method :origin_reader, :reader 121 | def reader(force_reload = false) 122 | result = origin_reader(force_reload) 123 | if Bullet.start? 124 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 125 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 126 | end 127 | result 128 | end 129 | end 130 | 131 | ::ActiveRecord::Associations::Association.class_eval do 132 | alias_method :origin_set_inverse_instance, :set_inverse_instance 133 | def set_inverse_instance(record) 134 | if Bullet.start? 135 | if record && invertible_for?(record) 136 | Bullet::Detector::NPlusOneQuery.add_inversed_object(record, inverse_reflection_for(record).name) 137 | end 138 | end 139 | origin_set_inverse_instance(record) 140 | end 141 | end 142 | 143 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 144 | alias_method :origin_has_cached_counter?, :has_cached_counter? 145 | 146 | def has_cached_counter?(reflection = reflection()) 147 | result = origin_has_cached_counter?(reflection) 148 | if Bullet.start? 149 | Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name) unless result 150 | end 151 | result 152 | end 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/bullet/detector/n_plus_one_query_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Bullet 4 | module Detector 5 | describe NPlusOneQuery do 6 | before(:all) do 7 | @post = Post.first 8 | @post2 = Post.last 9 | end 10 | 11 | context ".call_association" do 12 | it "should add call_object_associations" do 13 | expect(NPlusOneQuery).to receive(:add_call_object_associations).with(@post, :associations) 14 | NPlusOneQuery.call_association(@post, :associations) 15 | end 16 | end 17 | 18 | context ".possible?" do 19 | it "should be true if possible_objects contain" do 20 | NPlusOneQuery.add_possible_objects(@post) 21 | expect(NPlusOneQuery.possible?(@post)).to eq true 22 | end 23 | end 24 | 25 | context ".impossible?" do 26 | it "should be true if impossible_objects contain" do 27 | NPlusOneQuery.add_impossible_object(@post) 28 | expect(NPlusOneQuery.impossible?(@post)).to eq true 29 | end 30 | end 31 | 32 | context ".association?" do 33 | it "should be true if object, associations pair is already existed" do 34 | NPlusOneQuery.add_object_associations(@post, :association) 35 | expect(NPlusOneQuery.association?(@post, :association)).to eq true 36 | end 37 | 38 | it "should be false if object, association pair is not existed" do 39 | NPlusOneQuery.add_object_associations(@post, :association1) 40 | expect(NPlusOneQuery.association?(@post, :associatio2)).to eq false 41 | end 42 | end 43 | 44 | context ".conditions_met?" do 45 | it "should be true if object is possible, not impossible and object, associations pair is not already existed" do 46 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(true) 47 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(false) 48 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(false) 49 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq true 50 | end 51 | 52 | it "should be false if object is not possible, not impossible and object, associations pair is not already existed" do 53 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(false) 54 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(false) 55 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(false) 56 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq false 57 | end 58 | 59 | it "should be false if object is possible, but impossible and object, associations pair is not already existed" do 60 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(true) 61 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(true) 62 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(false) 63 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq false 64 | end 65 | 66 | it "should be false if object is possible, not impossible and object, associations pair is already existed" do 67 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(true) 68 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(false) 69 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(true) 70 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq false 71 | end 72 | end 73 | 74 | context ".call_association" do 75 | it "should create notification if conditions met" do 76 | expect(NPlusOneQuery).to receive(:conditions_met?).with(@post, :association).and_return(true) 77 | expect(NPlusOneQuery).to receive(:caller_in_project).and_return(["caller"]) 78 | expect(NPlusOneQuery).to receive(:create_notification).with(["caller"], "Post", :association) 79 | NPlusOneQuery.call_association(@post, :association) 80 | end 81 | 82 | it "should not create notification if conditions not met" do 83 | expect(NPlusOneQuery).to receive(:conditions_met?).with(@post, :association).and_return(false) 84 | expect(NPlusOneQuery).not_to receive(:caller_in_project!) 85 | expect(NPlusOneQuery).not_to receive(:create_notification).with("Post", :association) 86 | NPlusOneQuery.call_association(@post, :association) 87 | end 88 | end 89 | 90 | context ".caller_in_project" do 91 | it "should include only paths that are in the project" do 92 | in_project = File.join(Dir.pwd, 'abc', 'abc.rb') 93 | not_in_project = '/def/def.rb' 94 | 95 | expect(NPlusOneQuery).to receive(:caller).and_return([in_project, not_in_project]) 96 | expect(NPlusOneQuery).to receive(:conditions_met?).with(@post, :association).and_return(true) 97 | expect(NPlusOneQuery).to receive(:create_notification).with([in_project], "Post", :association) 98 | NPlusOneQuery.call_association(@post, :association) 99 | end 100 | 101 | context "stacktrace_includes" do 102 | before { Bullet.stacktrace_includes = [ 'def' ] } 103 | after { Bullet.stacktrace_includes = nil } 104 | 105 | it "should include paths that are in the stacktrace_include list" do 106 | in_project = File.join(Dir.pwd, 'abc', 'abc.rb') 107 | included_gem = '/def/def.rb' 108 | excluded_gem = '/ghi/ghi.rb' 109 | 110 | expect(NPlusOneQuery).to receive(:caller).and_return([in_project, included_gem, excluded_gem]) 111 | expect(NPlusOneQuery).to receive(:conditions_met?).with(@post, :association).and_return(true) 112 | expect(NPlusOneQuery).to receive(:create_notification).with([in_project, included_gem], "Post", :association) 113 | NPlusOneQuery.call_association(@post, :association) 114 | end 115 | end 116 | end 117 | 118 | context ".add_possible_objects" do 119 | it "should add possible objects" do 120 | NPlusOneQuery.add_possible_objects([@post, @post2]) 121 | expect(NPlusOneQuery.possible_objects).to be_include(@post.bullet_key) 122 | expect(NPlusOneQuery.possible_objects).to be_include(@post2.bullet_key) 123 | end 124 | 125 | it "should not raise error if object is nil" do 126 | expect { NPlusOneQuery.add_possible_objects(nil) }.not_to raise_error 127 | end 128 | end 129 | 130 | context ".add_impossible_object" do 131 | it "should add impossible object" do 132 | NPlusOneQuery.add_impossible_object(@post) 133 | expect(NPlusOneQuery.impossible_objects).to be_include(@post.bullet_key) 134 | end 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/bullet.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/module/delegation" 2 | require 'set' 3 | require 'uniform_notifier' 4 | require 'bullet/ext/object' 5 | require 'bullet/ext/string' 6 | require 'bullet/dependency' 7 | 8 | module Bullet 9 | extend Dependency 10 | 11 | autoload :ActiveRecord, "bullet/#{active_record_version}" if active_record? 12 | autoload :Mongoid, "bullet/#{mongoid_version}" if mongoid? 13 | autoload :Rack, 'bullet/rack' 14 | autoload :Notification, 'bullet/notification' 15 | autoload :Detector, 'bullet/detector' 16 | autoload :Registry, 'bullet/registry' 17 | autoload :NotificationCollector, 'bullet/notification_collector' 18 | 19 | if defined? Rails::Railtie 20 | class BulletRailtie < Rails::Railtie 21 | initializer "bullet.configure_rails_initialization" do |app| 22 | app.middleware.use Bullet::Rack 23 | end 24 | end 25 | end 26 | 27 | class << self 28 | attr_writer :enable, :n_plus_one_query_enable, :unused_eager_loading_enable, :counter_cache_enable, :stacktrace_includes 29 | attr_reader :notification_collector, :whitelist 30 | attr_accessor :add_footer, :orm_pathches_applied 31 | 32 | available_notifiers = UniformNotifier::AVAILABLE_NOTIFIERS.map { |notifier| "#{notifier}=" } 33 | available_notifiers << { :to => UniformNotifier } 34 | delegate *available_notifiers 35 | 36 | def raise=(should_raise) 37 | UniformNotifier.raise=(should_raise ? Notification::UnoptimizedQueryError : false) 38 | end 39 | 40 | DETECTORS = [ Bullet::Detector::NPlusOneQuery, 41 | Bullet::Detector::UnusedEagerLoading, 42 | Bullet::Detector::CounterCache ] 43 | 44 | def enable=(enable) 45 | @enable = @n_plus_one_query_enable = @unused_eager_loading_enable = @counter_cache_enable = enable 46 | if enable? 47 | reset_whitelist 48 | unless orm_pathches_applied 49 | self.orm_pathches_applied = true 50 | Bullet::Mongoid.enable if mongoid? 51 | Bullet::ActiveRecord.enable if active_record? 52 | end 53 | end 54 | end 55 | 56 | def enable? 57 | !!@enable 58 | end 59 | 60 | def n_plus_one_query_enable? 61 | self.enable? && !!@n_plus_one_query_enable 62 | end 63 | 64 | def unused_eager_loading_enable? 65 | self.enable? && !!@unused_eager_loading_enable 66 | end 67 | 68 | def counter_cache_enable? 69 | self.enable? && !!@counter_cache_enable 70 | end 71 | 72 | def stacktrace_includes 73 | @stacktrace_includes || [] 74 | end 75 | 76 | def add_whitelist(options) 77 | @whitelist[options[:type]][options[:class_name].classify] ||= [] 78 | @whitelist[options[:type]][options[:class_name].classify] << options[:association].to_sym 79 | end 80 | 81 | def get_whitelist_associations(type, class_name) 82 | Array(@whitelist[type][class_name]) 83 | end 84 | 85 | def reset_whitelist 86 | @whitelist = {:n_plus_one_query => {}, :unused_eager_loading => {}, :counter_cache => {}} 87 | end 88 | 89 | def bullet_logger=(active) 90 | if active 91 | require 'fileutils' 92 | root_path = "#{rails? ? Rails.root.to_s : Dir.pwd}" 93 | FileUtils.mkdir_p(root_path + '/log') 94 | bullet_log_file = File.open("#{root_path}/log/bullet.log", 'a+') 95 | bullet_log_file.sync = true 96 | UniformNotifier.customized_logger = bullet_log_file 97 | end 98 | end 99 | 100 | def debug(title, message) 101 | puts "[Bullet][#{title}] #{message}" if ENV['BULLET_DEBUG'] == 'true' 102 | end 103 | 104 | def start_request 105 | Thread.current[:bullet_start] = true 106 | Thread.current[:bullet_notification_collector] = Bullet::NotificationCollector.new 107 | 108 | Thread.current[:bullet_object_associations] = Bullet::Registry::Base.new 109 | Thread.current[:bullet_call_object_associations] = Bullet::Registry::Base.new 110 | Thread.current[:bullet_possible_objects] = Bullet::Registry::Object.new 111 | Thread.current[:bullet_impossible_objects] = Bullet::Registry::Object.new 112 | Thread.current[:bullet_inversed_objects] = Bullet::Registry::Base.new 113 | Thread.current[:bullet_eager_loadings] = Bullet::Registry::Association.new 114 | 115 | Thread.current[:bullet_counter_possible_objects] ||= Bullet::Registry::Object.new 116 | Thread.current[:bullet_counter_impossible_objects] ||= Bullet::Registry::Object.new 117 | end 118 | 119 | def end_request 120 | Thread.current[:bullet_start] = nil 121 | Thread.current[:bullet_notification_collector] = nil 122 | 123 | Thread.current[:bullet_object_associations] = nil 124 | Thread.current[:bullet_call_object_associations] = nil 125 | Thread.current[:bullet_possible_objects] = nil 126 | Thread.current[:bullet_impossible_objects] = nil 127 | Thread.current[:bullet_inversed_objects] = nil 128 | Thread.current[:bullet_eager_loadings] = nil 129 | 130 | Thread.current[:bullet_counter_possible_objects] = nil 131 | Thread.current[:bullet_counter_impossible_objects] = nil 132 | end 133 | 134 | def start? 135 | Thread.current[:bullet_start] 136 | end 137 | 138 | def notification_collector 139 | Thread.current[:bullet_notification_collector] 140 | end 141 | 142 | def notification? 143 | return unless start? 144 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 145 | notification_collector.notifications_present? 146 | end 147 | 148 | def gather_inline_notifications 149 | responses = [] 150 | for_each_active_notifier_with_notification do |notification| 151 | responses << notification.notify_inline 152 | end 153 | responses.join( "\n" ) 154 | end 155 | 156 | def perform_out_of_channel_notifications(env = {}) 157 | for_each_active_notifier_with_notification do |notification| 158 | notification.url = env['REQUEST_URI'] 159 | notification.notify_out_of_channel 160 | end 161 | end 162 | 163 | def footer_info 164 | info = [] 165 | notification_collector.collection.each do |notification| 166 | info << notification.short_notice 167 | end 168 | info 169 | end 170 | 171 | def warnings 172 | notification_collector.collection.inject({}) do |warnings, notification| 173 | warning_type = notification.class.to_s.split(':').last.tableize 174 | warnings[warning_type] ||= [] 175 | warnings[warning_type] << notification 176 | warnings 177 | end 178 | end 179 | 180 | def profile 181 | if Bullet.enable? 182 | begin 183 | Bullet.start_request 184 | 185 | yield 186 | 187 | Bullet.perform_out_of_channel_notifications if Bullet.notification? 188 | ensure 189 | Bullet.end_request 190 | end 191 | end 192 | end 193 | 194 | private 195 | def for_each_active_notifier_with_notification 196 | UniformNotifier.active_notifiers.each do |notifier| 197 | notification_collector.collection.each do |notification| 198 | notification.notifier = notifier 199 | yield notification 200 | end 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/bullet/active_record3.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module ActiveRecord 3 | def self.enable 4 | require 'active_record' 5 | ::ActiveRecord::Relation.class_eval do 6 | alias_method :origin_to_a, :to_a 7 | # if select a collection of objects, then these objects have possible to cause N+1 query. 8 | # if select only one object, then the only one object has impossible to cause N+1 query. 9 | def to_a 10 | records = origin_to_a 11 | if Bullet.start? 12 | if records.size > 1 13 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 14 | Bullet::Detector::CounterCache.add_possible_objects(records) 15 | elsif records.size == 1 16 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 17 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 18 | end 19 | end 20 | records 21 | end 22 | end 23 | 24 | ::ActiveRecord::AssociationPreload::ClassMethods.class_eval do 25 | alias_method :origin_preload_associations, :preload_associations 26 | # include query for one to many associations. 27 | # keep this eager loadings. 28 | def preload_associations(records, associations, preload_options={}) 29 | if Bullet.start? 30 | records = [records].flatten.compact.uniq 31 | return if records.empty? 32 | records.each do |record| 33 | Bullet::Detector::Association.add_object_associations(record, associations) 34 | end 35 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 36 | end 37 | origin_preload_associations(records, associations, preload_options={}) 38 | end 39 | end 40 | 41 | ::ActiveRecord::FinderMethods.class_eval do 42 | # add includes in scope 43 | alias_method :origin_find_with_associations, :find_with_associations 44 | def find_with_associations 45 | records = origin_find_with_associations 46 | if Bullet.start? 47 | associations = (@eager_load_values + @includes_values).uniq 48 | records.each do |record| 49 | Bullet::Detector::Association.add_object_associations(record, associations) 50 | end 51 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 52 | end 53 | records 54 | end 55 | end 56 | 57 | ::ActiveRecord::Associations::ClassMethods::JoinDependency.class_eval do 58 | alias_method :origin_instantiate, :instantiate 59 | alias_method :origin_construct_association, :construct_association 60 | 61 | def instantiate(rows) 62 | @bullet_eager_loadings = {} 63 | records = origin_instantiate(rows) 64 | 65 | if Bullet.start? 66 | @bullet_eager_loadings.each do |klazz, eager_loadings_hash| 67 | objects = eager_loadings_hash.keys 68 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 69 | end 70 | end 71 | records 72 | end 73 | 74 | # call join associations 75 | def construct_association(record, join, row) 76 | result = origin_construct_association(record, join, row) 77 | 78 | if Bullet.start? 79 | associations = join.reflection.name 80 | Bullet::Detector::Association.add_object_associations(record, associations) 81 | Bullet::Detector::NPlusOneQuery.call_association(record, associations) 82 | @bullet_eager_loadings[record.class] ||= {} 83 | @bullet_eager_loadings[record.class][record] ||= Set.new 84 | @bullet_eager_loadings[record.class][record] << associations 85 | end 86 | 87 | result 88 | end 89 | end 90 | 91 | ::ActiveRecord::Associations::AssociationCollection.class_eval do 92 | # call one to many associations 93 | alias_method :origin_load_target, :load_target 94 | def load_target 95 | if Bullet.start? 96 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 97 | end 98 | origin_load_target 99 | end 100 | 101 | alias_method :origin_first, :first 102 | def first(*args) 103 | if Bullet.start? 104 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 105 | end 106 | origin_first(*args) 107 | end 108 | 109 | alias_method :origin_last, :last 110 | def last(*args) 111 | if Bullet.start? 112 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 113 | end 114 | origin_last(*args) 115 | end 116 | 117 | alias_method :origin_empty?, :empty? 118 | def empty? 119 | if Bullet.start? 120 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 121 | end 122 | origin_empty? 123 | end 124 | 125 | alias_method :origin_include?, :include? 126 | def include?(object) 127 | if Bullet.start? 128 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 129 | end 130 | origin_include?(object) 131 | end 132 | end 133 | 134 | ::ActiveRecord::Associations::AssociationProxy.class_eval do 135 | # call has_one and belong_to association 136 | alias_method :origin_load_target, :load_target 137 | def load_target 138 | # avoid stack level too deep 139 | result = origin_load_target 140 | if Bullet.start? 141 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) unless caller.any? { |c| c.include?("load_target") } 142 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 143 | end 144 | result 145 | end 146 | 147 | alias_method :origin_set_inverse_instance, :set_inverse_instance 148 | def set_inverse_instance(record, instance) 149 | if Bullet.start? 150 | if record && we_can_set_the_inverse_on_this?(record) 151 | Bullet::Detector::NPlusOneQuery.add_inversed_object(record, @reflection.inverse_of.name) 152 | end 153 | end 154 | origin_set_inverse_instance(record, instance) 155 | end 156 | end 157 | 158 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 159 | alias_method :origin_has_cached_counter?, :has_cached_counter? 160 | 161 | def has_cached_counter? 162 | result = origin_has_cached_counter? 163 | if Bullet.start? 164 | Bullet::Detector::CounterCache.add_counter_cache(@owner, @reflection.name) unless result 165 | end 166 | result 167 | end 168 | end 169 | 170 | ::ActiveRecord::Associations::HasManyThroughAssociation.class_eval do 171 | alias_method :origin_has_cached_counter?, :has_cached_counter? 172 | def has_cached_counter? 173 | result = origin_has_cached_counter? 174 | if Bullet.start? 175 | Bullet::Detector::CounterCache.add_counter_cache(@owner, @reflection.name) unless result 176 | end 177 | result 178 | end 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/bullet/active_record42.rb: -------------------------------------------------------------------------------- 1 | module Bullet 2 | module ActiveRecord 3 | def self.enable 4 | require 'active_record' 5 | ::ActiveRecord::Base.class_eval do 6 | class < 1 33 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 34 | Bullet::Detector::CounterCache.add_possible_objects(records) 35 | elsif records.size == 1 36 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 37 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 38 | end 39 | end 40 | end 41 | records 42 | end 43 | end 44 | 45 | ::ActiveRecord::Associations::Preloader.class_eval do 46 | alias_method :origin_preloaders_on, :preloaders_on 47 | 48 | def preloaders_on(association, records, scope) 49 | if Bullet.start? 50 | records.compact! 51 | if records.first.class.name !~ /^HABTM_/ 52 | records.each do |record| 53 | Bullet::Detector::Association.add_object_associations(record, association) 54 | end 55 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, association) 56 | end 57 | end 58 | origin_preloaders_on(association, records, scope) 59 | end 60 | end 61 | 62 | ::ActiveRecord::FinderMethods.class_eval do 63 | # add includes in scope 64 | alias_method :origin_find_with_associations, :find_with_associations 65 | def find_with_associations 66 | return origin_find_with_associations { |r| yield r } if block_given? 67 | records = origin_find_with_associations 68 | if Bullet.start? 69 | associations = (eager_load_values + includes_values).uniq 70 | records.each do |record| 71 | Bullet::Detector::Association.add_object_associations(record, associations) 72 | end 73 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 74 | end 75 | records 76 | end 77 | end 78 | 79 | ::ActiveRecord::Associations::JoinDependency.class_eval do 80 | alias_method :origin_instantiate, :instantiate 81 | alias_method :origin_construct_model, :construct_model 82 | 83 | def instantiate(result_set, aliases) 84 | @bullet_eager_loadings = {} 85 | records = origin_instantiate(result_set, aliases) 86 | 87 | if Bullet.start? 88 | @bullet_eager_loadings.each do |klazz, eager_loadings_hash| 89 | objects = eager_loadings_hash.keys 90 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 91 | end 92 | end 93 | records 94 | end 95 | 96 | # call join associations 97 | def construct_model(record, node, row, model_cache, id, aliases) 98 | result = origin_construct_model(record, node, row, model_cache, id, aliases) 99 | 100 | if Bullet.start? 101 | associations = node.reflection.name 102 | Bullet::Detector::Association.add_object_associations(record, associations) 103 | Bullet::Detector::NPlusOneQuery.call_association(record, associations) 104 | @bullet_eager_loadings[record.class] ||= {} 105 | @bullet_eager_loadings[record.class][record] ||= Set.new 106 | @bullet_eager_loadings[record.class][record] << associations 107 | end 108 | 109 | result 110 | end 111 | end 112 | 113 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 114 | # call one to many associations 115 | alias_method :origin_load_target, :load_target 116 | def load_target 117 | records = origin_load_target 118 | 119 | if Bullet.start? 120 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) unless @inversed 121 | if records.first.class.name !~ /^HABTM_/ 122 | if records.size > 1 123 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 124 | Bullet::Detector::CounterCache.add_possible_objects(records) 125 | elsif records.size == 1 126 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 127 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 128 | end 129 | end 130 | end 131 | records 132 | end 133 | 134 | alias_method :origin_empty?, :empty? 135 | def empty? 136 | if Bullet.start? 137 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 138 | end 139 | origin_empty? 140 | end 141 | 142 | alias_method :origin_include?, :include? 143 | def include?(object) 144 | if Bullet.start? 145 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 146 | end 147 | origin_include?(object) 148 | end 149 | end 150 | 151 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 152 | # call has_one and belongs_to associations 153 | alias_method :origin_reader, :reader 154 | def reader(force_reload = false) 155 | result = origin_reader(force_reload) 156 | if Bullet.start? 157 | if @owner.class.name !~ /^HABTM_/ && !@inversed 158 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 159 | if Bullet::Detector::NPlusOneQuery.impossible?(@owner) 160 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 161 | else 162 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) if result 163 | end 164 | end 165 | end 166 | result 167 | end 168 | end 169 | 170 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 171 | alias_method :origin_many_empty?, :empty? 172 | def empty? 173 | Thread.current[:bullet_collection_empty] = true 174 | result = origin_many_empty? 175 | Thread.current[:bullet_collection_empty] = nil 176 | if Bullet.start? 177 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 178 | end 179 | result 180 | end 181 | 182 | alias_method :origin_has_cached_counter?, :has_cached_counter? 183 | def has_cached_counter?(reflection = reflection()) 184 | result = origin_has_cached_counter?(reflection) 185 | if Bullet.start? 186 | if !result && !Thread.current[:bullet_collection_empty] 187 | Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name) 188 | end 189 | end 190 | result 191 | end 192 | end 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /spec/support/sqlite_seed.rb: -------------------------------------------------------------------------------- 1 | module Support 2 | module SqliteSeed 3 | def seed_db 4 | newspaper1 = Newspaper.create(:name => "First Newspaper") 5 | newspaper2 = Newspaper.create(:name => "Second Newspaper") 6 | 7 | writer1 = Writer.create(:name => 'first', :newspaper => newspaper1) 8 | writer2 = Writer.create(:name => 'second', :newspaper => newspaper2) 9 | user1 = BaseUser.create(:name => 'third', :newspaper => newspaper1) 10 | user2 = BaseUser.create(:name => 'fourth', :newspaper => newspaper2) 11 | 12 | 13 | category1 = Category.create(:name => 'first') 14 | category2 = Category.create(:name => 'second') 15 | 16 | post1 = category1.posts.create(:name => 'first', :writer => writer1) 17 | post1a = category1.posts.create(:name => 'like first', :writer => writer2, active: false) 18 | post2 = category2.posts.create(:name => 'second', :writer => writer2) 19 | 20 | comment1 = post1.comments.create(:name => 'first', :author => writer1) 21 | comment2 = post1.comments.create(:name => 'first2', :author => writer1) 22 | comment3 = post1.comments.create(:name => 'first3', :author => writer1) 23 | comment4 = post1.comments.create(:name => 'second', :author => writer2) 24 | comment8 = post1a.comments.create(:name => "like first 1", :author => writer1) 25 | comment9 = post1a.comments.create(:name => "like first 2", :author => writer2) 26 | comment5 = post2.comments.create(:name => 'third', :author => user1) 27 | comment6 = post2.comments.create(:name => 'fourth', :author => user2) 28 | comment7 = post2.comments.create(:name => 'fourth', :author => writer1) 29 | 30 | entry1 = category1.entries.create(:name => 'first') 31 | entry2 = category1.entries.create(:name => 'second') 32 | 33 | student1 = Student.create(:name => 'first') 34 | student2 = Student.create(:name => 'second') 35 | teacher1 = Teacher.create(:name => 'first') 36 | teacher2 = Teacher.create(:name => 'second') 37 | student1.teachers = [teacher1, teacher2] 38 | student2.teachers = [teacher1, teacher2] 39 | teacher1.students << student1 40 | teacher2.students << student2 41 | 42 | firm1 = Firm.create(:name => 'first') 43 | firm2 = Firm.create(:name => 'second') 44 | client1 = Client.create(:name => 'first') 45 | client2 = Client.create(:name => 'second') 46 | firm1.clients = [client1, client2] 47 | firm2.clients = [client1, client2] 48 | client1.firms << firm1 49 | client2.firms << firm2 50 | 51 | company1 = Company.create(:name => 'first') 52 | company2 = Company.create(:name => 'second') 53 | 54 | Address.create(:name => 'first', :company => company1) 55 | Address.create(:name => 'second', :company => company2) 56 | 57 | country1 = Country.create(:name => 'first') 58 | country2 = Country.create(:name => 'second') 59 | 60 | country1.cities.create(:name => 'first') 61 | country1.cities.create(:name => 'second') 62 | country2.cities.create(:name => 'third') 63 | country2.cities.create(:name => 'fourth') 64 | 65 | person1 = Person.create(:name => 'first') 66 | person2 = Person.create(:name => 'second') 67 | 68 | person1.pets.create(:name => 'first') 69 | person1.pets.create(:name => 'second') 70 | person2.pets.create(:name => 'third') 71 | person2.pets.create(:name => 'fourth') 72 | 73 | author1 = Author.create(:name => 'author1') 74 | author2 = Author.create(:name => 'author2') 75 | folder1 = Folder.create(:name => 'folder1', :author_id => author1.id) 76 | folder2 = Folder.create(:name => 'folder2', :author_id => author2.id) 77 | page1 = Page.create(:name => 'page1', :parent_id => folder1.id, :author_id => author1.id) 78 | page2 = Page.create(:name => 'page2', :parent_id => folder1.id, :author_id => author1.id) 79 | page3 = Page.create(:name => 'page3', :parent_id => folder2.id, :author_id => author2.id) 80 | page4 = Page.create(:name => 'page4', :parent_id => folder2.id, :author_id => author2.id) 81 | 82 | user1 = User.create(:name => 'user1', :category => category1) 83 | user2 = User.create(:name => 'user2', :category => category1) 84 | 85 | submission1 = user1.create_submission(:name => "submission1") 86 | submission2 = user2.create_submission(:name => "submission2") 87 | 88 | submission1.replies.create(:name => 'reply1') 89 | submission1.replies.create(:name => 'reply2') 90 | submission2.replies.create(:name => 'reply3') 91 | submission2.replies.create(:name => 'reply4') 92 | end 93 | 94 | def setup_db 95 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:') 96 | 97 | ActiveRecord::Schema.define(:version => 1) do 98 | create_table :addresses do |t| 99 | t.column :name, :string 100 | t.column :company_id, :integer 101 | end 102 | 103 | create_table :authors do |t| 104 | t.string :name 105 | end 106 | 107 | create_table :base_users do |t| 108 | t.column :name, :string 109 | t.column :type, :string 110 | t.column :newspaper_id, :integer 111 | end 112 | 113 | create_table :categories do |t| 114 | t.column :name, :string 115 | end 116 | 117 | create_table :cities do |t| 118 | t.string :name 119 | t.integer :country_id 120 | end 121 | 122 | create_table :clients do |t| 123 | t.column :name, :string 124 | end 125 | 126 | create_table :comments do |t| 127 | t.column :name, :string 128 | t.column :post_id, :integer 129 | t.column :author_id, :integer 130 | end 131 | 132 | create_table :companies do |t| 133 | t.column :name, :string 134 | end 135 | 136 | create_table :contacts do |t| 137 | t.column :name, :string 138 | end 139 | 140 | create_table :countries do |t| 141 | t.string :name 142 | end 143 | 144 | create_table :deals do |t| 145 | t.column :name, :string 146 | t.column :hotel_id, :integer 147 | end 148 | 149 | create_table :documents do |t| 150 | t.string :name 151 | t.string :type 152 | t.integer :parent_id 153 | t.integer :author_id 154 | end 155 | 156 | create_table :emails do |t| 157 | t.column :name, :string 158 | t.column :contact_id, :integer 159 | end 160 | 161 | create_table :entries do |t| 162 | t.column :name, :string 163 | t.column :category_id, :integer 164 | end 165 | 166 | create_table :firms do |t| 167 | t.column :name, :string 168 | end 169 | 170 | create_table :hotels do |t| 171 | t.column :name, :string 172 | t.column :location_id, :integer 173 | end 174 | 175 | create_table :locations do |t| 176 | t.column :name, :string 177 | end 178 | 179 | create_table :newspapers do |t| 180 | t.column :name, :string 181 | end 182 | 183 | create_table :people do |t| 184 | t.string :name 185 | t.integer :pets_count 186 | end 187 | 188 | create_table :pets do |t| 189 | t.string :name 190 | t.integer :person_id 191 | end 192 | 193 | create_table :posts do |t| 194 | t.column :name, :string 195 | t.column :category_id, :integer 196 | t.column :writer_id, :integer 197 | t.column :active, :boolean, :default => true 198 | end 199 | 200 | create_table :relationships do |t| 201 | t.column :firm_id, :integer 202 | t.column :client_id, :integer 203 | end 204 | 205 | create_table :students do |t| 206 | t.column :name, :string 207 | end 208 | 209 | create_table :students_teachers, :id => false do |t| 210 | t.column :student_id, :integer 211 | t.column :teacher_id, :integer 212 | end 213 | 214 | create_table :teachers do |t| 215 | t.column :name, :string 216 | end 217 | 218 | create_table :replies do |t| 219 | t.column :name, :string 220 | t.column :submission_id, :integer 221 | end 222 | 223 | create_table :submissions do |t| 224 | t.column :name, :string 225 | t.column :user_id, :integer 226 | end 227 | 228 | create_table :users do |t| 229 | t.column :name, :string 230 | t.column :category_id, :integer 231 | end 232 | end 233 | end 234 | 235 | extend self 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /spec/integration/mongoid/association_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | if mongoid? 4 | describe Bullet::Detector::Association do 5 | context 'embeds_many' do 6 | context "posts => users" do 7 | it "should detect nothing" do 8 | Mongoid::Post.all.each do |post| 9 | post.users.map(&:name) 10 | end 11 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 12 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 13 | 14 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 15 | end 16 | end 17 | end 18 | 19 | context 'has_many' do 20 | context "posts => comments" do 21 | it "should detect non preload posts => comments" do 22 | Mongoid::Post.all.each do |post| 23 | post.comments.map(&:name) 24 | end 25 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 26 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 27 | 28 | expect(Bullet::Detector::Association).to be_detecting_unpreloaded_association_for(Mongoid::Post, :comments) 29 | end 30 | 31 | it "should detect preload post => comments" do 32 | Mongoid::Post.includes(:comments).each do |post| 33 | post.comments.map(&:name) 34 | end 35 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 36 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 37 | 38 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 39 | end 40 | 41 | it "should detect unused preload post => comments" do 42 | Mongoid::Post.includes(:comments).map(&:name) 43 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 44 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Post, :comments) 45 | 46 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 47 | end 48 | 49 | it "should not detect unused preload post => comments" do 50 | Mongoid::Post.all.map(&:name) 51 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 52 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 53 | 54 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 55 | end 56 | end 57 | 58 | context "category => posts, category => entries" do 59 | it "should detect non preload with category => [posts, entries]" do 60 | Mongoid::Category.all.each do |category| 61 | category.posts.map(&:name) 62 | category.entries.map(&:name) 63 | end 64 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 65 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 66 | 67 | expect(Bullet::Detector::Association).to be_detecting_unpreloaded_association_for(Mongoid::Category, :posts) 68 | expect(Bullet::Detector::Association).to be_detecting_unpreloaded_association_for(Mongoid::Category, :entries) 69 | end 70 | 71 | it "should detect preload with category => posts, but not with category => entries" do 72 | Mongoid::Category.includes(:posts).each do |category| 73 | category.posts.map(&:name) 74 | category.entries.map(&:name) 75 | end 76 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 77 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 78 | 79 | expect(Bullet::Detector::Association).not_to be_detecting_unpreloaded_association_for(Mongoid::Category, :posts) 80 | expect(Bullet::Detector::Association).to be_detecting_unpreloaded_association_for(Mongoid::Category, :entries) 81 | end 82 | 83 | it "should detect preload with category => [posts, entries]" do 84 | Mongoid::Category.includes(:posts, :entries).each do |category| 85 | category.posts.map(&:name) 86 | category.entries.map(&:name) 87 | end 88 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 89 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 90 | 91 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 92 | end 93 | 94 | it "should detect unused preload with category => [posts, entries]" do 95 | Mongoid::Category.includes(:posts, :entries).map(&:name) 96 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 97 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Category, :posts) 98 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Category, :entries) 99 | 100 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 101 | end 102 | 103 | it "should detect unused preload with category => entries, but not with category => posts" do 104 | Mongoid::Category.includes(:posts, :entries).each do |category| 105 | category.posts.map(&:name) 106 | end 107 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 108 | expect(Bullet::Detector::Association).not_to be_unused_preload_associations_for(Mongoid::Category, :posts) 109 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Category, :entries) 110 | 111 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 112 | end 113 | end 114 | 115 | context "post => comment" do 116 | it "should detect unused preload with post => comments" do 117 | Mongoid::Post.includes(:comments).each do |post| 118 | post.comments.first.name 119 | end 120 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 121 | expect(Bullet::Detector::Association).not_to be_unused_preload_associations_for(Mongoid::Post, :comments) 122 | 123 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 124 | end 125 | 126 | it "should detect preload with post => commnets" do 127 | Mongoid::Post.first.comments.map(&:name) 128 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 129 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 130 | 131 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 132 | end 133 | end 134 | 135 | context "scope preload_comments" do 136 | it "should detect preload post => comments with scope" do 137 | Mongoid::Post.preload_comments.each do |post| 138 | post.comments.map(&:name) 139 | end 140 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 141 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 142 | 143 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 144 | end 145 | 146 | it "should detect unused preload with scope" do 147 | Mongoid::Post.preload_comments.map(&:name) 148 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 149 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Post, :comments) 150 | 151 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 152 | end 153 | end 154 | end 155 | 156 | context 'belongs_to' do 157 | context "comment => post" do 158 | it "should detect non preload with comment => post" do 159 | Mongoid::Comment.all.each do |comment| 160 | comment.post.name 161 | end 162 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 163 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 164 | 165 | expect(Bullet::Detector::Association).to be_detecting_unpreloaded_association_for(Mongoid::Comment, :post) 166 | end 167 | 168 | it "should detect preload with one comment => post" do 169 | Mongoid::Comment.first.post.name 170 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 171 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 172 | 173 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 174 | end 175 | 176 | it "should detect preload with comment => post" do 177 | Mongoid::Comment.includes(:post).each do |comment| 178 | comment.post.name 179 | end 180 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 181 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 182 | 183 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 184 | end 185 | 186 | it "should not detect preload with comment => post" do 187 | Mongoid::Comment.all.map(&:name) 188 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 189 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 190 | 191 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 192 | end 193 | 194 | it "should detect unused preload with comments => post" do 195 | Mongoid::Comment.includes(:post).map(&:name) 196 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 197 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Comment, :post) 198 | 199 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 200 | end 201 | end 202 | end 203 | 204 | context "has_one" do 205 | context "company => address" do 206 | if Mongoid::VERSION !~ /\A3.0/ 207 | it "should detect non preload association" do 208 | Mongoid::Company.all.each do |company| 209 | company.address.name 210 | end 211 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 212 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 213 | 214 | expect(Bullet::Detector::Association).to be_detecting_unpreloaded_association_for(Mongoid::Company, :address) 215 | end 216 | end 217 | 218 | it "should detect preload association" do 219 | Mongoid::Company.includes(:address).each do |company| 220 | company.address.name 221 | end 222 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 223 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 224 | 225 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 226 | end 227 | 228 | it "should not detect preload association" do 229 | Mongoid::Company.all.map(&:name) 230 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 231 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 232 | 233 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 234 | end 235 | 236 | it "should detect unused preload association" do 237 | criteria = Mongoid::Company.includes(:address) 238 | criteria.map(&:name) 239 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 240 | expect(Bullet::Detector::Association).to be_unused_preload_associations_for(Mongoid::Company, :address) 241 | 242 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 243 | end 244 | end 245 | end 246 | 247 | context "call one association that in possible objects" do 248 | it "should not detect preload association" do 249 | Mongoid::Post.all 250 | Mongoid::Post.first.comments.map(&:name) 251 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 252 | expect(Bullet::Detector::Association).not_to be_has_unused_preload_associations 253 | 254 | expect(Bullet::Detector::Association).to be_completely_preloading_associations 255 | end 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bullet 2 | 3 | [![Gem Version](https://badge.fury.io/rb/bullet.png)](http://badge.fury.io/rb/bullet) 4 | [![Build Status](https://secure.travis-ci.org/flyerhzm/bullet.png)](http://travis-ci.org/flyerhzm/bullet) 5 | [![Coverage Status](https://coveralls.io/repos/flyerhzm/bullet/badge.png?branch=master)](https://coveralls.io/r/flyerhzm/bullet) 6 | 7 | [![Coderwall Endorse](http://api.coderwall.com/flyerhzm/endorsecount.png)](http://coderwall.com/flyerhzm) 8 | 9 | The Bullet gem is designed to help you increase your application's performance by reducing the number of queries it makes. It will watch your queries while you develop your application and notify you when you should add eager loading (N+1 queries), when you're using eager loading that isn't necessary and when you should use counter cache. 10 | 11 | Best practice is to use Bullet in development mode or custom mode (staging, profile, etc.). The last thing you want is your clients getting alerts about how lazy you are. 12 | 13 | Bullet gem now supports **activerecord** >= 3.0 and **mongoid** >= 2.4.1. 14 | 15 | If you use activerecord 2.x, please use bullet <= 4.5.0 16 | 17 | ## External Introduction 18 | 19 | * [http://railscasts.com/episodes/372-bullet](http://railscasts.com/episodes/372-bullet) 20 | * [http://ruby5.envylabs.com/episodes/9-episode-8-september-8-2009](http://ruby5.envylabs.com/episodes/9-episode-8-september-8-2009) 21 | * [http://railslab.newrelic.com/2009/10/23/episode-19-on-the-edge-part-1](http://railslab.newrelic.com/2009/10/23/episode-19-on-the-edge-part-1) 22 | * [http://weblog.rubyonrails.org/2009/10/22/community-highlights](http://weblog.rubyonrails.org/2009/10/22/community-highlights) 23 | 24 | ## Install 25 | 26 | You can install it as a gem: 27 | 28 | ``` 29 | gem install bullet 30 | ``` 31 | 32 | or add it into a Gemfile (Bundler): 33 | 34 | 35 | ```ruby 36 | gem "bullet", :group => "development" 37 | ``` 38 | 39 | **Note**: make sure `bulelt` gem is added after activerecord (rails) and 40 | mongoid. 41 | 42 | ## Configuration 43 | 44 | Bullet won't do ANYTHING unless you tell it to explicitly. Append to 45 | `config/environments/development.rb` initializer with the following code: 46 | 47 | ```ruby 48 | config.after_initialize do 49 | Bullet.enable = true 50 | Bullet.alert = true 51 | Bullet.bullet_logger = true 52 | Bullet.console = true 53 | Bullet.growl = true 54 | Bullet.xmpp = { :account => 'bullets_account@jabber.org', 55 | :password => 'bullets_password_for_jabber', 56 | :receiver => 'your_account@jabber.org', 57 | :show_online_status => true } 58 | Bullet.rails_logger = true 59 | Bullet.honeybadger = true 60 | Bullet.bugsnag = true 61 | Bullet.airbrake = true 62 | Bullet.rollbar = true 63 | Bullet.add_footer = true 64 | Bullet.stacktrace_includes = [ 'your_gem', 'your_middleware' ] 65 | Bullet.slack = { webhook_url: 'http://some.slack.url', foo: 'bar' } 66 | end 67 | ``` 68 | 69 | The notifier of Bullet is a wrap of [uniform_notifier](https://github.com/flyerhzm/uniform_notifier) 70 | 71 | The code above will enable all seven of the Bullet notification systems: 72 | * `Bullet.enable`: enable Bullet gem, otherwise do nothing 73 | * `Bullet.alert`: pop up a JavaScript alert in the browser 74 | * `Bullet.bullet_logger`: log to the Bullet log file (Rails.root/log/bullet.log) 75 | * `Bullet.rails_logger`: add warnings directly to the Rails log 76 | * `Bullet.honeybadger`: add notifications to Honeybadger 77 | * `Bullet.bugsnag`: add notifications to bugsnag 78 | * `Bullet.airbrake`: add notifications to airbrake 79 | * `Bullet.rollbar`: add notifications to rollbar 80 | * `Bullet.console`: log warnings to your browser's console.log (Safari/Webkit browsers or Firefox w/Firebug installed) 81 | * `Bullet.growl`: pop up Growl warnings if your system has Growl installed. Requires a little bit of configuration 82 | * `Bullet.xmpp`: send XMPP/Jabber notifications to the receiver indicated. Note that the code will currently not handle the adding of contacts, so you will need to make both accounts indicated know each other manually before you will receive any notifications. If you restart the development server frequently, the 'coming online' sound for the Bullet account may start to annoy - in this case set :show_online_status to false; you will still get notifications, but the Bullet account won't announce it's online status anymore. 83 | * `Bullet.raise`: raise errors, useful for making your specs fail unless they have optimized queries 84 | * `Bullet.add_footer`: adds the details in the bottom left corner of the page 85 | * `Bullet.stacktrace_includes`: include paths with any of these substrings in the stack trace, even if they are not in your main app 86 | * `Bullet.slack`: add notifications to slack 87 | 88 | Bullet also allows you to disable any of its detectors. 89 | 90 | ```ruby 91 | # Each of these settings defaults to true 92 | 93 | # Detect N+1 queries 94 | Bullet.n_plus_one_query_enable = false 95 | 96 | # Detect eager-loaded associations which are not used 97 | Bullet.unused_eager_loading_enable = false 98 | 99 | # Detect unnecessary COUNT queries which could be avoided 100 | # with a counter_cache 101 | Bullet.counter_cache_enable = false 102 | ``` 103 | 104 | ## Whitelist 105 | 106 | Sometimes Bullet may notify you of query problems you don't care to fix, or 107 | which come from outside your code. You can whitelist these to ignore them: 108 | 109 | ```ruby 110 | Bullet.add_whitelist :type => :n_plus_one_query, :class_name => "Post", :association => :comments 111 | Bullet.add_whitelist :type => :unused_eager_loading, :class_name => "Post", :association => :comments 112 | Bullet.add_whitelist :type => :counter_cache, :class_name => "Country", :association => :cities 113 | ``` 114 | 115 | If you want to skip bullet in some specific controller actions, you can 116 | do like 117 | 118 | ```ruby 119 | class ApplicationController < ActionController::Base 120 | around_action :skip_bullet 121 | 122 | def skip_bullet 123 | Bullet.enable = false 124 | yield 125 | ensure 126 | Bullet.enable = true 127 | end 128 | end 129 | ``` 130 | 131 | ## Log 132 | 133 | The Bullet log `log/bullet.log` will look something like this: 134 | 135 | * N+1 Query: 136 | 137 | ``` 138 | 2009-08-25 20:40:17[INFO] N+1 Query: PATH_INFO: /posts; model: Post => associations: [comments]· 139 | Add to your finder: :include => [:comments] 140 | 2009-08-25 20:40:17[INFO] N+1 Query: method call stack:· 141 | /Users/richard/Downloads/test/app/views/posts/index.html.erb:11:in `_run_erb_app47views47posts47index46html46erb' 142 | /Users/richard/Downloads/test/app/views/posts/index.html.erb:8:in `each' 143 | /Users/richard/Downloads/test/app/views/posts/index.html.erb:8:in `_run_erb_app47views47posts47index46html46erb' 144 | /Users/richard/Downloads/test/app/controllers/posts_controller.rb:7:in `index' 145 | ``` 146 | 147 | The first two lines are notifications that N+1 queries have been encountered. The remaining lines are stack traces so you can find exactly where the queries were invoked in your code, and fix them. 148 | 149 | * Unused eager loading: 150 | 151 | ``` 152 | 2009-08-25 20:53:56[INFO] Unused eager loadings: PATH_INFO: /posts; model: Post => associations: [comments]· 153 | Remove from your finder: :include => [:comments] 154 | ``` 155 | 156 | These two lines are notifications that unused eager loadings have been encountered. 157 | 158 | * Need counter cache: 159 | 160 | ``` 161 | 2009-09-11 09:46:50[INFO] Need Counter Cache 162 | Post => [:comments] 163 | ``` 164 | 165 | ## Growl, XMPP/Jabber and Airbrake Support 166 | 167 | see [https://github.com/flyerhzm/uniform_notifier](https://github.com/flyerhzm/uniform_notifier) 168 | 169 | ## Important 170 | 171 | If you find Bullet does not work for you, *please disable your browser's cache*. 172 | 173 | ## Advanced 174 | 175 | ### Profile a job 176 | 177 | The Bullet gem uses rack middleware to profile requests. If you want to use Bullet without an http server, like to profile a job, you can use use profile method and fetch warnings 178 | 179 | ```ruby 180 | Bullet.profile do 181 | # do anything 182 | 183 | warnings = Bullet.warnings 184 | end 185 | ``` 186 | 187 | ### Work with sinatra 188 | 189 | Configure and use `Bullet::Rack` 190 | 191 | ```ruby 192 | configure :development do 193 | Bullet.enable = true 194 | Bullet.bullet_logger = true 195 | use Bullet::Rack 196 | end 197 | ``` 198 | 199 | ### Run in tests 200 | 201 | First you need to enable Bullet in test environment. 202 | 203 | ```ruby 204 | # config/environments/test.rb 205 | config.after_initialize do 206 | Bullet.enable = true 207 | Bullet.bullet_logger = true 208 | Bullet.raise = true # raise an error if n+1 query occurs 209 | end 210 | ``` 211 | 212 | Then wrap each test in Bullet api. 213 | 214 | ```ruby 215 | # spec/spec_helper.rb 216 | if Bullet.enable? 217 | config.before(:each) do 218 | Bullet.start_request 219 | end 220 | 221 | config.after(:each) do 222 | Bullet.perform_out_of_channel_notifications if Bullet.notification? 223 | Bullet.end_request 224 | end 225 | end 226 | ``` 227 | 228 | ## Debug Mode 229 | 230 | Bullet outputs some details info, to enable debug mode, set 231 | `BULLET_DEBUG=true` env. 232 | 233 | ## Contributors 234 | 235 | [https://github.com/flyerhzm/bullet/contributors](https://github.com/flyerhzm/bullet/contributors) 236 | 237 | ## Demo 238 | 239 | Bullet is designed to function as you browse through your application in development. To see it in action, follow these steps to create, detect, and fix example query problems. 240 | 241 | 1\. Create an example application 242 | 243 | ``` 244 | $ rails new test_bullet 245 | $ cd test_bullet 246 | $ rails g scaffold post name:string 247 | $ rails g scaffold comment name:string post_id:integer 248 | $ bundle exec rake db:migrate 249 | ``` 250 | 251 | 2\. Change `app/model/post.rb` and `app/model/comment.rb` 252 | 253 | ```ruby 254 | class Post < ActiveRecord::Base 255 | has_many :comments 256 | end 257 | 258 | class Comment < ActiveRecord::Base 259 | belongs_to :post 260 | end 261 | ``` 262 | 263 | 3\. Go to `rails c` and execute 264 | 265 | ```ruby 266 | post1 = Post.create(:name => 'first') 267 | post2 = Post.create(:name => 'second') 268 | post1.comments.create(:name => 'first') 269 | post1.comments.create(:name => 'second') 270 | post2.comments.create(:name => 'third') 271 | post2.comments.create(:name => 'fourth') 272 | ``` 273 | 274 | 4\. Change the `app/views/posts/index.html.erb` to produce a N+1 query 275 | 276 | ``` 277 | <% @posts.each do |post| %> 278 | 279 | <%= post.name %> 280 | <%= post.comments.map(&:name) %> 281 | <%= link_to 'Show', post %> 282 | <%= link_to 'Edit', edit_post_path(post) %> 283 | <%= link_to 'Destroy', post, :confirm => 'Are you sure?', :method => :delete %> 284 | 285 | <% end %> 286 | ``` 287 | 288 | 5\. Add the `bullet` gem to the `Gemfile` 289 | 290 | ```ruby 291 | gem "bullet" 292 | ``` 293 | 294 | And run 295 | 296 | ``` 297 | bundle install 298 | ``` 299 | 300 | 6\. enable the Bullet gem in development, add a line to 301 | `config/environments/development.rb` 302 | 303 | ```ruby 304 | config.after_initialize do 305 | Bullet.enable = true 306 | Bullet.alert = true 307 | Bullet.bullet_logger = true 308 | Bullet.console = true 309 | # Bullet.growl = true 310 | Bullet.rails_logger = true 311 | Bullet.add_footer = true 312 | end 313 | ``` 314 | 315 | 7\. Start the server 316 | 317 | ``` 318 | $ rails s 319 | ``` 320 | 321 | 8\. Visit `http://localhost:3000/posts` in browser, and you will see a popup alert box that says 322 | 323 | ``` 324 | The request has unused preload associations as follows: 325 | None 326 | The request has N+1 queries as follows: 327 | model: Post => associations: [comment] 328 | ``` 329 | 330 | which means there is a N+1 query from the Post object to its Comment association. 331 | 332 | In the meanwhile, there's a log appended into `log/bullet.log` file 333 | 334 | ``` 335 | 2010-03-07 14:12:18[INFO] N+1 Query in /posts 336 | Post => [:comments] 337 | Add to your finder: :include => [:comments] 338 | 2010-03-07 14:12:18[INFO] N+1 Query method call stack 339 | /home/flyerhzm/Downloads/test_bullet/app/views/posts/index.html.erb:14:in `_render_template__600522146_80203160_0' 340 | /home/flyerhzm/Downloads/test_bullet/app/views/posts/index.html.erb:11:in `each' 341 | /home/flyerhzm/Downloads/test_bullet/app/views/posts/index.html.erb:11:in `_render_template__600522146_80203160_0' 342 | /home/flyerhzm/Downloads/test_bullet/app/controllers/posts_controller.rb:7:in `index' 343 | ``` 344 | 345 | The generated SQL is: 346 | 347 | ``` 348 | Post Load (1.0ms) SELECT * FROM "posts" 349 | Comment Load (0.4ms) SELECT * FROM "comments" WHERE ("comments".post_id = 1) 350 | Comment Load (0.3ms) SELECT * FROM "comments" WHERE ("comments".post_id = 2) 351 | ``` 352 | 353 | 9\. To fix the N+1 query, change `app/controllers/posts_controller.rb` file 354 | 355 | ```ruby 356 | def index 357 | @posts = Post.includes(:comments) 358 | 359 | respond_to do |format| 360 | format.html # index.html.erb 361 | format.xml { render :xml => @posts } 362 | end 363 | end 364 | ``` 365 | 366 | 10\. Refresh `http://localhost:3000/posts`. Now there's no alert box and nothing new in the log. 367 | 368 | The generated SQL is: 369 | 370 | ``` 371 | Post Load (0.5ms) SELECT * FROM "posts" 372 | Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE ("comments".post_id IN (1,2)) 373 | ``` 374 | 375 | N+1 query fixed. Cool! 376 | 377 | 11\. Now simulate unused eager loading. Change 378 | `app/controllers/posts_controller.rb` and 379 | `app/views/posts/index.html.erb` 380 | 381 | ```ruby 382 | def index 383 | @posts = Post.includes(:comments) 384 | 385 | respond_to do |format| 386 | format.html # index.html.erb 387 | format.xml { render :xml => @posts } 388 | end 389 | end 390 | ``` 391 | 392 | ``` 393 | <% @posts.each do |post| %> 394 | 395 | <%= post.name %> 396 | <%= link_to 'Show', post %> 397 | <%= link_to 'Edit', edit_post_path(post) %> 398 | <%= link_to 'Destroy', post, :confirm => 'Are you sure?', :method => :delete %> 399 | 400 | <% end %> 401 | ``` 402 | 403 | 12\. Refresh `http://localhost:3000/posts`, and you will see a popup alert box that says 404 | 405 | ``` 406 | The request has unused preload associations as follows: 407 | model: Post => associations: [comment] 408 | The request has N+1 queries as follows: 409 | None 410 | ``` 411 | 412 | Meanwhile, there's a line appended to `log/bullet.log` 413 | 414 | ``` 415 | 2009-08-25 21:13:22[INFO] Unused preload associations: PATH_INFO: /posts; model: Post => associations: [comments]· 416 | Remove from your finder: :include => [:comments] 417 | ``` 418 | 419 | 13\. Simulate counter_cache. Change `app/controllers/posts_controller.rb` 420 | and `app/views/posts/index.html.erb` 421 | 422 | ```ruby 423 | def index 424 | @posts = Post.all 425 | 426 | respond_to do |format| 427 | format.html # index.html.erb 428 | format.xml { render :xml => @posts } 429 | end 430 | end 431 | ``` 432 | 433 | ``` 434 | <% @posts.each do |post| %> 435 | 436 | <%= post.name %> 437 | <%= post.comments.size %> 438 | <%= link_to 'Show', post %> 439 | <%= link_to 'Edit', edit_post_path(post) %> 440 | <%= link_to 'Destroy', post, :confirm => 'Are you sure?', :method => :delete %> 441 | 442 | <% end %> 443 | ``` 444 | 445 | 14\. Refresh `http://localhost:3000/posts`, then you will see a popup alert box that says 446 | 447 | ``` 448 | Need counter cache 449 | Post => [:comments] 450 | ``` 451 | 452 | Meanwhile, there's a line appended to `log/bullet.log` 453 | 454 | ``` 455 | 2009-09-11 10:07:10[INFO] Need Counter Cache 456 | Post => [:comments] 457 | ``` 458 | 459 | Copyright (c) 2009 - 2015 Richard Huang (flyerhzm@gmail.com), released under the MIT license 460 | --------------------------------------------------------------------------------