├── .github ├── dependabot.yml └── workflows │ ├── close_inactive_issues.yml │ └── main.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── Gemfile.mongoid ├── Gemfile.mongoid-4.0 ├── Gemfile.mongoid-5.0 ├── Gemfile.mongoid-6.0 ├── Gemfile.mongoid-7.0 ├── Gemfile.mongoid-8.0 ├── Gemfile.mongoid-9.0 ├── Gemfile.rails-4.2 ├── Gemfile.rails-5.0 ├── Gemfile.rails-5.1 ├── Gemfile.rails-5.2 ├── Gemfile.rails-6.0 ├── Gemfile.rails-6.1 ├── Gemfile.rails-7.0 ├── Gemfile.rails-7.1 ├── Gemfile.rails-7.2 ├── Gemfile.rails-8.0 ├── Guardfile ├── Hacking.md ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bullet.gemspec ├── lib ├── bullet.rb ├── bullet │ ├── active_job.rb │ ├── active_record4.rb │ ├── active_record41.rb │ ├── active_record42.rb │ ├── active_record5.rb │ ├── active_record52.rb │ ├── active_record60.rb │ ├── active_record61.rb │ ├── active_record70.rb │ ├── active_record71.rb │ ├── active_record72.rb │ ├── active_record80.rb │ ├── bullet_xhr.js │ ├── dependency.rb │ ├── detector.rb │ ├── detector │ │ ├── association.rb │ │ ├── base.rb │ │ ├── counter_cache.rb │ │ ├── n_plus_one_query.rb │ │ └── unused_eager_loading.rb │ ├── ext │ │ ├── object.rb │ │ └── string.rb │ ├── mongoid4x.rb │ ├── mongoid5x.rb │ ├── mongoid6x.rb │ ├── mongoid7x.rb │ ├── mongoid8x.rb │ ├── mongoid9x.rb │ ├── notification.rb │ ├── notification │ │ ├── base.rb │ │ ├── counter_cache.rb │ │ ├── n_plus_one_query.rb │ │ └── unused_eager_loading.rb │ ├── notification_collector.rb │ ├── rack.rb │ ├── registry.rb │ ├── registry │ │ ├── association.rb │ │ ├── base.rb │ │ ├── call_stack.rb │ │ └── object.rb │ ├── stack_trace_filter.rb │ └── version.rb └── generators │ └── bullet │ └── install_generator.rb ├── perf └── benchmark.rb ├── rails └── init.rb ├── spec ├── bullet │ ├── detector │ │ ├── association_spec.rb │ │ ├── base_spec.rb │ │ ├── counter_cache_spec.rb │ │ ├── n_plus_one_query_spec.rb │ │ └── unused_eager_loading_spec.rb │ ├── ext │ │ ├── object_spec.rb │ │ └── string_spec.rb │ ├── notification │ │ ├── base_spec.rb │ │ ├── counter_cache_spec.rb │ │ ├── n_plus_one_query_spec.rb │ │ └── unused_eager_loading_spec.rb │ ├── notification_collector_spec.rb │ ├── rack_spec.rb │ ├── registry │ │ ├── association_spec.rb │ │ ├── base_spec.rb │ │ └── object_spec.rb │ └── stack_trace_filter_spec.rb ├── bullet_spec.rb ├── integration │ ├── active_record │ │ └── association_spec.rb │ ├── counter_cache_spec.rb │ └── mongoid │ │ └── association_spec.rb ├── models │ ├── address.rb │ ├── attachment.rb │ ├── author.rb │ ├── base_user.rb │ ├── category.rb │ ├── city.rb │ ├── client.rb │ ├── comment.rb │ ├── company.rb │ ├── country.rb │ ├── deal.rb │ ├── document.rb │ ├── entry.rb │ ├── firm.rb │ ├── folder.rb │ ├── group.rb │ ├── mongoid │ │ ├── address.rb │ │ ├── category.rb │ │ ├── comment.rb │ │ ├── company.rb │ │ ├── entry.rb │ │ ├── post.rb │ │ └── user.rb │ ├── newspaper.rb │ ├── page.rb │ ├── person.rb │ ├── pet.rb │ ├── post.rb │ ├── relationship.rb │ ├── reply.rb │ ├── role.rb │ ├── student.rb │ ├── submission.rb │ ├── teacher.rb │ ├── user.rb │ └── writer.rb ├── spec_helper.rb └── support │ ├── bullet_ext.rb │ ├── mongo_seed.rb │ ├── rack_double.rb │ └── sqlite_seed.rb ├── tasks └── bullet_tasks.rake ├── test.sh └── update.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/close_inactive_issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: CI 9 | 10 | on: 11 | push: 12 | branches: [ main ] 13 | pull_request: 14 | branches: [ main ] 15 | 16 | jobs: 17 | test_rails_4: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | gemfile: ['Gemfile.rails-4.2'] 22 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 23 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Ruby 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: 2.7 30 | bundler: 1 31 | bundler-cache: true 32 | - name: Run tests 33 | run: bundle exec rake 34 | test_rails_5: 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | gemfile: ['Gemfile.rails-5.0', 'Gemfile.rails-5.1', 'Gemfile.rails-5.2'] 39 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 40 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Set up Ruby 44 | uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: 2.7 47 | bundler: 1 48 | bundler-cache: true 49 | - name: Run tests 50 | run: bundle exec rake 51 | test_rails_6: 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | gemfile: ['Gemfile.rails-6.0', 'Gemfile.rails-6.1'] 56 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 57 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | - name: Set up Ruby 61 | uses: ruby/setup-ruby@v1 62 | with: 63 | ruby-version: 2.7 64 | bundler-cache: true 65 | - name: Run tests 66 | run: bundle exec rake 67 | test_rails_7: 68 | runs-on: ubuntu-latest 69 | strategy: 70 | matrix: 71 | gemfile: ['Gemfile.rails-7.0', 'Gemfile.rails-7.1', 'Gemfile.rails-7.2'] 72 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 73 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 74 | steps: 75 | - uses: actions/checkout@v4 76 | - name: Set up Ruby 77 | uses: ruby/setup-ruby@v1 78 | with: 79 | ruby-version: 3.1 80 | bundler-cache: true 81 | - name: Run tests 82 | run: bundle exec rake 83 | test_rails_8: 84 | runs-on: ubuntu-latest 85 | strategy: 86 | matrix: 87 | gemfile: [ 'Gemfile.rails-8.0' ] 88 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 89 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 90 | steps: 91 | - uses: actions/checkout@v4 92 | - name: Set up Ruby 93 | uses: ruby/setup-ruby@v1 94 | with: 95 | ruby-version: 3.2 96 | bundler-cache: true 97 | - name: Run tests 98 | run: bundle exec rake 99 | -------------------------------------------------------------------------------- /.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 | .idea/ 16 | .vscode/ 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format progress 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Next Release 2 | 3 | ## 8.0.8 (05/30/2025) 4 | 5 | * Add middleware after initializers 6 | * Fix bullet composite primary key retrieval 7 | 8 | ## 8.0.7 (05/15/2025) 9 | 10 | * Try to insert `Bullet::Rack` properly 11 | 12 | ## 8.0.6 (05/07/2025) 13 | 14 | * Add CSP nonce for footer styles as well 15 | * Add support for OpenTelemetry reporting 16 | 17 | ## 8.0.5 (04/21/2025) 18 | 19 | * Properly insert ContentSecurityPolicy middleware 20 | * Properly parse query string 21 | 22 | ## 8.0.4 (04/18/2025) 23 | 24 | * Insert bullet middleware before `ContentSecurityPolicy` 25 | * Support url query `skip_html_injection=true` 26 | * Mark object as impossible after updating inversed 27 | 28 | ## 8.0.3 (04/04/2025) 29 | 30 | * Update non persisted `inversed_objects` 31 | 32 | ## 8.0.2 (04/02/2025) 33 | 34 | * Do not cache `bullet_key` if object is not persisted 35 | 36 | ## 8.0.1 (02/10/2025) 37 | 38 | * Update benchmark to use sqlite 39 | * Reduce mem allocations 40 | * Require active_support's inflections module before requiring the delegation module 41 | 42 | ## 8.0.0 (11/10/2024) 43 | 44 | * Support Rails 8 45 | * Drop Rails 4.0 and 4.1 support 46 | * Require Ruby at least 2.7.0 47 | * Store global objects into thread-local variables 48 | * Avoid globally polluting `::String` and `::Object` classes 49 | 50 | ## 7.2.0 (07/12/2024) 51 | 52 | * Support Rails 7.2 53 | * Fix count method signature for active_record5 and active_record60 54 | 55 | ## 7.1.6 (01/16/2024) 56 | 57 | * Allow apps to not include the user in a notification 58 | 59 | ## 7.1.5 (01/05/2024) 60 | 61 | * Fix mongoid8 62 | 63 | ## 7.1.4 (11/17/2023) 64 | 65 | * Call association also on through reflection 66 | 67 | ## 7.1.3 (11/05/2023) 68 | 69 | * Call NPlusOneQuery's call_association when calling count on collection association 70 | 71 | ## 7.1.2 (10/13/2023) 72 | 73 | * Handle Rails 7.1 composite primary keys 74 | 75 | ## 7.1.1 (10/07/2023) 76 | 77 | * Add support for `Content-Security-Policy-Report-Only` nonces 78 | * Fix count method signature 79 | 80 | ## 7.1.0 (10/06/2023) 81 | 82 | * Support rails 7.1 83 | * Alias `Bullet.enable?` to `enabled?`, and `Bullet.enable=` to `enabled=` 84 | * Added `always_append_html_body` option, so the html snippet is always included even if there are no notifications 85 | * Added detection of n+1 count queries from `count` method 86 | * Changed the counter cache notification title to recommend using `size` 87 | 88 | ## 7.0.7 (03/01/2023) 89 | 90 | * Check `Rails.application.config.content_security_policy` before insert `Bullet::Rack` 91 | 92 | ## 7.0.6 (03/01/2023) 93 | 94 | * Better way to check if `ActionDispatch::ContentSecurityPolicy::Middleware` exists 95 | 96 | ## 7.0.5 (01/01/2023) 97 | 98 | * Fix n+1 false positives in AR 7.0 99 | * Fix eager_load nested has_many :through false positives 100 | * Respect Content-Security-Policy nonces 101 | * Added CallStacks support for avoid eager loading 102 | * Iterate fewer times over objects 103 | 104 | ## 7.0.4 (11/28/2022) 105 | 106 | * Fix `eager_load` `has_many :through` false positives 107 | * mongoid7x: add dynamic methods 108 | 109 | ## 7.0.3 (08/13/2022) 110 | 111 | * Replace `Array()` with `Array.wrap()` 112 | 113 | ## 7.0.2 (05/31/2022) 114 | 115 | * Drop growl support 116 | * Do not check html tag in Bullet::Rack anymore 117 | 118 | ## 7.0.1 (01/15/2022) 119 | 120 | * Get rid of *_whitelist methods 121 | * Hack ActiveRecord::Associations::Preloader::Batch in rails 7 122 | 123 | ## 7.0.0 (12/18/2021) 124 | 125 | * Support rails 7 126 | * Fix Mongoid 7 view iteration 127 | * Move CI from Travis to Github Actions 128 | 129 | ## 6.1.5 (08/16/2021) 130 | 131 | * Rename whitelist to safelist 132 | * Fix onload called twice 133 | * Support Rack::Files::Iterator responses 134 | * Ensure HABTM associations are not incorrectly labeled n+1 135 | 136 | ## 6.1.4 (02/26/2021) 137 | 138 | * Added an option to stop adding HTTP headers to API requests 139 | 140 | ## 6.1.3 (01/21/2021) 141 | 142 | * Consider ThroughAssociation at SingularAssociation like CollectionAssociation 143 | * Add xhr_script only when add_footer is enabled 144 | 145 | ## 6.1.2 (12/12/2020) 146 | 147 | * Revert "Make whitelist thread safe" 148 | 149 | ## 6.1.1 (12/12/2020) 150 | 151 | * Add support Rails 6.1 152 | * Make whitelist thread safe 153 | 154 | ## 6.1.0 (12/28/2019) 155 | 156 | * Add skip_html_injection flag 157 | * Remove writer hack in active_record6 158 | * Use modern includes syntax in warnings 159 | * Fix warning: The last argument is used as the keyword parameter 160 | 161 | ## 6.0.2 (08/20/2019) 162 | 163 | * Fully support Rails 6.0 164 | 165 | ## 6.0.1 (06/26/2019) 166 | 167 | * Add Bullet::ActiveJob 168 | * Prevent "Maximum call stack exceeded" errors when used with Turbolinks 169 | 170 | ## 6.0.0 (04/25/2019) 171 | 172 | * Add XHR support to Bullet 173 | * Support Rails 6.0 174 | * Handle case where ID is manually set on unpersisted record 175 | 176 | ## 5.9.0 (11/11/2018) 177 | 178 | * Require Ruby 2.3+ 179 | * Support Mongo 7.x 180 | 181 | ## 5.8.0 (10/29/2018) 182 | 183 | * Fix through reflection for rails 5.x 184 | * Fix false positive in after_save/after_create callbacks 185 | * Don't trigger a preload error on "manual" preloads 186 | * Avoid Bullet from making extra queries in mongoid6 187 | * Support option for #first and #last on mongoid6.x 188 | * Fix duplicate logs in mongoid 4.x and 5.x version 189 | * Use caller for ruby 1.9 while caller_locations for 2.0+ 190 | * Extend stacktrace matching for sub-file precision 191 | * Exclude configured bundler path in addition to '/vendor' 192 | * Fix `caller_path` in `excluded_stacktrace_path` 193 | * Update `uniform_notifier` dependency to add Sentry support 194 | * Integrate awesomecode.io and refactor code 195 | 196 | ## 5.7.0 (12/03/2017) 197 | 198 | * Support rails 5.2 199 | * Implement Bullet.delete_whitelist to delete a specific whitelist definition 200 | * Fix caller_path in the case of nil 201 | 202 | ## 5.6.0 (07/16/2017) 203 | 204 | * Migrate alias_method to Module#prepend 205 | * Add install generator 206 | * Stack trace filter 207 | * Fix rails 5.1 compatibility 208 | * Fix inverse_of for rails 5 209 | * Fix detect file attachment for rack #319 210 | 211 | ## 5.5.0 (12/30/2016) 212 | 213 | * Display http request method #311 214 | * Add close button to footer 215 | * Raise an error if bullet does not support AR or Mongoid 216 | * Avoid double backtrace 217 | * Fix false alert on counter cache when associations are already loaded #288 218 | * Fix "false alert" in rails 5 #239 219 | * Do not support ActiveRecord 3.x and Mongoid 3.x / 4.x anymore 220 | 221 | ## 5.4.0 (10/09/2016) 222 | 223 | * Support rails 5.1 224 | * Extract stack trace filtering into module 225 | 226 | ## 5.3.0 (15/08/2016) 227 | 228 | * Fix false alert on through association with join sql #301 229 | * Fix association.target in `through_association` can be singular #302 230 | * Support `find_by_sql` #303 231 | * Fix env `REQUEST_URI` 232 | 233 | ## 5.2.0 (07/26/2016) 234 | 235 | * Fix `has_cached_counter?` is not defined in HABTM #297 236 | * Fix false alert if preloaded association has no records #260 237 | * Support Rails 5.0.0 238 | 239 | ## 5.1.0 (05/21/2016) 240 | 241 | * Fix false alert when `empty?` used with `counter_cache` 242 | * Fix `alias_method_chain` deprecation for rails 5 243 | * Add response handling for non-Rails Rack responses 244 | * Fix false alert when querying immediately after creation 245 | * Fix UnusedEagerLoading bug when multiple eager loading query include same objects 246 | 247 | ## 5.0.0 (01/06/2016) 248 | 249 | * Support Rails 5.0.0.beta1 250 | * Fix `has_many :through` infinite loop issue 251 | * Support mongoid 5.0.0 252 | * Do not report association queries immediately after object creation to 253 | require a preload 254 | * Detect `counter_cache` for `has_many :through` association 255 | * Compatible with `composite_primary_keys` gem 256 | * Fix AR 4.2 SingularAssociation#reader result can be nil 257 | * `perform_out_of_channel_notifications` should always be triggered 258 | * Fix false positive with `belongs_to` -> `belongs_to` for active\_record 4.2 259 | * Activate active\_record hacks only when Bullet already start 260 | * Don't execute query when running `to_sql` 261 | * Send backtrace to `uniform_notifier` 262 | * Fix sse response check 263 | * Dynamically delegate available notifiers to UniformNotifier 264 | * Hotfix nil object when `add_impossible_object` 265 | * Fix `has_one` then `has_many` associations in rails 4.2 266 | * Append js and dom to html body in proper position 267 | 268 | ## 4.14.0 (10/03/2014) 269 | 270 | * Support rails 4.2 271 | * Polish notification output 272 | * Fix warning: `*' interpreted as argument prefix 273 | 274 | ## 4.13.0 (07/19/2014) 275 | 276 | * Support include? call on ar associations 277 | 278 | ## 4.12.0 (07/13/2014) 279 | 280 | * Fix false n+1 queries caused by inversed objects. 281 | * Replace .id with .primary_key_value 282 | * Rename bullet_ar_key to bullet_key 283 | * Fix rails sse detect 284 | * Fix bullet using in test environment 285 | * Memoize whoami 286 | 287 | ## 4.11.0 (06/24/2014) 288 | 289 | * Support empty? call on ar associations 290 | * Skip detecting if object is a new record 291 | 292 | ## 4.10.0 (06/06/2014) 293 | 294 | * Handle join query smarter 295 | * Support mongoid 4.0 296 | * Thread safe 297 | * Add debug mode 298 | 299 | ## 4.9.0 (04/30/2014) 300 | 301 | * Add Bullet.stacktrace_includes option 302 | * Applied keyword argument fixes on Ruby 2.2.0 303 | * Add bugsnag notifier 304 | * Support rails 4.1.0 305 | 306 | ## 4.8.0 (02/16/2014) 307 | 308 | * Support rails 4.1.0.beta1 309 | * Update specs to be RSpec 3.0 compatible 310 | * Update latest minor version activerecord and mongoid on travis 311 | 312 | ## 4.7.0 (11/03/2013) 313 | 314 | * Add coverall support 315 | * Add helper to profile code outside a request 316 | * Add activesupport dependency 317 | * Add Bullet.raise notification 318 | * Add Bullet.add_footer notification 319 | * Fix activerecord4 warnings in test code 320 | 321 | ## 4.6.0 (04/18/2013) 322 | 323 | * Fix Bullet::Rack to support sinatra 324 | 325 | ## 4.5.0 (03/24/2013) 326 | 327 | * Add api way to access captured association 328 | * Allow disable n_plus_one_query, unused_eager_loading and counter_cache respectively 329 | * Add whitelist 330 | 331 | ## 4.4.0 (03/15/2013) 332 | 333 | * Remove disable_browser_cache option 334 | * Compatible with Rails 4.0.0.beta1 335 | 336 | ## 4.3.0 (12/28/2012) 337 | 338 | * Fix content-length for non ascii html 339 | * Add mongoid 2.5.x support 340 | 341 | ## 4.2.0 (09/29/2012) 342 | 343 | * Add Bullet::Dependency to check AR and mongoid version 344 | * Add Rails 4 support 345 | * Add airbrake notifier support 346 | 347 | ## 4.1.0 (05/30/2012) 348 | 349 | * Add mongoid 3 support 350 | 351 | ## 4.0.0 (05/09/2012) 352 | 353 | * Add mongoid support 354 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) do |repo_name| 4 | repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/') 5 | "https://github.com/#{repo_name}.git" 6 | end 7 | 8 | gemspec 9 | 10 | gem 'rails', github: 'rails' 11 | gem 'sqlite3', platforms: [:ruby] 12 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 13 | gem 'activerecord-import' 14 | 15 | gem 'rspec' 16 | gem 'guard' 17 | gem 'guard-rspec' 18 | 19 | gem 'coveralls', require: false 20 | 21 | platforms :rbx do 22 | gem 'rubysl', '~> 2.0' 23 | gem 'rubinius-developer_tools' 24 | end 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /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.1.0' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.mongoid-6.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 5.0.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 6.0.0' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.mongoid-7.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 5.0' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 7.0.0' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.mongoid-8.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 6.1' 6 | gem 'sqlite3', platforms: [:ruby] 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 8.0' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.mongoid-9.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 8.0' 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'mongoid', '~> 9.0' 9 | gem 'rspec' 10 | 11 | 12 | -------------------------------------------------------------------------------- /Gemfile.rails-4.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 4.2.0' 6 | gem 'sqlite3', '~> 1.3.6' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | gem 'tins', '~> 1.6.0', platforms: [:ruby_19] 10 | 11 | gem 'bigdecimal', '~> 1.4' 12 | 13 | gem "rspec" 14 | 15 | platforms :rbx do 16 | gem 'rubysl', '~> 2.0' 17 | gem 'rubinius-developer_tools' 18 | end 19 | -------------------------------------------------------------------------------- /Gemfile.rails-5.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 5.0.0' 6 | gem 'sqlite3', '~> 1.3.6' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.rails-5.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 5.1.0' 6 | gem 'sqlite3', '~> 1.3.6' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.rails-5.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 5.2.0' 6 | gem 'sqlite3', '~> 1.3.6' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.rails-6.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 6.0.0' 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.rails-6.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 6.1.0' 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | 12 | platforms :rbx do 13 | gem 'rubysl', '~> 2.0' 14 | gem 'rubinius-developer_tools' 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.rails-7.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 7.0.0' 6 | gem 'sqlite3', '~> 1.4' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | -------------------------------------------------------------------------------- /Gemfile.rails-7.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails', '~> 7.1.0' 6 | gem 'sqlite3', '~> 1.4' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | -------------------------------------------------------------------------------- /Gemfile.rails-7.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rails", ">= 7.2.0.beta2", "< 7.3" 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | -------------------------------------------------------------------------------- /Gemfile.rails-8.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rails", ">= 8.0.0.beta1", "< 8.1" 6 | gem 'sqlite3' 7 | gem 'activerecord-jdbcsqlite3-adapter', platforms: [:jruby] 8 | gem 'activerecord-import' 9 | 10 | gem "rspec" 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 27 | 1. Bullet.start_request is called, which resets all the detectors and empties 28 | the notification collector 29 | 2. The request is handled by Rails, and the installed ActiveRecord extensions 30 | trigger Detector callbacks 31 | 3. Detectors once called, will determine whether something noteworthy happened. 32 | If yes, then a Notification is created and stored in the notification collector. 33 | 4. Rails finishes handling the request 34 | 5. For each notification in the collector, Bullet will iterate over each 35 | Presenter and will try to generate an inline message that will be appended to 36 | the generated response body. 37 | 6. The response is returned to the client. 38 | 7. Bullet will try to generate an out-of-channel message for each notification. 39 | 8. Bullet calls end_request for each detector. 40 | 9. Goto 1. 41 | 42 | ## Adding Notification Types 43 | 44 | If you want to add more kinds of things that Bullet can detect, a little more 45 | work is needed than if you were just adding a Presenter, but the concepts are 46 | similar. 47 | 48 | * Add the class to the DETECTORS constant in the main Bullet module 49 | * Add (if needed) Rails monkey patches to Bullet.enable 50 | * Add an autoload directive to lib/bullet/detector.rb 51 | * Create a corresponding notification class in the Bullet::Notification namespace 52 | * Add an autoload directive to lib/bullet/notification.rb 53 | 54 | As a rule of thumb, you can assume that each Detector will have its own 55 | Notification class. If you follow the principle of Separation of Concerns I 56 | can't really think of an example where one would deviate from this rule. 57 | 58 | Since the detection of pathological associations is a bit hairy, I'd recommend 59 | having a look at the counter cache detector and associated notification to get 60 | a feel for what is needed to get off the ground. 61 | 62 | ### Detectors 63 | 64 | The only things you'll need to consider when building your Detector class is 65 | that it will need to supply the .start_request, .end_request and .clear class 66 | methods. 67 | 68 | Simple implementations are provided by Bullet::Detector::Base for start_request 69 | and end_request, you will have to supply your own clear method. 70 | 71 | ### Notifications 72 | 73 | For notifications you will want to supply a #title and #body instance method, 74 | and check to see if the #initialize and #full_notice methods in the 75 | Bullet::Notification::Base class fit your needs. 76 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 - 2024 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('lib', __dir__) 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 | begin 38 | require 'rdoc/task' 39 | 40 | desc 'Generate documentation for the plugin.' 41 | Rake::RDocTask.new do |rdoc| 42 | rdoc.rdoc_dir = 'rdoc' 43 | rdoc.title = "bullet #{Bullet::VERSION}" 44 | rdoc.rdoc_files.include('README*') 45 | rdoc.rdoc_files.include('lib/**/*.rb') 46 | end 47 | rescue LoadError 48 | puts 'RDocTask is not supported for this platform' 49 | end 50 | 51 | task default: :spec 52 | -------------------------------------------------------------------------------- /bullet.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 5 | 6 | require 'bullet/version' 7 | 8 | Gem::Specification.new do |s| 9 | s.name = 'bullet' 10 | s.version = Bullet::VERSION 11 | s.platform = Gem::Platform::RUBY 12 | s.authors = ['Richard Huang'] 13 | s.email = ['flyerhzm@gmail.com'] 14 | s.homepage = 'https://github.com/flyerhzm/bullet' 15 | s.summary = 'help to kill N+1 queries and unused eager loading.' 16 | s.description = 'help to kill N+1 queries and unused eager loading.' 17 | s.metadata = { 18 | 'changelog_uri' => 'https://github.com/flyerhzm/bullet/blob/main/CHANGELOG.md', 19 | 'source_code_uri' => 'https://github.com/flyerhzm/bullet' 20 | } 21 | 22 | s.license = 'MIT' 23 | 24 | s.required_ruby_version = '>= 2.7.0' 25 | s.required_rubygems_version = '>= 1.3.6' 26 | 27 | s.add_runtime_dependency 'activesupport', '>= 3.0.0' 28 | s.add_runtime_dependency 'uniform_notifier', '~> 1.11' 29 | 30 | s.files = Dir.chdir(__dir__) do 31 | `git ls-files -z`.split("\x0").reject do |file| 32 | file.start_with?(*%w[.git .rspec Gemfile Guardfile Hacking Rakefile 33 | bullet.gemspec perf rails spec test.sh update.sh]) 34 | end 35 | end 36 | s.require_paths = ['lib'] 37 | end 38 | -------------------------------------------------------------------------------- /lib/bullet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | require 'active_support/core_ext/module/delegation' 5 | require 'set' 6 | require 'uniform_notifier' 7 | require 'bullet/ext/object' 8 | require 'bullet/ext/string' 9 | require 'bullet/dependency' 10 | require 'bullet/stack_trace_filter' 11 | 12 | module Bullet 13 | extend Dependency 14 | 15 | autoload :ActiveRecord, "bullet/#{active_record_version}" if active_record? 16 | autoload :Mongoid, "bullet/#{mongoid_version}" if mongoid? 17 | autoload :Rack, 'bullet/rack' 18 | autoload :ActiveJob, 'bullet/active_job' 19 | autoload :Notification, 'bullet/notification' 20 | autoload :Detector, 'bullet/detector' 21 | autoload :Registry, 'bullet/registry' 22 | autoload :NotificationCollector, 'bullet/notification_collector' 23 | 24 | if defined?(Rails::Railtie) 25 | class BulletRailtie < Rails::Railtie 26 | initializer 'bullet.add_middleware', after: :load_config_initializers do |app| 27 | if defined?(ActionDispatch::ContentSecurityPolicy::Middleware) && Rails.application.config.content_security_policy && !app.config.api_only 28 | app.middleware.insert_before ActionDispatch::ContentSecurityPolicy::Middleware, Bullet::Rack 29 | else 30 | app.middleware.use Bullet::Rack 31 | end 32 | end 33 | end 34 | end 35 | 36 | class << self 37 | attr_writer :n_plus_one_query_enable, 38 | :unused_eager_loading_enable, 39 | :counter_cache_enable, 40 | :stacktrace_includes, 41 | :stacktrace_excludes, 42 | :skip_html_injection 43 | attr_reader :safelist 44 | attr_accessor :add_footer, 45 | :orm_patches_applied, 46 | :skip_http_headers, 47 | :always_append_html_body, 48 | :skip_user_in_notification 49 | 50 | available_notifiers = 51 | UniformNotifier::AVAILABLE_NOTIFIERS.select { |notifier| notifier != :raise } 52 | .map { |notifier| "#{notifier}=" } 53 | available_notifiers_options = { to: UniformNotifier } 54 | delegate(*available_notifiers, **available_notifiers_options) 55 | 56 | def raise=(should_raise) 57 | UniformNotifier.raise = (should_raise ? Notification::UnoptimizedQueryError : false) 58 | end 59 | 60 | DETECTORS = [ 61 | Bullet::Detector::NPlusOneQuery, 62 | Bullet::Detector::UnusedEagerLoading, 63 | Bullet::Detector::CounterCache 64 | ].freeze 65 | 66 | def enable=(enable) 67 | @enable = enable 68 | 69 | if enable? 70 | reset_safelist 71 | unless orm_patches_applied 72 | self.orm_patches_applied = true 73 | Bullet::Mongoid.enable if mongoid? 74 | Bullet::ActiveRecord.enable if active_record? 75 | end 76 | end 77 | end 78 | 79 | alias enabled= enable= 80 | 81 | def enable? 82 | !!@enable 83 | end 84 | 85 | alias enabled? enable? 86 | 87 | # Rails.root might be nil if `railties` is a dependency on a project that does not use Rails 88 | def app_root 89 | @app_root ||= (defined?(::Rails.root) && !::Rails.root.nil? ? Rails.root.to_s : Dir.pwd).to_s 90 | end 91 | 92 | def n_plus_one_query_enable? 93 | enable? && (@n_plus_one_query_enable.nil? ? true : @n_plus_one_query_enable) 94 | end 95 | 96 | def unused_eager_loading_enable? 97 | enable? && (@unused_eager_loading_enable.nil? ? true : @unused_eager_loading_enable) 98 | end 99 | 100 | def counter_cache_enable? 101 | enable? && (@counter_cache_enable.nil? ? true : @counter_cache_enable) 102 | end 103 | 104 | def stacktrace_includes 105 | @stacktrace_includes ||= [] 106 | end 107 | 108 | def stacktrace_excludes 109 | @stacktrace_excludes ||= [] 110 | end 111 | 112 | def add_safelist(options) 113 | reset_safelist 114 | @safelist[options[:type]][options[:class_name]] ||= [] 115 | @safelist[options[:type]][options[:class_name]] << options[:association].to_sym 116 | end 117 | 118 | def delete_safelist(options) 119 | reset_safelist 120 | @safelist[options[:type]][options[:class_name]] ||= [] 121 | @safelist[options[:type]][options[:class_name]].delete(options[:association].to_sym) 122 | @safelist[options[:type]].delete_if { |_key, val| val.empty? } 123 | end 124 | 125 | def get_safelist_associations(type, class_name) 126 | Array.wrap(@safelist[type][class_name]) 127 | end 128 | 129 | def reset_safelist 130 | @safelist ||= { n_plus_one_query: {}, unused_eager_loading: {}, counter_cache: {} } 131 | end 132 | 133 | def clear_safelist 134 | @safelist = nil 135 | end 136 | 137 | def bullet_logger=(active) 138 | if active 139 | require 'fileutils' 140 | FileUtils.mkdir_p(app_root + '/log') 141 | bullet_log_file = File.open("#{app_root}/log/bullet.log", 'a+') 142 | bullet_log_file.sync = true 143 | UniformNotifier.customized_logger = bullet_log_file 144 | end 145 | end 146 | 147 | def debug(title, message) 148 | puts "[Bullet][#{title}] #{message}" if ENV['BULLET_DEBUG'] == 'true' 149 | end 150 | 151 | def start_request 152 | Thread.current.thread_variable_set(:bullet_start, true) 153 | Thread.current.thread_variable_set(:bullet_notification_collector, Bullet::NotificationCollector.new) 154 | 155 | Thread.current.thread_variable_set(:bullet_object_associations, Bullet::Registry::Base.new) 156 | Thread.current.thread_variable_set(:bullet_call_object_associations, Bullet::Registry::Base.new) 157 | Thread.current.thread_variable_set(:bullet_possible_objects, Bullet::Registry::Object.new) 158 | Thread.current.thread_variable_set(:bullet_impossible_objects, Bullet::Registry::Object.new) 159 | Thread.current.thread_variable_set(:bullet_inversed_objects, Bullet::Registry::Base.new) 160 | Thread.current.thread_variable_set(:bullet_eager_loadings, Bullet::Registry::Association.new) 161 | Thread.current.thread_variable_set(:bullet_call_stacks, Bullet::Registry::CallStack.new) 162 | 163 | unless Thread.current.thread_variable_get(:bullet_counter_possible_objects) 164 | Thread.current.thread_variable_set(:bullet_counter_possible_objects, Bullet::Registry::Object.new) 165 | end 166 | 167 | unless Thread.current.thread_variable_get(:bullet_counter_impossible_objects) 168 | Thread.current.thread_variable_set(:bullet_counter_impossible_objects, Bullet::Registry::Object.new) 169 | end 170 | end 171 | 172 | def end_request 173 | Thread.current.thread_variable_set(:bullet_start, nil) 174 | Thread.current.thread_variable_set(:bullet_notification_collector, nil) 175 | 176 | Thread.current.thread_variable_set(:bullet_object_associations, nil) 177 | Thread.current.thread_variable_set(:bullet_call_object_associations, nil) 178 | Thread.current.thread_variable_set(:bullet_possible_objects, nil) 179 | Thread.current.thread_variable_set(:bullet_impossible_objects, nil) 180 | Thread.current.thread_variable_set(:bullet_inversed_objects, nil) 181 | Thread.current.thread_variable_set(:bullet_eager_loadings, nil) 182 | 183 | Thread.current.thread_variable_set(:bullet_counter_possible_objects, nil) 184 | Thread.current.thread_variable_set(:bullet_counter_impossible_objects, nil) 185 | end 186 | 187 | def start? 188 | enable? && Thread.current.thread_variable_get(:bullet_start) 189 | end 190 | 191 | def notification_collector 192 | Thread.current.thread_variable_get(:bullet_notification_collector) 193 | end 194 | 195 | def notification? 196 | return unless start? 197 | 198 | Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations 199 | notification_collector.notifications_present? 200 | end 201 | 202 | def gather_inline_notifications 203 | responses = [] 204 | for_each_active_notifier_with_notification { |notification| responses << notification.notify_inline } 205 | responses.join("\n") 206 | end 207 | 208 | def perform_out_of_channel_notifications(env = {}) 209 | request_uri = build_request_uri(env) 210 | for_each_active_notifier_with_notification do |notification| 211 | notification.url = request_uri 212 | notification.notify_out_of_channel 213 | end 214 | end 215 | 216 | def footer_info 217 | info = [] 218 | notification_collector.collection.each { |notification| info << notification.short_notice } 219 | info 220 | end 221 | 222 | def text_notifications 223 | info = [] 224 | notification_collector.collection.each do |notification| 225 | info << notification.notification_data.values.compact.join("\n") 226 | end 227 | info 228 | end 229 | 230 | def warnings 231 | notification_collector.collection.each_with_object({}) do |notification, warnings| 232 | warning_type = notification.class.to_s.split(':').last.tableize 233 | warnings[warning_type] ||= [] 234 | warnings[warning_type] << notification 235 | end 236 | end 237 | 238 | def profile 239 | return_value = nil 240 | 241 | if Bullet.enable? 242 | begin 243 | Bullet.start_request 244 | 245 | return_value = yield 246 | 247 | Bullet.perform_out_of_channel_notifications if Bullet.notification? 248 | ensure 249 | Bullet.end_request 250 | end 251 | else 252 | return_value = yield 253 | end 254 | 255 | return_value 256 | end 257 | 258 | def console_enabled? 259 | UniformNotifier.active_notifiers.include?(UniformNotifier::JavascriptConsole) 260 | end 261 | 262 | def inject_into_page? 263 | return false if defined?(@skip_html_injection) && @skip_html_injection 264 | 265 | console_enabled? || add_footer 266 | end 267 | 268 | private 269 | 270 | def for_each_active_notifier_with_notification 271 | UniformNotifier.active_notifiers.each do |notifier| 272 | notification_collector.collection.each do |notification| 273 | notification.notifier = notifier 274 | yield notification 275 | end 276 | end 277 | end 278 | 279 | def build_request_uri(env) 280 | return "#{env['REQUEST_METHOD']} #{env['REQUEST_URI']}" if env['REQUEST_URI'] 281 | 282 | if env['QUERY_STRING'].present? 283 | "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}?#{env['QUERY_STRING']}" 284 | else 285 | "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}" 286 | end 287 | end 288 | end 289 | end 290 | -------------------------------------------------------------------------------- /lib/bullet/active_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module ActiveJob 5 | def self.included(base) 6 | base.class_eval do 7 | around_perform do |_job, block| 8 | Bullet.profile { block.call } 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/bullet/active_record4.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module ActiveRecord 5 | def self.enable 6 | require 'active_record' 7 | ::ActiveRecord::Base.class_eval do 8 | class << self 9 | alias_method :origin_find_by_sql, :find_by_sql 10 | def find_by_sql(sql, binds = []) 11 | result = origin_find_by_sql(sql, binds) 12 | if Bullet.start? 13 | if result.is_a? Array 14 | if result.size > 1 15 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 16 | Bullet::Detector::CounterCache.add_possible_objects(result) 17 | elsif result.size == 1 18 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first) 19 | Bullet::Detector::CounterCache.add_impossible_object(result.first) 20 | end 21 | elsif result.is_a? ::ActiveRecord::Base 22 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) 23 | Bullet::Detector::CounterCache.add_impossible_object(result) 24 | end 25 | end 26 | result 27 | end 28 | end 29 | end 30 | 31 | ::ActiveRecord::Relation.class_eval do 32 | alias_method :origin_to_a, :to_a 33 | # if select a collection of objects, then these objects have possible to cause N+1 query. 34 | # if select only one object, then the only one object has impossible to cause N+1 query. 35 | def to_a 36 | records = origin_to_a 37 | if Bullet.start? 38 | if records.size > 1 39 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 40 | Bullet::Detector::CounterCache.add_possible_objects(records) 41 | elsif records.size == 1 42 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 43 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 44 | end 45 | end 46 | records 47 | end 48 | end 49 | 50 | ::ActiveRecord::Persistence.class_eval do 51 | def _create_record_with_bullet(*args) 52 | _create_record_without_bullet(*args).tap do 53 | Bullet::Detector::NPlusOneQuery.update_inversed_object(self) 54 | Bullet::Detector::NPlusOneQuery.add_impossible_object(self) 55 | end 56 | end 57 | alias_method_chain :_create_record, :bullet 58 | end 59 | 60 | ::ActiveRecord::Associations::Preloader.class_eval do 61 | # include query for one to many associations. 62 | # keep this eager loadings. 63 | alias_method :origin_initialize, :initialize 64 | def initialize(records, associations, preload_scope = nil) 65 | origin_initialize(records, associations, preload_scope) 66 | 67 | if Bullet.start? 68 | records = [records].flatten.compact.uniq 69 | return if records.empty? 70 | 71 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, associations) } 72 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 73 | end 74 | end 75 | end 76 | 77 | ::ActiveRecord::FinderMethods.class_eval do 78 | # add includes in scope 79 | alias_method :origin_find_with_associations, :find_with_associations 80 | def find_with_associations 81 | records = origin_find_with_associations 82 | if Bullet.start? 83 | associations = (eager_load_values + includes_values).uniq 84 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, associations) } 85 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 86 | end 87 | records 88 | end 89 | end 90 | 91 | ::ActiveRecord::Associations::JoinDependency.class_eval do 92 | alias_method :origin_instantiate, :instantiate 93 | alias_method :origin_construct_association, :construct_association 94 | 95 | def instantiate(rows) 96 | @bullet_eager_loadings = {} 97 | records = origin_instantiate(rows) 98 | 99 | if Bullet.start? 100 | @bullet_eager_loadings.each do |_klazz, eager_loadings_hash| 101 | objects = eager_loadings_hash.keys 102 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 103 | end 104 | end 105 | records 106 | end 107 | 108 | # call join associations 109 | def construct_association(record, join, row) 110 | result = origin_construct_association(record, join, row) 111 | 112 | if Bullet.start? 113 | associations = [join.reflection.name] 114 | if join.reflection.nested? 115 | associations << join.reflection.through_reflection.name 116 | end 117 | associations.each do |association| 118 | Bullet::Detector::Association.add_object_associations(record, association) 119 | Bullet::Detector::NPlusOneQuery.call_association(record, association) 120 | @bullet_eager_loadings[record.class] ||= {} 121 | @bullet_eager_loadings[record.class][record] ||= Set.new 122 | @bullet_eager_loadings[record.class][record] << association 123 | end 124 | end 125 | 126 | result 127 | end 128 | end 129 | 130 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 131 | # call one to many associations 132 | alias_method :origin_load_target, :load_target 133 | def load_target 134 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) if Bullet.start? 135 | origin_load_target 136 | end 137 | 138 | alias_method :origin_include?, :include? 139 | def include?(object) 140 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) if Bullet.start? 141 | origin_include?(object) 142 | end 143 | end 144 | 145 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 146 | alias_method :origin_empty?, :empty? 147 | def empty? 148 | if Bullet.start? && !loaded? && !has_cached_counter?(@reflection) 149 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 150 | end 151 | origin_empty? 152 | end 153 | end 154 | 155 | ::ActiveRecord::Associations::HasAndBelongsToManyAssociation.class_eval do 156 | alias_method :origin_empty?, :empty? 157 | def empty? 158 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) if Bullet.start? && !loaded? 159 | origin_empty? 160 | end 161 | end 162 | 163 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 164 | # call has_one and belongs_to associations 165 | alias_method :origin_reader, :reader 166 | def reader(force_reload = false) 167 | result = origin_reader(force_reload) 168 | if Bullet.start? 169 | unless @inversed 170 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 171 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 172 | end 173 | end 174 | result 175 | end 176 | end 177 | 178 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 179 | alias_method :origin_has_cached_counter?, :has_cached_counter? 180 | 181 | def has_cached_counter?(reflection = reflection()) 182 | result = origin_has_cached_counter?(reflection) 183 | Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name) if Bullet.start? && !result 184 | result 185 | end 186 | end 187 | 188 | ::ActiveRecord::Associations::CollectionProxy.class_eval do 189 | def count(column_name = nil, options = {}) 190 | if Bullet.start? 191 | Bullet::Detector::CounterCache.add_counter_cache(proxy_association.owner, proxy_association.reflection.name) 192 | Bullet::Detector::NPlusOneQuery.call_association(proxy_association.owner, proxy_association.reflection.name) 193 | end 194 | super(column_name, options) 195 | end 196 | end 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/bullet/active_record41.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module ActiveRecord 5 | def self.enable 6 | require 'active_record' 7 | ::ActiveRecord::Base.class_eval do 8 | class << self 9 | alias_method :origin_find_by_sql, :find_by_sql 10 | def find_by_sql(sql, binds = []) 11 | result = origin_find_by_sql(sql, binds) 12 | if Bullet.start? 13 | if result.is_a? Array 14 | if result.size > 1 15 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 16 | Bullet::Detector::CounterCache.add_possible_objects(result) 17 | elsif result.size == 1 18 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first) 19 | Bullet::Detector::CounterCache.add_impossible_object(result.first) 20 | end 21 | elsif result.is_a? ::ActiveRecord::Base 22 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) 23 | Bullet::Detector::CounterCache.add_impossible_object(result) 24 | end 25 | end 26 | result 27 | end 28 | end 29 | end 30 | 31 | ::ActiveRecord::Relation.class_eval do 32 | alias_method :origin_to_a, :to_a 33 | 34 | # if select a collection of objects, then these objects have possible to cause N+1 query. 35 | # if select only one object, then the only one object has impossible to cause N+1 query. 36 | def to_a 37 | records = origin_to_a 38 | if Bullet.start? 39 | if records.first.class.name !~ /^HABTM_/ 40 | if records.size > 1 41 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 42 | Bullet::Detector::CounterCache.add_possible_objects(records) 43 | elsif records.size == 1 44 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 45 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 46 | end 47 | end 48 | end 49 | records 50 | end 51 | end 52 | 53 | ::ActiveRecord::Persistence.class_eval do 54 | def _create_record_with_bullet(*args) 55 | _create_record_without_bullet(*args).tap do 56 | Bullet::Detector::NPlusOneQuery.update_inversed_object(self) 57 | Bullet::Detector::NPlusOneQuery.add_impossible_object(self) 58 | end 59 | end 60 | alias_method_chain :_create_record, :bullet 61 | end 62 | 63 | ::ActiveRecord::Associations::Preloader.class_eval do 64 | alias_method :origin_preloaders_on, :preloaders_on 65 | 66 | def preloaders_on(association, records, scope) 67 | if Bullet.start? 68 | records.compact! 69 | if records.first.class.name !~ /^HABTM_/ 70 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, association) } 71 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, association) 72 | end 73 | end 74 | origin_preloaders_on(association, records, scope) 75 | end 76 | end 77 | 78 | ::ActiveRecord::FinderMethods.class_eval do 79 | # add includes in scope 80 | alias_method :origin_find_with_associations, :find_with_associations 81 | def find_with_associations 82 | return origin_find_with_associations { |r| yield r } if block_given? 83 | 84 | records = origin_find_with_associations 85 | if Bullet.start? 86 | associations = (eager_load_values + includes_values).uniq 87 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, associations) } 88 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 89 | end 90 | records 91 | end 92 | end 93 | 94 | ::ActiveRecord::Associations::JoinDependency.class_eval do 95 | alias_method :origin_instantiate, :instantiate 96 | alias_method :origin_construct_model, :construct_model 97 | 98 | def instantiate(result_set, aliases) 99 | @bullet_eager_loadings = {} 100 | records = origin_instantiate(result_set, aliases) 101 | 102 | if Bullet.start? 103 | @bullet_eager_loadings.each do |_klazz, eager_loadings_hash| 104 | objects = eager_loadings_hash.keys 105 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 106 | end 107 | end 108 | records 109 | end 110 | 111 | # call join associations 112 | def construct_model(record, node, row, model_cache, id, aliases) 113 | result = origin_construct_model(record, node, row, model_cache, id, aliases) 114 | 115 | if Bullet.start? 116 | associations = [node.reflection.name] 117 | if node.reflection.nested? 118 | associations << node.reflection.through_reflection.name 119 | end 120 | associations.each do |association| 121 | Bullet::Detector::Association.add_object_associations(record, association) 122 | Bullet::Detector::NPlusOneQuery.call_association(record, association) 123 | @bullet_eager_loadings[record.class] ||= {} 124 | @bullet_eager_loadings[record.class][record] ||= Set.new 125 | @bullet_eager_loadings[record.class][record] << association 126 | end 127 | end 128 | 129 | result 130 | end 131 | end 132 | 133 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 134 | # call one to many associations 135 | alias_method :origin_load_target, :load_target 136 | def load_target 137 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) if Bullet.start? && !@inversed 138 | origin_load_target 139 | end 140 | 141 | alias_method :origin_empty?, :empty? 142 | def empty? 143 | if Bullet.start? && !has_cached_counter?(@reflection) 144 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 145 | end 146 | origin_empty? 147 | end 148 | 149 | alias_method :origin_include?, :include? 150 | def include?(object) 151 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) if Bullet.start? 152 | origin_include?(object) 153 | end 154 | end 155 | 156 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 157 | # call has_one and belongs_to associations 158 | alias_method :origin_reader, :reader 159 | def reader(force_reload = false) 160 | result = origin_reader(force_reload) 161 | if Bullet.start? 162 | if @owner.class.name !~ /^HABTM_/ && !@inversed 163 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 164 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 165 | end 166 | end 167 | result 168 | end 169 | end 170 | 171 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 172 | alias_method :origin_count_records, :count_records 173 | def count_records 174 | result = has_cached_counter? 175 | Bullet::Detector::CounterCache.add_counter_cache(@owner, @reflection.name) if Bullet.start? && !result 176 | origin_count_records 177 | end 178 | end 179 | 180 | ::ActiveRecord::Associations::CollectionProxy.class_eval do 181 | def count(column_name = nil, options = {}) 182 | if Bullet.start? 183 | Bullet::Detector::CounterCache.add_counter_cache(proxy_association.owner, proxy_association.reflection.name) 184 | Bullet::Detector::NPlusOneQuery.call_association(proxy_association.owner, proxy_association.reflection.name) 185 | end 186 | super(column_name, options) 187 | end 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/bullet/active_record42.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module ActiveRecord 5 | def self.enable 6 | require 'active_record' 7 | ::ActiveRecord::Base.class_eval do 8 | class << self 9 | alias_method :origin_find, :find 10 | def find(*args) 11 | result = origin_find(*args) 12 | if Bullet.start? 13 | if result.is_a? Array 14 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 15 | Bullet::Detector::CounterCache.add_possible_objects(result) 16 | elsif result.is_a? ::ActiveRecord::Base 17 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) 18 | Bullet::Detector::CounterCache.add_impossible_object(result) 19 | end 20 | end 21 | result 22 | end 23 | 24 | alias_method :origin_find_by_sql, :find_by_sql 25 | def find_by_sql(sql, binds = []) 26 | result = origin_find_by_sql(sql, binds) 27 | if Bullet.start? 28 | if result.is_a? Array 29 | if result.size > 1 30 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 31 | Bullet::Detector::CounterCache.add_possible_objects(result) 32 | elsif result.size == 1 33 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first) 34 | Bullet::Detector::CounterCache.add_impossible_object(result.first) 35 | end 36 | elsif result.is_a? ::ActiveRecord::Base 37 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) 38 | Bullet::Detector::CounterCache.add_impossible_object(result) 39 | end 40 | end 41 | result 42 | end 43 | end 44 | end 45 | 46 | ::ActiveRecord::Persistence.class_eval do 47 | def _create_record_with_bullet(*args) 48 | _create_record_without_bullet(*args).tap do 49 | Bullet::Detector::NPlusOneQuery.update_inversed_object(self) 50 | Bullet::Detector::NPlusOneQuery.add_impossible_object(self) 51 | end 52 | end 53 | alias_method_chain :_create_record, :bullet 54 | end 55 | 56 | ::ActiveRecord::Relation.class_eval do 57 | alias_method :origin_to_a, :to_a 58 | 59 | # if select a collection of objects, then these objects have possible to cause N+1 query. 60 | # if select only one object, then the only one object has impossible to cause N+1 query. 61 | def to_a 62 | records = origin_to_a 63 | if Bullet.start? 64 | if records.first.class.name !~ /^HABTM_/ 65 | if records.size > 1 66 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 67 | Bullet::Detector::CounterCache.add_possible_objects(records) 68 | elsif records.size == 1 69 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 70 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 71 | end 72 | end 73 | end 74 | records 75 | end 76 | end 77 | 78 | ::ActiveRecord::Associations::Preloader.class_eval do 79 | alias_method :origin_preloaders_on, :preloaders_on 80 | 81 | def preloaders_on(association, records, scope) 82 | if Bullet.start? 83 | records.compact! 84 | if records.first.class.name !~ /^HABTM_/ 85 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, association) } 86 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, association) 87 | end 88 | end 89 | origin_preloaders_on(association, records, scope) 90 | end 91 | end 92 | 93 | ::ActiveRecord::FinderMethods.class_eval do 94 | # add includes in scope 95 | alias_method :origin_find_with_associations, :find_with_associations 96 | def find_with_associations 97 | return origin_find_with_associations { |r| yield r } if block_given? 98 | 99 | records = origin_find_with_associations 100 | if Bullet.start? 101 | associations = (eager_load_values + includes_values).uniq 102 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, associations) } 103 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, associations) 104 | end 105 | records 106 | end 107 | end 108 | 109 | ::ActiveRecord::Associations::JoinDependency.class_eval do 110 | alias_method :origin_instantiate, :instantiate 111 | alias_method :origin_construct, :construct 112 | alias_method :origin_construct_model, :construct_model 113 | 114 | def instantiate(result_set, aliases) 115 | @bullet_eager_loadings = {} 116 | records = origin_instantiate(result_set, aliases) 117 | 118 | if Bullet.start? 119 | @bullet_eager_loadings.each do |_klazz, eager_loadings_hash| 120 | objects = eager_loadings_hash.keys 121 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(objects, eager_loadings_hash[objects.first].to_a) 122 | end 123 | end 124 | records 125 | end 126 | 127 | def construct(ar_parent, parent, row, rs, seen, model_cache, aliases) 128 | if Bullet.start? 129 | unless ar_parent.nil? 130 | parent.children.each do |node| 131 | key = aliases.column_alias(node, node.primary_key) 132 | id = row[key] 133 | next unless id.nil? 134 | 135 | associations = [node.reflection.name] 136 | if node.reflection.nested? 137 | associations << node.reflection.through_reflection.name 138 | end 139 | associations.each do |association| 140 | Bullet::Detector::Association.add_object_associations(ar_parent, association) 141 | Bullet::Detector::NPlusOneQuery.call_association(ar_parent, association) 142 | @bullet_eager_loadings[ar_parent.class] ||= {} 143 | @bullet_eager_loadings[ar_parent.class][ar_parent] ||= Set.new 144 | @bullet_eager_loadings[ar_parent.class][ar_parent] << association 145 | end 146 | end 147 | end 148 | end 149 | 150 | origin_construct(ar_parent, parent, row, rs, seen, model_cache, aliases) 151 | end 152 | 153 | # call join associations 154 | def construct_model(record, node, row, model_cache, id, aliases) 155 | result = origin_construct_model(record, node, row, model_cache, id, aliases) 156 | 157 | if Bullet.start? 158 | associations = [node.reflection.name] 159 | if node.reflection.nested? 160 | associations << node.reflection.through_reflection.name 161 | end 162 | associations.each do |association| 163 | Bullet::Detector::Association.add_object_associations(record, association) 164 | Bullet::Detector::NPlusOneQuery.call_association(record, association) 165 | @bullet_eager_loadings[record.class] ||= {} 166 | @bullet_eager_loadings[record.class][record] ||= Set.new 167 | @bullet_eager_loadings[record.class][record] << association 168 | end 169 | end 170 | 171 | result 172 | end 173 | end 174 | 175 | ::ActiveRecord::Associations::CollectionAssociation.class_eval do 176 | # call one to many associations 177 | alias_method :origin_load_target, :load_target 178 | def load_target 179 | records = origin_load_target 180 | 181 | if Bullet.start? 182 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) unless @inversed 183 | if records.first.class.name !~ /^HABTM_/ 184 | if records.size > 1 185 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 186 | Bullet::Detector::CounterCache.add_possible_objects(records) 187 | elsif records.size == 1 188 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 189 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 190 | end 191 | end 192 | end 193 | records 194 | end 195 | 196 | alias_method :origin_empty?, :empty? 197 | def empty? 198 | if Bullet.start? && !has_cached_counter?(@reflection) 199 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 200 | end 201 | origin_empty? 202 | end 203 | 204 | alias_method :origin_include?, :include? 205 | def include?(object) 206 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) if Bullet.start? 207 | origin_include?(object) 208 | end 209 | end 210 | 211 | ::ActiveRecord::Associations::SingularAssociation.class_eval do 212 | # call has_one and belongs_to associations 213 | alias_method :origin_reader, :reader 214 | def reader(force_reload = false) 215 | result = origin_reader(force_reload) 216 | 217 | if Bullet.start? 218 | if @owner.class.name !~ /^HABTM_/ && !@inversed 219 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 220 | 221 | if Bullet::Detector::NPlusOneQuery.impossible?(@owner) 222 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 223 | else 224 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) if result 225 | end 226 | end 227 | end 228 | result 229 | end 230 | end 231 | 232 | ::ActiveRecord::Associations::HasManyAssociation.class_eval do 233 | alias_method :origin_many_empty?, :empty? 234 | def empty? 235 | result = origin_many_empty? 236 | if Bullet.start? && !has_cached_counter?(@reflection) 237 | Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) 238 | end 239 | result 240 | end 241 | 242 | alias_method :origin_count_records, :count_records 243 | def count_records 244 | result = has_cached_counter? 245 | Bullet::Detector::CounterCache.add_counter_cache(@owner, @reflection.name) if Bullet.start? && !result 246 | origin_count_records 247 | end 248 | end 249 | 250 | ::ActiveRecord::Associations::CollectionProxy.class_eval do 251 | def count(column_name = nil, options = {}) 252 | if Bullet.start? 253 | Bullet::Detector::CounterCache.add_counter_cache(proxy_association.owner, proxy_association.reflection.name) 254 | Bullet::Detector::NPlusOneQuery.call_association(proxy_association.owner, proxy_association.reflection.name) 255 | end 256 | super(column_name, options) 257 | end 258 | end 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /lib/bullet/active_record52.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module SaveWithBulletSupport 5 | def _create_record(*) 6 | super do 7 | Bullet::Detector::NPlusOneQuery.update_inversed_object(self) 8 | Bullet::Detector::NPlusOneQuery.add_impossible_object(self) 9 | yield(self) if block_given? 10 | end 11 | end 12 | end 13 | 14 | module ActiveRecord 15 | def self.enable 16 | require 'active_record' 17 | ::ActiveRecord::Base.extend( 18 | Module.new do 19 | def find_by_sql(sql, binds = [], preparable: nil, &block) 20 | result = super 21 | if Bullet.start? 22 | if result.is_a? Array 23 | if result.size > 1 24 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 25 | Bullet::Detector::CounterCache.add_possible_objects(result) 26 | elsif result.size == 1 27 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first) 28 | Bullet::Detector::CounterCache.add_impossible_object(result.first) 29 | end 30 | elsif result.is_a? ::ActiveRecord::Base 31 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) 32 | Bullet::Detector::CounterCache.add_impossible_object(result) 33 | end 34 | end 35 | result 36 | end 37 | end 38 | ) 39 | 40 | ::ActiveRecord::Base.prepend(SaveWithBulletSupport) 41 | 42 | ::ActiveRecord::Relation.prepend( 43 | Module.new do 44 | # if select a collection of objects, then these objects have possible to cause N+1 query. 45 | # if select only one object, then the only one object has impossible to cause N+1 query. 46 | def records 47 | result = super 48 | if Bullet.start? 49 | if result.first.class.name !~ /^HABTM_/ 50 | if result.size > 1 51 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) 52 | Bullet::Detector::CounterCache.add_possible_objects(result) 53 | elsif result.size == 1 54 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first) 55 | Bullet::Detector::CounterCache.add_impossible_object(result.first) 56 | end 57 | end 58 | end 59 | result 60 | end 61 | end 62 | ) 63 | 64 | ::ActiveRecord::Associations::Preloader.prepend( 65 | Module.new do 66 | def preloaders_for_one(association, records, scope) 67 | if Bullet.start? 68 | records.compact! 69 | if records.first.class.name !~ /^HABTM_/ 70 | records.each { |record| Bullet::Detector::Association.add_object_associations(record, association) } 71 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(records, association) 72 | end 73 | end 74 | super 75 | end 76 | end 77 | ) 78 | 79 | ::ActiveRecord::Associations::JoinDependency.prepend( 80 | Module.new do 81 | def instantiate(result_set, &block) 82 | @bullet_eager_loadings = {} 83 | records = super 84 | 85 | if Bullet.start? 86 | @bullet_eager_loadings.each do |_klazz, eager_loadings_hash| 87 | objects = eager_loadings_hash.keys 88 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings( 89 | objects, 90 | eager_loadings_hash[objects.first].to_a 91 | ) 92 | end 93 | end 94 | records 95 | end 96 | 97 | def construct(ar_parent, parent, row, rs, seen, model_cache, aliases) 98 | if Bullet.start? 99 | unless ar_parent.nil? 100 | parent.children.each do |node| 101 | key = aliases.column_alias(node, node.primary_key) 102 | id = row[key] 103 | next unless id.nil? 104 | 105 | associations = [node.reflection.name] 106 | if node.reflection.through_reflection? 107 | associations << node.reflection.through_reflection.name 108 | end 109 | associations.each do |association| 110 | Bullet::Detector::Association.add_object_associations(ar_parent, association) 111 | Bullet::Detector::NPlusOneQuery.call_association(ar_parent, association) 112 | @bullet_eager_loadings[ar_parent.class] ||= {} 113 | @bullet_eager_loadings[ar_parent.class][ar_parent] ||= Set.new 114 | @bullet_eager_loadings[ar_parent.class][ar_parent] << association 115 | end 116 | end 117 | end 118 | end 119 | 120 | super 121 | end 122 | 123 | # call join associations 124 | def construct_model(record, node, row, model_cache, id, aliases) 125 | result = super 126 | 127 | if Bullet.start? 128 | associations = [node.reflection.name] 129 | if node.reflection.through_reflection? 130 | associations << node.reflection.through_reflection.name 131 | end 132 | associations.each do |association| 133 | Bullet::Detector::Association.add_object_associations(record, association) 134 | Bullet::Detector::NPlusOneQuery.call_association(record, association) 135 | @bullet_eager_loadings[record.class] ||= {} 136 | @bullet_eager_loadings[record.class][record] ||= Set.new 137 | @bullet_eager_loadings[record.class][record] << association 138 | end 139 | end 140 | 141 | result 142 | end 143 | end 144 | ) 145 | 146 | ::ActiveRecord::Associations::Association.prepend( 147 | Module.new do 148 | def inversed_from(record) 149 | if Bullet.start? 150 | Bullet::Detector::NPlusOneQuery.add_inversed_object(owner, reflection.name) 151 | end 152 | super 153 | end 154 | end 155 | ) 156 | 157 | ::ActiveRecord::Associations::CollectionAssociation.prepend( 158 | Module.new do 159 | def load_target 160 | records = super 161 | 162 | if Bullet.start? 163 | if is_a? ::ActiveRecord::Associations::ThroughAssociation 164 | association = owner.association(reflection.through_reflection.name) 165 | if association.loaded? 166 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.through_reflection.name) 167 | Array.wrap(association.target).each do |through_record| 168 | Bullet::Detector::NPlusOneQuery.call_association(through_record, source_reflection.name) 169 | end 170 | 171 | if reflection.through_reflection != through_reflection 172 | Bullet::Detector::NPlusOneQuery.call_association(owner, through_reflection.name) 173 | end 174 | end 175 | end 176 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name) unless @inversed 177 | if records.first.class.name !~ /^HABTM_/ 178 | if records.size > 1 179 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 180 | Bullet::Detector::CounterCache.add_possible_objects(records) 181 | elsif records.size == 1 182 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 183 | Bullet::Detector::CounterCache.add_impossible_object(records.first) 184 | end 185 | end 186 | end 187 | records 188 | end 189 | 190 | def empty? 191 | if Bullet.start? && !reflection.has_cached_counter? 192 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name) 193 | end 194 | super 195 | end 196 | 197 | def include?(object) 198 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name) if Bullet.start? 199 | super 200 | end 201 | end 202 | ) 203 | 204 | ::ActiveRecord::Associations::SingularAssociation.prepend( 205 | Module.new do 206 | # call has_one and belongs_to associations 207 | def target 208 | result = super() 209 | 210 | if Bullet.start? 211 | if owner.class.name !~ /^HABTM_/ && !@inversed 212 | if is_a? ::ActiveRecord::Associations::ThroughAssociation 213 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.through_reflection.name) 214 | association = owner.association reflection.through_reflection.name 215 | Array.wrap(association.target).each do |through_record| 216 | Bullet::Detector::NPlusOneQuery.call_association(through_record, source_reflection.name) 217 | end 218 | 219 | if reflection.through_reflection != through_reflection 220 | Bullet::Detector::NPlusOneQuery.call_association(owner, through_reflection.name) 221 | end 222 | end 223 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name) 224 | 225 | if Bullet::Detector::NPlusOneQuery.impossible?(owner) 226 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 227 | else 228 | Bullet::Detector::NPlusOneQuery.add_possible_objects(result) if result 229 | end 230 | end 231 | end 232 | result 233 | end 234 | end 235 | ) 236 | 237 | ::ActiveRecord::Associations::HasManyAssociation.prepend( 238 | Module.new do 239 | def empty? 240 | result = super 241 | if Bullet.start? && !reflection.has_cached_counter? 242 | Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name) 243 | end 244 | result 245 | end 246 | 247 | def count_records 248 | result = reflection.has_cached_counter? 249 | if Bullet.start? && !result && !is_a?(::ActiveRecord::Associations::ThroughAssociation) 250 | Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name) 251 | end 252 | super 253 | end 254 | end 255 | ) 256 | 257 | ::ActiveRecord::Associations::CollectionProxy.prepend( 258 | Module.new do 259 | def count(column_name = nil) 260 | if Bullet.start? && !proxy_association.is_a?(::ActiveRecord::Associations::ThroughAssociation) 261 | Bullet::Detector::CounterCache.add_counter_cache( 262 | proxy_association.owner, 263 | proxy_association.reflection.name 264 | ) 265 | Bullet::Detector::NPlusOneQuery.call_association( 266 | proxy_association.owner, 267 | proxy_association.reflection.name 268 | ) 269 | end 270 | super(column_name) 271 | end 272 | end 273 | ) 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /lib/bullet/bullet_xhr.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var oldOpen = window.XMLHttpRequest.prototype.open; 3 | var oldSend = window.XMLHttpRequest.prototype.send; 4 | 5 | /** 6 | * Return early if we've already extended prototype. This prevents 7 | * "maximum call stack exceeded" errors when used with Turbolinks. 8 | * See https://github.com/flyerhzm/bullet/issues/454 9 | */ 10 | if (isBulletInitiated()) return; 11 | 12 | function isBulletInitiated() { 13 | return oldOpen.name == "bulletXHROpen" && oldSend.name == "bulletXHRSend"; 14 | } 15 | function bulletXHROpen(_, url) { 16 | this._storedUrl = url; 17 | return Reflect.apply(oldOpen, this, arguments); 18 | } 19 | function bulletXHRSend() { 20 | if (this.onload) { 21 | this._storedOnload = this.onload; 22 | } 23 | this.onload = null; 24 | this.addEventListener("load", bulletXHROnload); 25 | return Reflect.apply(oldSend, this, arguments); 26 | } 27 | function bulletXHROnload() { 28 | if ( 29 | this._storedUrl.startsWith(window.location.protocol + "//" + window.location.host) || 30 | !this._storedUrl.startsWith("http") // For relative paths 31 | ) { 32 | var bulletFooterText = this.getResponseHeader("X-bullet-footer-text"); 33 | if (bulletFooterText) { 34 | setTimeout(function () { 35 | var oldHtml = document.querySelector("#bullet-footer").innerHTML.split("
"); 36 | var header = oldHtml[0]; 37 | oldHtml = oldHtml.slice(1, oldHtml.length); 38 | var newHtml = oldHtml.concat(JSON.parse(bulletFooterText)); 39 | newHtml = newHtml.slice(newHtml.length - 10, newHtml.length); // rotate through 10 most recent 40 | document.querySelector("#bullet-footer").innerHTML = `${header}
${newHtml.join("
")}`; 41 | }, 0); 42 | } 43 | var bulletConsoleText = this.getResponseHeader("X-bullet-console-text"); 44 | if (bulletConsoleText && typeof console !== "undefined" && console.log) { 45 | setTimeout(function () { 46 | JSON.parse(bulletConsoleText).forEach((message) => { 47 | if (console.groupCollapsed && console.groupEnd) { 48 | console.groupCollapsed("Uniform Notifier"); 49 | console.log(message); 50 | console.groupEnd(); 51 | } else { 52 | console.log(message); 53 | } 54 | }); 55 | }, 0); 56 | } 57 | } 58 | if (this._storedOnload) { 59 | return Reflect.apply(this._storedOnload, this, arguments); 60 | } 61 | } 62 | window.XMLHttpRequest.prototype.open = bulletXHROpen; 63 | window.XMLHttpRequest.prototype.send = bulletXHRSend; 64 | })(); 65 | -------------------------------------------------------------------------------- /lib/bullet/dependency.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Dependency 5 | def mongoid? 6 | @mongoid ||= defined?(::Mongoid) 7 | end 8 | 9 | def active_record? 10 | @active_record ||= defined?(::ActiveRecord) 11 | end 12 | 13 | def active_record_version 14 | @active_record_version ||= 15 | begin 16 | if active_record40? 17 | 'active_record4' 18 | elsif active_record41? 19 | 'active_record41' 20 | elsif active_record42? 21 | 'active_record42' 22 | elsif active_record50? 23 | 'active_record5' 24 | elsif active_record51? 25 | 'active_record5' 26 | elsif active_record52? 27 | 'active_record52' 28 | elsif active_record60? 29 | 'active_record60' 30 | elsif active_record61? 31 | 'active_record61' 32 | elsif active_record70? 33 | 'active_record70' 34 | elsif active_record71? 35 | 'active_record71' 36 | elsif active_record72? 37 | 'active_record72' 38 | elsif active_record80? 39 | 'active_record80' 40 | else 41 | raise "Bullet does not support active_record #{::ActiveRecord::VERSION::STRING} yet" 42 | end 43 | end 44 | end 45 | 46 | def mongoid_version 47 | @mongoid_version ||= 48 | begin 49 | if mongoid4x? 50 | 'mongoid4x' 51 | elsif mongoid5x? 52 | 'mongoid5x' 53 | elsif mongoid6x? 54 | 'mongoid6x' 55 | elsif mongoid7x? 56 | 'mongoid7x' 57 | elsif mongoid8x? 58 | 'mongoid8x' 59 | elsif mongoid9x? 60 | 'mongoid9x' 61 | else 62 | raise "Bullet does not support mongoid #{::Mongoid::VERSION} yet" 63 | end 64 | end 65 | end 66 | 67 | def active_record4? 68 | active_record? && ::ActiveRecord::VERSION::MAJOR == 4 69 | end 70 | 71 | def active_record5? 72 | active_record? && ::ActiveRecord::VERSION::MAJOR == 5 73 | end 74 | 75 | def active_record6? 76 | active_record? && ::ActiveRecord::VERSION::MAJOR == 6 77 | end 78 | 79 | def active_record7? 80 | active_record? && ::ActiveRecord::VERSION::MAJOR == 7 81 | end 82 | 83 | def active_record8? 84 | active_record? && ::ActiveRecord::VERSION::MAJOR == 8 85 | end 86 | 87 | def active_record40? 88 | active_record4? && ::ActiveRecord::VERSION::MINOR == 0 89 | end 90 | 91 | def active_record41? 92 | active_record4? && ::ActiveRecord::VERSION::MINOR == 1 93 | end 94 | 95 | def active_record42? 96 | active_record4? && ::ActiveRecord::VERSION::MINOR == 2 97 | end 98 | 99 | def active_record50? 100 | active_record5? && ::ActiveRecord::VERSION::MINOR == 0 101 | end 102 | 103 | def active_record51? 104 | active_record5? && ::ActiveRecord::VERSION::MINOR == 1 105 | end 106 | 107 | def active_record52? 108 | active_record5? && ::ActiveRecord::VERSION::MINOR == 2 109 | end 110 | 111 | def active_record60? 112 | active_record6? && ::ActiveRecord::VERSION::MINOR == 0 113 | end 114 | 115 | def active_record61? 116 | active_record6? && ::ActiveRecord::VERSION::MINOR == 1 117 | end 118 | 119 | def active_record70? 120 | active_record7? && ::ActiveRecord::VERSION::MINOR == 0 121 | end 122 | 123 | def active_record71? 124 | active_record7? && ::ActiveRecord::VERSION::MINOR == 1 125 | end 126 | 127 | def active_record72? 128 | active_record7? && ::ActiveRecord::VERSION::MINOR == 2 129 | end 130 | 131 | def active_record80? 132 | active_record8? && ::ActiveRecord::VERSION::MINOR == 0 133 | end 134 | 135 | def mongoid4x? 136 | mongoid? && ::Mongoid::VERSION =~ /\A4/ 137 | end 138 | 139 | def mongoid5x? 140 | mongoid? && ::Mongoid::VERSION =~ /\A5/ 141 | end 142 | 143 | def mongoid6x? 144 | mongoid? && ::Mongoid::VERSION =~ /\A6/ 145 | end 146 | 147 | def mongoid7x? 148 | mongoid? && ::Mongoid::VERSION =~ /\A7/ 149 | end 150 | 151 | def mongoid8x? 152 | mongoid? && ::Mongoid::VERSION =~ /\A8/ 153 | end 154 | 155 | def mongoid9x? 156 | mongoid? && ::Mongoid::VERSION =~ /\A9/ 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/bullet/detector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Detector 5 | autoload :Base, 'bullet/detector/base' 6 | autoload :Association, 'bullet/detector/association' 7 | autoload :NPlusOneQuery, 'bullet/detector/n_plus_one_query' 8 | autoload :UnusedEagerLoading, 'bullet/detector/unused_eager_loading' 9 | autoload :CounterCache, 'bullet/detector/counter_cache' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/bullet/detector/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | using Bullet::Ext::Object 4 | 5 | module Bullet 6 | module Detector 7 | class Association < Base 8 | class << self 9 | def add_object_associations(object, associations) 10 | return unless Bullet.start? 11 | return if !Bullet.n_plus_one_query_enable? && !Bullet.unused_eager_loading_enable? 12 | return unless object.bullet_primary_key_value 13 | 14 | Bullet.debug( 15 | 'Detector::Association#add_object_associations', 16 | "object: #{object.bullet_key}, associations: #{associations}" 17 | ) 18 | call_stacks.add(object.bullet_key) 19 | object_associations.add(object.bullet_key, associations) 20 | end 21 | 22 | def add_call_object_associations(object, associations) 23 | return unless Bullet.start? 24 | return if !Bullet.n_plus_one_query_enable? && !Bullet.unused_eager_loading_enable? 25 | return unless object.bullet_primary_key_value 26 | 27 | Bullet.debug( 28 | 'Detector::Association#add_call_object_associations', 29 | "object: #{object.bullet_key}, associations: #{associations}" 30 | ) 31 | call_stacks.add(object.bullet_key) 32 | call_object_associations.add(object.bullet_key, associations) 33 | end 34 | 35 | # possible_objects keep the class to object relationships 36 | # that the objects may cause N+1 query. 37 | # e.g. { Post => ["Post:1", "Post:2"] } 38 | def possible_objects 39 | Thread.current.thread_variable_get(:bullet_possible_objects) 40 | end 41 | 42 | # impossible_objects keep the class to objects relationships 43 | # that the objects may not cause N+1 query. 44 | # e.g. { Post => ["Post:1", "Post:2"] } 45 | # if find collection returns only one object, then the object is impossible object, 46 | # impossible_objects are used to avoid treating 1+1 query to N+1 query. 47 | def impossible_objects 48 | Thread.current.thread_variable_get(:bullet_impossible_objects) 49 | end 50 | 51 | private 52 | 53 | # object_associations keep the object relationships 54 | # that the object has many associations. 55 | # e.g. { "Post:1" => [:comments] } 56 | # the object_associations keep all associations that may be or may no be 57 | # unpreload associations or unused preload associations. 58 | def object_associations 59 | Thread.current.thread_variable_get(:bullet_object_associations) 60 | end 61 | 62 | # call_object_associations keep the object relationships 63 | # that object.associations is called. 64 | # e.g. { "Post:1" => [:comments] } 65 | # they are used to detect unused preload associations. 66 | def call_object_associations 67 | Thread.current.thread_variable_get(:bullet_call_object_associations) 68 | end 69 | 70 | # inversed_objects keeps object relationships 71 | # that association is inversed. 72 | # e.g. { "Comment:1" => ["post"] } 73 | def inversed_objects 74 | Thread.current.thread_variable_get(:bullet_inversed_objects) 75 | end 76 | 77 | # eager_loadings keep the object relationships 78 | # that the associations are preloaded by find :include. 79 | # e.g. { ["Post:1", "Post:2"] => [:comments, :user] } 80 | def eager_loadings 81 | Thread.current.thread_variable_get(:bullet_eager_loadings) 82 | end 83 | 84 | # call_stacks keeps stacktraces where querie-objects were called from. 85 | # e.g. { 'Object:111' => [SomeProject/app/controllers/...] } 86 | def call_stacks 87 | Thread.current.thread_variable_get(:bullet_call_stacks) 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/bullet/detector/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Detector 5 | class Base 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/bullet/detector/counter_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | using Bullet::Ext::Object 4 | 5 | module Bullet 6 | module Detector 7 | class CounterCache < Base 8 | class << self 9 | def add_counter_cache(object, associations) 10 | return unless Bullet.start? 11 | return unless Bullet.counter_cache_enable? 12 | return unless object.bullet_primary_key_value 13 | 14 | Bullet.debug( 15 | 'Detector::CounterCache#add_counter_cache', 16 | "object: #{object.bullet_key}, associations: #{associations}" 17 | ) 18 | create_notification object.class.to_s, associations if conditions_met?(object, associations) 19 | end 20 | 21 | def add_possible_objects(object_or_objects) 22 | return unless Bullet.start? 23 | return unless Bullet.counter_cache_enable? 24 | 25 | objects = Array.wrap(object_or_objects) 26 | return if objects.map(&:bullet_primary_key_value).compact.empty? 27 | 28 | Bullet.debug( 29 | 'Detector::CounterCache#add_possible_objects', 30 | "objects: #{objects.map(&:bullet_key).join(', ')}" 31 | ) 32 | objects.each { |object| possible_objects.add object.bullet_key } 33 | end 34 | 35 | def add_impossible_object(object) 36 | return unless Bullet.start? 37 | return unless Bullet.counter_cache_enable? 38 | return unless object.bullet_primary_key_value 39 | 40 | Bullet.debug('Detector::CounterCache#add_impossible_object', "object: #{object.bullet_key}") 41 | impossible_objects.add object.bullet_key 42 | end 43 | 44 | def conditions_met?(object, _associations) 45 | possible_objects.include?(object.bullet_key) && !impossible_objects.include?(object.bullet_key) 46 | end 47 | 48 | def possible_objects 49 | Thread.current.thread_variable_get(:bullet_counter_possible_objects) 50 | end 51 | 52 | def impossible_objects 53 | Thread.current.thread_variable_get(:bullet_counter_impossible_objects) 54 | end 55 | 56 | private 57 | 58 | def create_notification(klazz, associations) 59 | notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations(:counter_cache, klazz) 60 | 61 | if notify_associations.present? 62 | notice = Bullet::Notification::CounterCache.new klazz, notify_associations 63 | Bullet.notification_collector.add notice 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/bullet/detector/n_plus_one_query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | using Bullet::Ext::Object 4 | 5 | module Bullet 6 | module Detector 7 | class NPlusOneQuery < Association 8 | extend Dependency 9 | extend StackTraceFilter 10 | 11 | class << self 12 | # executed when object.associations is called. 13 | # first, it keeps this method call for object.association. 14 | # then, it checks if this associations call is unpreload. 15 | # if it is, keeps this unpreload associations and caller. 16 | def call_association(object, associations) 17 | return unless Bullet.start? 18 | return unless Bullet.n_plus_one_query_enable? 19 | return unless object.bullet_primary_key_value 20 | return if inversed_objects.include?(object.bullet_key, associations) 21 | 22 | add_call_object_associations(object, associations) 23 | 24 | Bullet.debug( 25 | 'Detector::NPlusOneQuery#call_association', 26 | "object: #{object.bullet_key}, associations: #{associations}" 27 | ) 28 | if !excluded_stacktrace_path? && conditions_met?(object, associations) 29 | Bullet.debug('detect n + 1 query', "object: #{object.bullet_key}, associations: #{associations}") 30 | create_notification(caller_in_project(object.bullet_key), object.class.to_s, associations) 31 | end 32 | end 33 | 34 | def add_possible_objects(object_or_objects) 35 | return unless Bullet.start? 36 | return unless Bullet.n_plus_one_query_enable? 37 | 38 | objects = Array.wrap(object_or_objects) 39 | class_names_match_regex = true 40 | primary_key_values_are_empty = true 41 | 42 | keys_joined = objects.map do |obj| 43 | unless obj.class.name =~ /^HABTM_/ 44 | class_names_match_regex = false 45 | end 46 | unless obj.bullet_primary_key_value.nil? 47 | primary_key_values_are_empty = false 48 | end 49 | obj.bullet_key 50 | end.join(", ") 51 | 52 | unless class_names_match_regex || primary_key_values_are_empty 53 | Bullet.debug('Detector::NPlusOneQuery#add_possible_objects', "objects: #{keys_joined}") 54 | objects.each { |object| possible_objects.add object.bullet_key } 55 | end 56 | end 57 | 58 | def add_impossible_object(object) 59 | return unless Bullet.start? 60 | return unless Bullet.n_plus_one_query_enable? 61 | return unless object.bullet_primary_key_value 62 | 63 | Bullet.debug('Detector::NPlusOneQuery#add_impossible_object', "object: #{object.bullet_key}") 64 | impossible_objects.add object.bullet_key 65 | end 66 | 67 | def add_inversed_object(object, association) 68 | return unless Bullet.start? 69 | return unless Bullet.n_plus_one_query_enable? 70 | 71 | object_key = object.bullet_primary_key_value ? object.bullet_key : object.object_id 72 | Bullet.debug( 73 | 'Detector::NPlusOneQuery#add_inversed_object', 74 | "object: #{object_key}, association: #{association}" 75 | ) 76 | inversed_objects.add object_key, association 77 | end 78 | 79 | def update_inversed_object(object) 80 | if inversed_objects&.key?(object.object_id) 81 | Bullet.debug( 82 | 'Detector::NPlusOneQuery#update_inversed_object', 83 | "object from #{object.object_id} to #{object.bullet_key}" 84 | ) 85 | inversed_objects.add(object.bullet_key, inversed_objects[object.object_id].to_a) 86 | end 87 | end 88 | 89 | # decide whether the object.associations is unpreloaded or not. 90 | def conditions_met?(object, associations) 91 | possible?(object) && !impossible?(object) && !association?(object, associations) 92 | end 93 | 94 | def possible?(object) 95 | possible_objects.include? object.bullet_key 96 | end 97 | 98 | def impossible?(object) 99 | impossible_objects.include? object.bullet_key 100 | end 101 | 102 | # check if object => associations already exists in object_associations. 103 | def association?(object, associations) 104 | value = object_associations[object.bullet_key] 105 | value&.each do |v| 106 | # associations == v comparison order is important here because 107 | # v variable might be a squeel node where :== method is redefined, 108 | # so it does not compare values at all and return unexpected results 109 | result = v.is_a?(Hash) ? v.key?(associations) : associations == v 110 | return true if result 111 | end 112 | 113 | false 114 | end 115 | 116 | private 117 | 118 | def create_notification(callers, klazz, associations) 119 | notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations(:n_plus_one_query, klazz) 120 | 121 | if notify_associations.present? 122 | notice = Bullet::Notification::NPlusOneQuery.new(callers, klazz, notify_associations) 123 | Bullet.notification_collector.add(notice) 124 | end 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/bullet/detector/unused_eager_loading.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | using Bullet::Ext::Object 4 | using Bullet::Ext::String 5 | 6 | module Bullet 7 | module Detector 8 | class UnusedEagerLoading < Association 9 | extend Dependency 10 | extend StackTraceFilter 11 | 12 | class << self 13 | # check if there are unused preload associations. 14 | # get related_objects from eager_loadings associated with object and associations 15 | # get call_object_association from associations of call_object_associations whose object is in related_objects 16 | # if association not in call_object_association, then the object => association - call_object_association is unused preload associations 17 | def check_unused_preload_associations 18 | return unless Bullet.start? 19 | return unless Bullet.unused_eager_loading_enable? 20 | 21 | object_associations.each do |bullet_key, associations| 22 | object_association_diff = diff_object_associations bullet_key, associations 23 | next if object_association_diff.empty? 24 | 25 | Bullet.debug('detect unused preload', "object: #{bullet_key}, associations: #{object_association_diff}") 26 | create_notification(caller_in_project(bullet_key), bullet_key.bullet_class_name, object_association_diff) 27 | end 28 | end 29 | 30 | def add_eager_loadings(objects, associations) 31 | return unless Bullet.start? 32 | return unless Bullet.unused_eager_loading_enable? 33 | return if objects.map(&:bullet_primary_key_value).compact.empty? 34 | 35 | Bullet.debug( 36 | 'Detector::UnusedEagerLoading#add_eager_loadings', 37 | "objects: #{objects.map(&:bullet_key).join(', ')}, associations: #{associations}" 38 | ) 39 | bullet_keys = objects.map(&:bullet_key) 40 | 41 | to_add = [] 42 | to_merge = [] 43 | to_delete = [] 44 | eager_loadings.each do |k, _v| 45 | key_objects_overlap = k & bullet_keys 46 | 47 | next if key_objects_overlap.empty? 48 | 49 | bullet_keys -= k 50 | if key_objects_overlap == k 51 | to_add << [k, associations] 52 | else 53 | to_merge << [key_objects_overlap, (eager_loadings[k].dup << associations)] 54 | 55 | keys_without_objects = k - key_objects_overlap 56 | to_merge << [keys_without_objects, eager_loadings[k]] 57 | to_delete << k 58 | end 59 | end 60 | 61 | to_add.each { |k, val| eager_loadings.add k, val } 62 | to_merge.each { |k, val| eager_loadings.merge k, val } 63 | to_delete.each { |k| eager_loadings.delete k } 64 | 65 | eager_loadings.add bullet_keys, associations unless bullet_keys.empty? 66 | end 67 | 68 | private 69 | 70 | def create_notification(callers, klazz, associations) 71 | notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations( 72 | :unused_eager_loading, 73 | klazz 74 | ) 75 | 76 | if notify_associations.present? 77 | notice = Bullet::Notification::UnusedEagerLoading.new(callers, klazz, notify_associations) 78 | Bullet.notification_collector.add(notice) 79 | end 80 | end 81 | 82 | def call_associations(bullet_key, associations) 83 | all = Set.new 84 | eager_loadings.similarly_associated(bullet_key, associations).each do |related_bullet_key| 85 | coa = call_object_associations[related_bullet_key] 86 | next if coa.nil? 87 | 88 | all.merge coa 89 | end 90 | all.to_a 91 | end 92 | 93 | def diff_object_associations(bullet_key, associations) 94 | potential_associations = associations - call_associations(bullet_key, associations) 95 | potential_associations.reject { |a| a.is_a?(Hash) } 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/bullet/ext/object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Ext 5 | module Object 6 | refine ::Object do 7 | attr_writer :bullet_key, :bullet_primary_key_value 8 | 9 | def bullet_key 10 | return "#{self.class}:" if respond_to?(:persisted?) && !persisted? 11 | 12 | @bullet_key ||= "#{self.class}:#{bullet_primary_key_value}" 13 | end 14 | 15 | def bullet_primary_key_value 16 | return if respond_to?(:persisted?) && !persisted? 17 | 18 | @bullet_primary_key_value ||= 19 | begin 20 | primary_key = self.class.try(:primary_keys) || self.class.try(:primary_key) || :id 21 | 22 | bullet_join_potential_composite_primary_key(primary_key) 23 | end 24 | end 25 | 26 | private 27 | 28 | def bullet_join_potential_composite_primary_key(primary_keys) 29 | return read_attribute(primary_keys) unless primary_keys.is_a?(Enumerable) 30 | 31 | primary_keys.map { |primary_key| read_attribute primary_key } 32 | .compact.join(',') 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/bullet/ext/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Ext 5 | module String 6 | refine ::String do 7 | def bullet_class_name 8 | last_colon = self.rindex(':') 9 | last_colon ? self[0...last_colon].dup : self.dup 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/bullet/mongoid4x.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Mongoid 5 | def self.enable 6 | require 'mongoid' 7 | ::Mongoid::Contextual::Mongo.class_eval do 8 | alias_method :origin_first, :first 9 | alias_method :origin_last, :last 10 | alias_method :origin_each, :each 11 | alias_method :origin_eager_load, :eager_load 12 | 13 | def first 14 | result = origin_first 15 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 16 | result 17 | end 18 | 19 | def last 20 | result = origin_last 21 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 22 | result 23 | end 24 | 25 | def each(&block) 26 | return to_enum unless block 27 | 28 | records = [] 29 | origin_each { |record| records << record } 30 | if records.length > 1 31 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 32 | elsif records.size == 1 33 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 34 | end 35 | records.each(&block) 36 | end 37 | 38 | def eager_load(docs) 39 | associations = criteria.inclusions.map(&:name) 40 | docs.each { |doc| Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) } 41 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 42 | origin_eager_load(docs) 43 | end 44 | end 45 | 46 | ::Mongoid::Relations::Accessors.class_eval do 47 | alias_method :origin_get_relation, :get_relation 48 | 49 | def get_relation(name, metadata, object, reload = false) 50 | result = origin_get_relation(name, metadata, object, reload) 51 | Bullet::Detector::NPlusOneQuery.call_association(self, name) if metadata.macro !~ /embed/ 52 | result 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/bullet/mongoid5x.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Mongoid 5 | def self.enable 6 | require 'mongoid' 7 | ::Mongoid::Contextual::Mongo.class_eval do 8 | alias_method :origin_first, :first 9 | alias_method :origin_last, :last 10 | alias_method :origin_each, :each 11 | alias_method :origin_eager_load, :eager_load 12 | 13 | def first 14 | result = origin_first 15 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 16 | result 17 | end 18 | 19 | def last 20 | result = origin_last 21 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 22 | result 23 | end 24 | 25 | def each(&block) 26 | return to_enum unless block 27 | 28 | records = [] 29 | origin_each { |record| records << record } 30 | if records.length > 1 31 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 32 | elsif records.size == 1 33 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 34 | end 35 | records.each(&block) 36 | end 37 | 38 | def eager_load(docs) 39 | associations = criteria.inclusions.map(&:name) 40 | docs.each { |doc| Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) } 41 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 42 | origin_eager_load(docs) 43 | end 44 | end 45 | 46 | ::Mongoid::Relations::Accessors.class_eval do 47 | alias_method :origin_get_relation, :get_relation 48 | 49 | def get_relation(name, metadata, object, reload = false) 50 | result = origin_get_relation(name, metadata, object, reload) 51 | Bullet::Detector::NPlusOneQuery.call_association(self, name) if metadata.macro !~ /embed/ 52 | result 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/bullet/mongoid6x.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Mongoid 5 | def self.enable 6 | require 'mongoid' 7 | ::Mongoid::Contextual::Mongo.class_eval do 8 | alias_method :origin_first, :first 9 | alias_method :origin_last, :last 10 | alias_method :origin_each, :each 11 | alias_method :origin_eager_load, :eager_load 12 | 13 | def first(opt = {}) 14 | result = origin_first(opt) 15 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 16 | result 17 | end 18 | 19 | def last(opt = {}) 20 | result = origin_last(opt) 21 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 22 | result 23 | end 24 | 25 | def each(&block) 26 | return to_enum unless block 27 | 28 | records = [] 29 | origin_each { |record| records << record } 30 | if records.length > 1 31 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 32 | elsif records.size == 1 33 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 34 | end 35 | records.each(&block) 36 | end 37 | 38 | def eager_load(docs) 39 | associations = criteria.inclusions.map(&:name) 40 | docs.each { |doc| Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) } 41 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 42 | origin_eager_load(docs) 43 | end 44 | end 45 | 46 | ::Mongoid::Relations::Accessors.class_eval do 47 | alias_method :origin_get_relation, :get_relation 48 | 49 | def get_relation(name, metadata, object, reload = false) 50 | result = origin_get_relation(name, metadata, object, reload) 51 | Bullet::Detector::NPlusOneQuery.call_association(self, name) if metadata.macro !~ /embed/ 52 | result 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/bullet/mongoid7x.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Mongoid 5 | def self.enable 6 | require 'mongoid' 7 | require 'rubygems' 8 | ::Mongoid::Contextual::Mongo.class_eval do 9 | alias_method :origin_first, :first 10 | alias_method :origin_last, :last 11 | alias_method :origin_each, :each 12 | alias_method :origin_eager_load, :eager_load 13 | 14 | %i[first last].each do |context| 15 | default = Gem::Version.new(::Mongoid::VERSION) >= Gem::Version.new('7.5') ? nil : {} 16 | define_method(context) do |opts = default| 17 | result = send(:"origin_#{context}", opts) 18 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 19 | result 20 | end 21 | end 22 | 23 | def each(&block) 24 | return to_enum unless block_given? 25 | 26 | first_document = nil 27 | document_count = 0 28 | 29 | origin_each do |document| 30 | document_count += 1 31 | 32 | if document_count == 1 33 | first_document = document 34 | elsif document_count == 2 35 | Bullet::Detector::NPlusOneQuery.add_possible_objects([first_document, document]) 36 | yield(first_document) 37 | first_document = nil 38 | yield(document) 39 | else 40 | Bullet::Detector::NPlusOneQuery.add_possible_objects(document) 41 | yield(document) 42 | end 43 | end 44 | 45 | if document_count == 1 46 | Bullet::Detector::NPlusOneQuery.add_impossible_object(first_document) 47 | yield(first_document) 48 | end 49 | 50 | self 51 | end 52 | 53 | def eager_load(docs) 54 | associations = criteria.inclusions.map(&:name) 55 | docs.each { |doc| Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) } 56 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 57 | origin_eager_load(docs) 58 | end 59 | end 60 | 61 | ::Mongoid::Association::Accessors.class_eval do 62 | alias_method :origin_get_relation, :get_relation 63 | 64 | def get_relation(name, association, object, reload = false) 65 | result = origin_get_relation(name, association, object, reload) 66 | Bullet::Detector::NPlusOneQuery.call_association(self, name) unless association.embedded? 67 | result 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/bullet/mongoid8x.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(limit = nil) 12 | result = origin_first(limit) 13 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 14 | result 15 | end 16 | 17 | def last(limit = nil) 18 | result = origin_last(limit) 19 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 20 | result 21 | end 22 | 23 | def each(&block) 24 | return to_enum unless block_given? 25 | 26 | records = [] 27 | origin_each { |record| records << record } 28 | if records.length > 1 29 | Bullet::Detector::NPlusOneQuery.add_possible_objects(records) 30 | elsif records.size == 1 31 | Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first) 32 | end 33 | records.each(&block) 34 | end 35 | 36 | def eager_load(docs) 37 | associations = criteria.inclusions.map(&:name) 38 | docs.each do |doc| 39 | Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) 40 | end 41 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 42 | origin_eager_load(docs) 43 | end 44 | end 45 | 46 | ::Mongoid::Association::Accessors.class_eval do 47 | alias_method :origin_get_relation, :get_relation 48 | 49 | def get_relation(name, association, object, reload = false) 50 | result = origin_get_relation(name, association, object, reload) 51 | unless association.embedded? 52 | Bullet::Detector::NPlusOneQuery.call_association(self, name) 53 | end 54 | result 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/bullet/mongoid9x.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Mongoid 5 | def self.enable 6 | require 'mongoid' 7 | require 'rubygems' 8 | ::Mongoid::Contextual::Mongo.class_eval do 9 | alias_method :origin_first, :first 10 | alias_method :origin_last, :last 11 | alias_method :origin_each, :each 12 | alias_method :origin_eager_load, :eager_load 13 | 14 | %i[first last].each do |context| 15 | default = Gem::Version.new(::Mongoid::VERSION) >= Gem::Version.new('7.5') ? nil : {} 16 | define_method(context) do |opts = default| 17 | result = send(:"origin_#{context}", opts) 18 | Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result 19 | result 20 | end 21 | end 22 | 23 | def each(&_block) 24 | return to_enum unless block_given? 25 | 26 | first_document = nil 27 | document_count = 0 28 | 29 | origin_each do |document| 30 | document_count += 1 31 | 32 | if document_count == 1 33 | first_document = document 34 | elsif document_count == 2 35 | Bullet::Detector::NPlusOneQuery.add_possible_objects([first_document, document]) 36 | yield(first_document) 37 | first_document = nil 38 | yield(document) 39 | else 40 | Bullet::Detector::NPlusOneQuery.add_possible_objects(document) 41 | yield(document) 42 | end 43 | end 44 | 45 | if document_count == 1 46 | Bullet::Detector::NPlusOneQuery.add_impossible_object(first_document) 47 | yield(first_document) 48 | end 49 | 50 | self 51 | end 52 | 53 | def eager_load(docs) 54 | associations = criteria.inclusions.map(&:name) 55 | docs.each { |doc| Bullet::Detector::NPlusOneQuery.add_object_associations(doc, associations) } 56 | Bullet::Detector::UnusedEagerLoading.add_eager_loadings(docs, associations) 57 | origin_eager_load(docs) 58 | end 59 | end 60 | 61 | ::Mongoid::Association::Accessors.class_eval do 62 | alias_method :origin_get_relation, :get_relation 63 | 64 | def get_relation(name, association, object, reload = false) 65 | result = origin_get_relation(name, association, object, reload) 66 | Bullet::Detector::NPlusOneQuery.call_association(self, name) unless association.embedded? 67 | result 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/bullet/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Notification 5 | autoload :Base, 'bullet/notification/base' 6 | autoload :UnusedEagerLoading, 'bullet/notification/unused_eager_loading' 7 | autoload :NPlusOneQuery, 'bullet/notification/n_plus_one_query' 8 | autoload :CounterCache, 'bullet/notification/counter_cache' 9 | 10 | class UnoptimizedQueryError < StandardError 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/bullet/notification/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Notification 5 | class Base 6 | attr_accessor :notifier, :url 7 | attr_reader :base_class, :associations, :path 8 | 9 | def initialize(base_class, association_or_associations, path = nil) 10 | @base_class = base_class 11 | @associations = 12 | association_or_associations.is_a?(Array) ? association_or_associations : [association_or_associations] 13 | @path = path 14 | end 15 | 16 | def title 17 | raise NoMethodError, 'no method title defined' 18 | end 19 | 20 | def body 21 | raise NoMethodError, 'no method body defined' 22 | end 23 | 24 | def call_stack_messages 25 | '' 26 | end 27 | 28 | def whoami 29 | @user ||= 30 | ENV['USER'].presence || 31 | ( 32 | begin 33 | `whoami`.chomp 34 | rescue StandardError 35 | '' 36 | end 37 | ) 38 | @user.present? ? "user: #{@user}" : '' 39 | end 40 | 41 | def body_with_caller 42 | "#{body}\n#{call_stack_messages}\n" 43 | end 44 | 45 | def notify_inline 46 | notifier.inline_notify(notification_data) 47 | end 48 | 49 | def notify_out_of_channel 50 | notifier.out_of_channel_notify(notification_data) 51 | end 52 | 53 | def short_notice 54 | parts = [] 55 | parts << whoami.presence unless Bullet.skip_user_in_notification 56 | parts << url 57 | parts << title 58 | parts << body 59 | 60 | parts.compact.join(' ') 61 | end 62 | 63 | def notification_data 64 | hash = {} 65 | hash[:user] = whoami unless Bullet.skip_user_in_notification 66 | hash[:url] = url 67 | hash[:title] = title 68 | hash[:body] = body_with_caller 69 | hash 70 | end 71 | 72 | def eql?(other) 73 | self.class == other.class && klazz_associations_str == other.klazz_associations_str 74 | end 75 | 76 | def hash 77 | [self.class, klazz_associations_str].hash 78 | end 79 | 80 | protected 81 | 82 | def klazz_associations_str 83 | " #{@base_class} => [#{@associations.map(&:inspect).join(', ')}]" 84 | end 85 | 86 | def associations_str 87 | ".includes(#{@associations.map { |a| a.to_s.to_sym } 88 | .inspect})" 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/bullet/notification/counter_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Notification 5 | class CounterCache < Base 6 | def body 7 | klazz_associations_str 8 | end 9 | 10 | def title 11 | 'Need Counter Cache with Active Record size' 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bullet/notification/n_plus_one_query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Notification 5 | class NPlusOneQuery < Base 6 | def initialize(callers, base_class, associations, path = nil) 7 | super(base_class, associations, path) 8 | 9 | @callers = callers 10 | end 11 | 12 | def body 13 | "#{klazz_associations_str}\n Add to your query: #{associations_str}" 14 | end 15 | 16 | def title 17 | "USE eager loading #{@path ? "in #{@path}" : 'detected'}" 18 | end 19 | 20 | def notification_data 21 | super.merge(backtrace: []) 22 | end 23 | 24 | protected 25 | 26 | def call_stack_messages 27 | (['Call stack'] + @callers).join("\n ") 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/bullet/notification/unused_eager_loading.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Notification 5 | class UnusedEagerLoading < Base 6 | def initialize(callers, base_class, associations, path = nil) 7 | super(base_class, associations, path) 8 | 9 | @callers = callers 10 | end 11 | 12 | def body 13 | "#{klazz_associations_str}\n Remove from your query: #{associations_str}" 14 | end 15 | 16 | def title 17 | "AVOID eager loading #{@path ? "in #{@path}" : 'detected'}" 18 | end 19 | 20 | def notification_data 21 | super.merge(backtrace: []) 22 | end 23 | 24 | protected 25 | 26 | def call_stack_messages 27 | (['Call stack'] + @callers).join("\n ") 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/bullet/notification_collector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | module Bullet 6 | class NotificationCollector 7 | attr_reader :collection 8 | 9 | def initialize 10 | reset 11 | end 12 | 13 | def reset 14 | @collection = Set.new 15 | end 16 | 17 | def add(value) 18 | @collection << value 19 | end 20 | 21 | def notifications_present? 22 | !@collection.empty? 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/bullet/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack/request' 4 | require 'json' 5 | require 'cgi' 6 | 7 | module Bullet 8 | class Rack 9 | include Dependency 10 | 11 | NONCE_MATCHER = /(script|style)-src .*'nonce-(?[A-Za-z0-9+\/]+={0,2})'/ 12 | 13 | def initialize(app) 14 | @app = app 15 | end 16 | 17 | def call(env) 18 | return @app.call(env) unless Bullet.enable? 19 | 20 | Bullet.start_request 21 | status, headers, response = @app.call(env) 22 | 23 | response_body = nil 24 | 25 | if Bullet.notification? || Bullet.always_append_html_body 26 | request = ::Rack::Request.new(env) 27 | if Bullet.inject_into_page? && !skip_html_injection?(request) && !file?(headers) && !sse?(headers) && !empty?(response) && status == 200 28 | if html_request?(headers, response) 29 | response_body = response_body(response) 30 | 31 | with_security_policy_nonce(headers) do |nonce| 32 | response_body = append_to_html_body(response_body, footer_note(nonce)) if Bullet.add_footer 33 | response_body = append_to_html_body(response_body, Bullet.gather_inline_notifications) 34 | if Bullet.add_footer && !Bullet.skip_http_headers 35 | response_body = append_to_html_body(response_body, xhr_script(nonce)) 36 | end 37 | end 38 | 39 | headers['Content-Length'] = response_body.bytesize.to_s 40 | elsif !Bullet.skip_http_headers 41 | set_header(headers, 'X-bullet-footer-text', Bullet.footer_info.uniq) if Bullet.add_footer 42 | set_header(headers, 'X-bullet-console-text', Bullet.text_notifications) if Bullet.console_enabled? 43 | end 44 | end 45 | Bullet.perform_out_of_channel_notifications(env) 46 | end 47 | [status, headers, response_body ? [response_body] : response] 48 | ensure 49 | Bullet.end_request 50 | end 51 | 52 | # fix issue if response's body is a Proc 53 | def empty?(response) 54 | # response may be ["Not Found"], ["Move Permanently"], etc, but 55 | # those should not happen if the status is 200 56 | return true if !response.respond_to?(:body) && !response.respond_to?(:first) 57 | 58 | body = response_body(response) 59 | body.nil? || body.empty? 60 | end 61 | 62 | def append_to_html_body(response_body, content) 63 | body = response_body.dup 64 | content = content.html_safe if content.respond_to?(:html_safe) 65 | if body.include?('') 66 | position = body.rindex('') 67 | body.insert(position, content) 68 | else 69 | body << content 70 | end 71 | end 72 | 73 | def footer_note(nonce = nil) 74 | %() 75 | end 76 | 77 | # Make footer styles work with ContentSecurityPolicy style-src as self 78 | def footer_style(nonce = nil) 79 | css = <<~CSS 80 | details#bullet-footer {cursor: pointer; position: fixed; left: 0px; bottom: 0px; z-index: 9999; background: #fdf2f2; color: #9b1c1c; font-size: 12px; border-radius: 0px 8px 0px 0px; border: 1px solid #9b1c1c;} 81 | details#bullet-footer summary {font-weight: 600; padding: 2px 8px;} 82 | details#bullet-footer div {padding: 8px; border-top: 1px solid #9b1c1c;} 83 | CSS 84 | if nonce 85 | %() 86 | else 87 | %() 88 | end 89 | end 90 | 91 | def set_header(headers, header_name, header_array) 92 | # Many proxy applications such as Nginx and AWS ELB limit 93 | # the size a header to 8KB, so truncate the list of reports to 94 | # be under that limit 95 | header_array.pop while JSON.generate(header_array).length > 8 * 1024 96 | headers[header_name] = JSON.generate(header_array) 97 | end 98 | 99 | def skip_html_injection?(request) 100 | query_string = request.env['QUERY_STRING'] 101 | return false if query_string.nil? || query_string.empty? 102 | 103 | params = simple_parse_query_string(query_string) 104 | params['skip_html_injection'] == 'true' 105 | end 106 | 107 | # Simple query string parser 108 | def simple_parse_query_string(query_string) 109 | params = {} 110 | query_string.split('&').each do |pair| 111 | key, value = pair.split('=', 2).map { |s| CGI.unescape(s) } 112 | params[key] = value if key && !key.empty? 113 | end 114 | params 115 | end 116 | 117 | def file?(headers) 118 | headers['Content-Transfer-Encoding'] == 'binary' || headers['Content-Disposition'] 119 | end 120 | 121 | def sse?(headers) 122 | headers['Content-Type'] == 'text/event-stream' 123 | end 124 | 125 | def html_request?(headers, response) 126 | headers['Content-Type']&.include?('text/html') 127 | end 128 | 129 | def response_body(response) 130 | if response.respond_to?(:body) 131 | Array === response.body ? response.body.first : response.body 132 | elsif response.respond_to?(:first) 133 | response.first 134 | end 135 | end 136 | 137 | private 138 | 139 | def footer_console_message(nonce = nil) 140 | if Bullet.console_enabled? 141 | footer = %(
See 'Uniform Notifier' in JS Console for Stacktrace) 142 | css = "details#bullet-footer #console-message {font-style: italic;}" 143 | style = 144 | if nonce 145 | %() 146 | else 147 | %() 148 | end 149 | 150 | footer + style 151 | end 152 | end 153 | 154 | # Make footer work for XHR requests by appending data to the footer 155 | def xhr_script(nonce = nil) 156 | script = File.read("#{__dir__}/bullet_xhr.js") 157 | 158 | if nonce 159 | "" 160 | else 161 | "" 162 | end 163 | end 164 | 165 | def with_security_policy_nonce(headers) 166 | csp = headers['Content-Security-Policy'] || headers['Content-Security-Policy-Report-Only'] || '' 167 | matched = csp.match(NONCE_MATCHER) 168 | nonce = matched[:nonce] if matched 169 | 170 | if nonce 171 | console_enabled = UniformNotifier.console 172 | alert_enabled = UniformNotifier.alert 173 | 174 | UniformNotifier.console = { attributes: { nonce: nonce } } if console_enabled 175 | UniformNotifier.alert = { attributes: { nonce: nonce } } if alert_enabled 176 | 177 | yield nonce 178 | 179 | UniformNotifier.console = console_enabled 180 | UniformNotifier.alert = alert_enabled 181 | else 182 | yield 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/bullet/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Registry 5 | autoload :Base, 'bullet/registry/base' 6 | autoload :Object, 'bullet/registry/object' 7 | autoload :Association, 'bullet/registry/association' 8 | autoload :CallStack, 'bullet/registry/call_stack' 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/bullet/registry/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Registry 5 | class Association < Base 6 | def merge(base, associations) 7 | @registry.merge!(base => associations) 8 | end 9 | 10 | def similarly_associated(base, associations) 11 | @registry.select { |key, value| key.include?(base) && value == associations } 12 | .collect(&:first).flatten 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/bullet/registry/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Registry 5 | class Base 6 | attr_reader :registry 7 | 8 | def initialize 9 | @registry = {} 10 | end 11 | 12 | def [](key) 13 | @registry[key] 14 | end 15 | 16 | def each(&block) 17 | @registry.each(&block) 18 | end 19 | 20 | def delete(base) 21 | @registry.delete(base) 22 | end 23 | 24 | def select(*args, &block) 25 | @registry.select(*args, &block) 26 | end 27 | 28 | def add(key, value) 29 | @registry[key] ||= Set.new 30 | if value.is_a? Array 31 | @registry[key] += value 32 | else 33 | @registry[key] << value 34 | end 35 | end 36 | 37 | def include?(key, value) 38 | key?(key) && @registry[key].include?(value) 39 | end 40 | 41 | def key?(key) 42 | @registry.key?(key) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/bullet/registry/call_stack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Registry 5 | class CallStack < Base 6 | # remembers found association backtrace 7 | def add(key) 8 | @registry[key] ||= Thread.current.backtrace 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/bullet/registry/object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | using Bullet::Ext::Object 4 | using Bullet::Ext::String 5 | 6 | module Bullet 7 | module Registry 8 | class Object < Base 9 | def add(bullet_key) 10 | super(bullet_key.bullet_class_name, bullet_key) 11 | end 12 | 13 | def include?(bullet_key) 14 | super(bullet_key.bullet_class_name, bullet_key) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/bullet/stack_trace_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | 5 | using Bullet::Ext::Object 6 | 7 | module Bullet 8 | module StackTraceFilter 9 | VENDOR_PATH = '/vendor' 10 | 11 | # @param bullet_key[String] - use this to get stored call stack from call_stacks object. 12 | def caller_in_project(bullet_key = nil) 13 | vendor_root = Bullet.app_root + VENDOR_PATH 14 | bundler_path = Bundler.bundle_path.to_s 15 | select_caller_locations(bullet_key) do |location| 16 | caller_path = location_as_path(location) 17 | caller_path.include?(Bullet.app_root) && !caller_path.include?(vendor_root) && 18 | !caller_path.include?(bundler_path) || Bullet.stacktrace_includes.any? { |include_pattern| 19 | pattern_matches?(location, include_pattern) 20 | } 21 | end 22 | end 23 | 24 | def excluded_stacktrace_path? 25 | Bullet.stacktrace_excludes.any? do |exclude_pattern| 26 | caller_in_project.any? { |location| pattern_matches?(location, exclude_pattern) } 27 | end 28 | end 29 | 30 | private 31 | 32 | def pattern_matches?(location, pattern) 33 | path = location_as_path(location) 34 | case pattern 35 | when Array 36 | pattern_path = pattern.first 37 | filter = pattern.last 38 | return false unless pattern_matches?(location, pattern_path) 39 | 40 | case filter 41 | when Range 42 | filter.include?(location.lineno) 43 | when Integer 44 | filter == location.lineno 45 | when String 46 | filter == location.base_label 47 | end 48 | when String 49 | path.include?(pattern) 50 | when Regexp 51 | path =~ pattern 52 | end 53 | end 54 | 55 | def location_as_path(location) 56 | return location if location.is_a?(String) 57 | 58 | location.absolute_path.to_s 59 | end 60 | 61 | def select_caller_locations(bullet_key = nil) 62 | call_stack = bullet_key ? call_stacks[bullet_key] : caller_locations 63 | 64 | call_stack.select { |location| yield location } 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/bullet/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | VERSION = '8.0.8' 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/bullet/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bullet 4 | module Generators 5 | class InstallGenerator < ::Rails::Generators::Base 6 | desc <<~DESC 7 | Description: 8 | Enable bullet in development/test for your application. 9 | DESC 10 | 11 | def enable_in_development 12 | environment(nil, env: 'development') do 13 | <<~FILE 14 | config.after_initialize do 15 | Bullet.enable = true 16 | Bullet.alert = true 17 | Bullet.bullet_logger = true 18 | Bullet.console = true 19 | Bullet.rails_logger = true 20 | Bullet.add_footer = true 21 | end 22 | 23 | FILE 24 | end 25 | 26 | say 'Enabled bullet in config/environments/development.rb' 27 | end 28 | 29 | def enable_in_test 30 | return unless yes?('Would you like to enable bullet in test environment? (y/n)') 31 | 32 | environment(nil, env: 'test') do 33 | <<~FILE 34 | config.after_initialize do 35 | Bullet.enable = true 36 | Bullet.bullet_logger = true 37 | Bullet.raise = true # raise an error if n+1 query occurs 38 | end 39 | 40 | FILE 41 | end 42 | 43 | say 'Enabled bullet in config/environments/test.rb' 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /perf/benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << 'lib' 4 | require 'benchmark' 5 | require 'rails' 6 | require 'active_record' 7 | require 'activerecord-import' 8 | require 'bullet' 9 | 10 | begin 11 | require 'perftools' 12 | rescue LoadError 13 | puts "Could not load perftools.rb, profiling won't be possible" 14 | end 15 | 16 | class Post < ActiveRecord::Base 17 | belongs_to :user 18 | has_many :comments 19 | end 20 | 21 | class Comment < ActiveRecord::Base 22 | belongs_to :user 23 | belongs_to :post 24 | end 25 | 26 | class User < ActiveRecord::Base 27 | has_many :posts 28 | has_many :comments 29 | end 30 | 31 | # create database bullet_benchmark; 32 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: 'bullet_benchmark') 33 | 34 | ActiveRecord::Base.connection.tables.each { |table| ActiveRecord::Base.connection.drop_table(table) } 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 = 1_000 56 | comments_size = 10_000 57 | users = [] 58 | users_size.times { |i| users << User.new(name: "user#{i}") } 59 | User.import users 60 | users = User.all 61 | 62 | posts = [] 63 | posts_size.times { |i| posts << Post.new(title: "Title #{i}", body: "Body #{i}", user: users[i % 100]) } 64 | Post.import posts 65 | posts = Post.all 66 | 67 | comments = [] 68 | comments_size.times { |i| comments << Comment.new(body: "Comment #{i}", post: posts[i % 1_000], user: users[i % 100]) } 69 | Comment.import comments 70 | 71 | puts 'Start benchmarking...' 72 | 73 | Bullet.enable = true 74 | 75 | Benchmark.bm(70) do |bm| 76 | bm.report("Querying & Iterating #{posts_size} Posts with #{comments_size} Comments and #{users_size} Users") do 77 | 10.times do 78 | Bullet.start_request 79 | Post.includes(:user, comments: :user).each do |p| 80 | p.title 81 | p.user.name 82 | p.comments.each do |c| 83 | c.body 84 | c.user.name 85 | end 86 | end 87 | Bullet.end_request 88 | end 89 | end 90 | end 91 | 92 | puts 'End benchmarking...' 93 | 94 | # Run benchmark with bundler 95 | # 96 | # bundle exec ruby perf/benchmark.rb 97 | # 98 | # bullet 2.3.0 with rails 3.2.2 99 | # user system total real 100 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 16.460000 0.190000 16.650000 ( 16.968246) 101 | # 102 | # bullet 2.3.0 with rails 3.1.4 103 | # user system total real 104 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 14.600000 0.130000 14.730000 ( 14.937590) 105 | # 106 | # bullet 2.3.0 with rails 3.0.12 107 | # user system total real 108 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 26.120000 0.430000 26.550000 ( 27.179304) 109 | # 110 | # 111 | # bullet 2.2.1 with rails 3.0.12 112 | # user system total real 113 | # Querying & Iterating 1000 Posts with 10000 Comments and 100 Users 29.970000 0.270000 30.240000 ( 30.452083) 114 | -------------------------------------------------------------------------------- /rails/init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bullet' 4 | -------------------------------------------------------------------------------- /spec/bullet/detector/association_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | using Bullet::Ext::Object 6 | 7 | module Bullet 8 | module Detector 9 | describe Association do 10 | before :all do 11 | @post1 = Post.first 12 | @post2 = Post.last 13 | end 14 | 15 | context '.add_object_association' do 16 | it 'should add object, associations pair' do 17 | Association.add_object_associations(@post1, :associations) 18 | expect(Association.send(:object_associations)).to be_include(@post1.bullet_key, :associations) 19 | end 20 | end 21 | 22 | context '.add_call_object_associations' do 23 | it 'should add call object, associations pair' do 24 | Association.add_call_object_associations(@post1, :associations) 25 | expect(Association.send(:call_object_associations)).to be_include(@post1.bullet_key, :associations) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/bullet/detector/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Detector 7 | describe Base do 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/bullet/detector/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | using Bullet::Ext::Object 6 | 7 | module Bullet 8 | module Detector 9 | describe CounterCache do 10 | before :all do 11 | @post1 = Post.first 12 | @post2 = Post.last 13 | end 14 | 15 | context '.add_counter_cache' do 16 | it 'should create notification if conditions met' do 17 | expect(CounterCache).to receive(:conditions_met?).with(@post1, %i[comments]).and_return(true) 18 | expect(CounterCache).to receive(:create_notification).with('Post', %i[comments]) 19 | CounterCache.add_counter_cache(@post1, %i[comments]) 20 | end 21 | 22 | it 'should not create notification if conditions not met' do 23 | expect(CounterCache).to receive(:conditions_met?).with(@post1, %i[comments]).and_return(false) 24 | expect(CounterCache).to receive(:create_notification).never 25 | CounterCache.add_counter_cache(@post1, %i[comments]) 26 | end 27 | end 28 | 29 | context '.add_possible_objects' do 30 | it 'should add possible objects' do 31 | CounterCache.add_possible_objects([@post1, @post2]) 32 | expect(CounterCache.possible_objects).to be_include(@post1.bullet_key) 33 | expect(CounterCache.possible_objects).to be_include(@post2.bullet_key) 34 | end 35 | 36 | it 'should add impossible object' do 37 | CounterCache.add_impossible_object(@post1) 38 | expect(CounterCache.impossible_objects).to be_include(@post1.bullet_key) 39 | end 40 | end 41 | 42 | context '.conditions_met?' do 43 | it 'should be true when object is possible, not impossible' do 44 | CounterCache.add_possible_objects(@post1) 45 | expect(CounterCache.conditions_met?(@post1, :associations)).to eq true 46 | end 47 | 48 | it 'should be false when object is not possible' do 49 | expect(CounterCache.conditions_met?(@post1, :associations)).to eq false 50 | end 51 | 52 | it 'should be false when object is possible, and impossible' do 53 | CounterCache.add_possible_objects(@post1) 54 | CounterCache.add_impossible_object(@post1) 55 | expect(CounterCache.conditions_met?(@post1, :associations)).to eq false 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/bullet/detector/n_plus_one_query_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'ostruct' 5 | 6 | using Bullet::Ext::Object 7 | 8 | module Bullet 9 | module Detector 10 | describe NPlusOneQuery do 11 | before(:all) do 12 | @post = Post.first 13 | @post2 = Post.last 14 | end 15 | 16 | context '.call_association' do 17 | it 'should add call_object_associations' do 18 | expect(NPlusOneQuery).to receive(:add_call_object_associations).with(@post, :associations) 19 | NPlusOneQuery.call_association(@post, :associations) 20 | end 21 | end 22 | 23 | context '.possible?' do 24 | it 'should be true if possible_objects contain' do 25 | NPlusOneQuery.add_possible_objects(@post) 26 | expect(NPlusOneQuery.possible?(@post)).to eq true 27 | end 28 | end 29 | 30 | context '.impossible?' do 31 | it 'should be true if impossible_objects contain' do 32 | NPlusOneQuery.add_impossible_object(@post) 33 | expect(NPlusOneQuery.impossible?(@post)).to eq true 34 | end 35 | end 36 | 37 | context '.association?' do 38 | it 'should be true if object, associations pair is already existed' do 39 | NPlusOneQuery.add_object_associations(@post, :association) 40 | expect(NPlusOneQuery.association?(@post, :association)).to eq true 41 | end 42 | 43 | it 'should be false if object, association pair is not existed' do 44 | NPlusOneQuery.add_object_associations(@post, :association1) 45 | expect(NPlusOneQuery.association?(@post, :association2)).to eq false 46 | end 47 | end 48 | 49 | context '.conditions_met?' do 50 | it 'should be true if object is possible, not impossible and object, associations pair is not already existed' do 51 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(true) 52 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(false) 53 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(false) 54 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq true 55 | end 56 | 57 | it 'should be false if object is not possible, not impossible and object, associations pair is not already existed' do 58 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(false) 59 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(false) 60 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(false) 61 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq false 62 | end 63 | 64 | it 'should be false if object is possible, but impossible and object, associations pair is not already existed' do 65 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(true) 66 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(true) 67 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(false) 68 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq false 69 | end 70 | 71 | it 'should be false if object is possible, not impossible and object, associations pair is already existed' do 72 | allow(NPlusOneQuery).to receive(:possible?).with(@post).and_return(true) 73 | allow(NPlusOneQuery).to receive(:impossible?).with(@post).and_return(false) 74 | allow(NPlusOneQuery).to receive(:association?).with(@post, :associations).and_return(true) 75 | expect(NPlusOneQuery.conditions_met?(@post, :associations)).to eq false 76 | end 77 | end 78 | 79 | context '.call_association' do 80 | it 'should create notification if conditions met' do 81 | expect(NPlusOneQuery).to receive(:conditions_met?).with(@post, :association).and_return(true) 82 | expect(NPlusOneQuery).to receive(:caller_in_project).and_return(%w[caller]) 83 | expect(NPlusOneQuery).to receive(:create_notification).with(%w[caller], 'Post', :association) 84 | NPlusOneQuery.call_association(@post, :association) 85 | end 86 | 87 | it 'should not create notification if conditions not met' do 88 | expect(NPlusOneQuery).to receive(:conditions_met?).with(@post, :association).and_return(false) 89 | expect(NPlusOneQuery).not_to receive(:caller_in_project!) 90 | expect(NPlusOneQuery).not_to receive(:create_notification).with('Post', :association) 91 | NPlusOneQuery.call_association(@post, :association) 92 | end 93 | 94 | context 'stacktrace_excludes' do 95 | before { Bullet.stacktrace_excludes = [/def/] } 96 | after { Bullet.stacktrace_excludes = nil } 97 | 98 | it 'should not create notification when stacktrace contains paths that are in the exclude list' do 99 | in_project = OpenStruct.new(absolute_path: File.join(Dir.pwd, 'abc', 'abc.rb')) 100 | included_path = OpenStruct.new(absolute_path: '/ghi/ghi.rb') 101 | excluded_path = OpenStruct.new(absolute_path: '/def/def.rb') 102 | 103 | expect(NPlusOneQuery).to receive(:caller_locations).and_return([in_project, included_path, excluded_path]) 104 | expect(NPlusOneQuery).to_not receive(:create_notification) 105 | NPlusOneQuery.call_association(@post, :association) 106 | end 107 | 108 | # just a sanity spec to make sure the following spec works correctly 109 | it "should create notification when stacktrace contains methods that aren't in the exclude list" do 110 | method = NPlusOneQuery.method(:excluded_stacktrace_path?).source_location 111 | in_project = OpenStruct.new(absolute_path: File.join(Dir.pwd, 'abc', 'abc.rb')) 112 | excluded_path = OpenStruct.new(absolute_path: method.first, lineno: method.last) 113 | 114 | expect(NPlusOneQuery).to receive(:caller_locations).at_least(1).and_return([in_project, excluded_path]) 115 | expect(NPlusOneQuery).to receive(:conditions_met?).and_return(true) 116 | expect(NPlusOneQuery).to receive(:create_notification) 117 | NPlusOneQuery.call_association(@post, :association) 118 | end 119 | 120 | it 'should not create notification when stacktrace contains methods that are in the exclude list' do 121 | method = NPlusOneQuery.method(:excluded_stacktrace_path?).source_location 122 | Bullet.stacktrace_excludes = [method] 123 | in_project = OpenStruct.new(absolute_path: File.join(Dir.pwd, 'abc', 'abc.rb')) 124 | excluded_path = OpenStruct.new(absolute_path: method.first, lineno: method.last) 125 | 126 | expect(NPlusOneQuery).to receive(:caller_locations).and_return([in_project, excluded_path]) 127 | expect(NPlusOneQuery).to_not receive(:create_notification) 128 | NPlusOneQuery.call_association(@post, :association) 129 | end 130 | end 131 | end 132 | 133 | context '.add_possible_objects' do 134 | it 'should add possible objects' do 135 | NPlusOneQuery.add_possible_objects([@post, @post2]) 136 | expect(NPlusOneQuery.possible_objects).to be_include(@post.bullet_key) 137 | expect(NPlusOneQuery.possible_objects).to be_include(@post2.bullet_key) 138 | end 139 | 140 | it 'should not raise error if object is nil' do 141 | expect { NPlusOneQuery.add_possible_objects(nil) } 142 | .not_to raise_error 143 | end 144 | end 145 | 146 | context '.add_impossible_object' do 147 | it 'should add impossible object' do 148 | NPlusOneQuery.add_impossible_object(@post) 149 | expect(NPlusOneQuery.impossible_objects).to be_include(@post.bullet_key) 150 | end 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/bullet/detector/unused_eager_loading_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | using Bullet::Ext::Object 6 | 7 | module Bullet 8 | module Detector 9 | describe UnusedEagerLoading do 10 | before(:all) do 11 | @post = Post.first 12 | @post2 = Post.all[1] 13 | @post3 = Post.last 14 | end 15 | 16 | context '.call_associations' do 17 | it 'should get empty array if eager_loadings' do 18 | expect(UnusedEagerLoading.send(:call_associations, @post.bullet_key, Set.new([:association]))).to be_empty 19 | end 20 | 21 | it 'should get call associations if object and association are both in eager_loadings and call_object_associations' do 22 | UnusedEagerLoading.add_eager_loadings([@post], :association) 23 | UnusedEagerLoading.add_call_object_associations(@post, :association) 24 | expect(UnusedEagerLoading.send(:call_associations, @post.bullet_key, Set.new([:association]))).to eq( 25 | [:association] 26 | ) 27 | end 28 | 29 | it 'should not get call associations if not exist in call_object_associations' do 30 | UnusedEagerLoading.add_eager_loadings([@post], :association) 31 | expect(UnusedEagerLoading.send(:call_associations, @post.bullet_key, Set.new([:association]))).to be_empty 32 | end 33 | end 34 | 35 | context '.diff_object_associations' do 36 | it 'should return associations not exist in call_association' do 37 | expect(UnusedEagerLoading.send(:diff_object_associations, @post.bullet_key, Set.new([:association]))).to eq( 38 | [:association] 39 | ) 40 | end 41 | 42 | it 'should return empty if associations exist in call_association' do 43 | UnusedEagerLoading.add_eager_loadings([@post], :association) 44 | UnusedEagerLoading.add_call_object_associations(@post, :association) 45 | expect( 46 | UnusedEagerLoading.send(:diff_object_associations, @post.bullet_key, Set.new([:association])) 47 | ).to be_empty 48 | end 49 | end 50 | 51 | context '.check_unused_preload_associations' do 52 | let(:paths) { %w[/dir1 /dir1/subdir] } 53 | it 'should create notification if object_association_diff is not empty' do 54 | UnusedEagerLoading.add_object_associations(@post, :association) 55 | allow(UnusedEagerLoading).to receive(:caller_in_project).and_return(paths) 56 | expect(UnusedEagerLoading).to receive(:create_notification).with(paths, 'Post', [:association]) 57 | UnusedEagerLoading.check_unused_preload_associations 58 | end 59 | 60 | it 'should not create notification if object_association_diff is empty' do 61 | UnusedEagerLoading.add_object_associations(@post, :association) 62 | UnusedEagerLoading.add_eager_loadings([@post], :association) 63 | UnusedEagerLoading.add_call_object_associations(@post, :association) 64 | expect( 65 | UnusedEagerLoading.send(:diff_object_associations, @post.bullet_key, Set.new([:association])) 66 | ).to be_empty 67 | expect(UnusedEagerLoading).not_to receive(:create_notification).with('Post', [:association]) 68 | UnusedEagerLoading.check_unused_preload_associations 69 | end 70 | 71 | it 'should create call stack for notification' do 72 | UnusedEagerLoading.add_object_associations(@post, :association) 73 | expect(UnusedEagerLoading.send(:call_stacks).registry).not_to be_empty 74 | end 75 | end 76 | 77 | context '.add_eager_loadings' do 78 | it 'should add objects, associations pair when eager_loadings are empty' do 79 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :associations) 80 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include( 81 | [@post.bullet_key, @post2.bullet_key], 82 | :associations 83 | ) 84 | end 85 | 86 | it 'should add objects, associations pair for existing eager_loadings' do 87 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association1) 88 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association2) 89 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include( 90 | [@post.bullet_key, @post2.bullet_key], 91 | :association1 92 | ) 93 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include( 94 | [@post.bullet_key, @post2.bullet_key], 95 | :association2 96 | ) 97 | end 98 | 99 | it 'should merge objects, associations pair for existing eager_loadings' do 100 | UnusedEagerLoading.add_eager_loadings([@post], :association1) 101 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association2) 102 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association1) 103 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association2) 104 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post2.bullet_key], :association2) 105 | end 106 | 107 | it 'should vmerge objects recursively, associations pair for existing eager_loadings' do 108 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association1) 109 | UnusedEagerLoading.add_eager_loadings([@post, @post3], :association1) 110 | UnusedEagerLoading.add_eager_loadings([@post, @post3], :association2) 111 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association1) 112 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association2) 113 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post2.bullet_key], :association1) 114 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post3.bullet_key], :association1) 115 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post3.bullet_key], :association2) 116 | end 117 | 118 | it 'should delete objects, associations pair for existing eager_loadings' do 119 | UnusedEagerLoading.add_eager_loadings([@post, @post2], :association1) 120 | UnusedEagerLoading.add_eager_loadings([@post], :association2) 121 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association1) 122 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post.bullet_key], :association2) 123 | expect(UnusedEagerLoading.send(:eager_loadings)).to be_include([@post2.bullet_key], :association1) 124 | end 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/bullet/ext/object_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | using Bullet::Ext::Object 6 | 7 | describe Object do 8 | context 'bullet_key' do 9 | it 'should return class and id composition' do 10 | post = Post.first 11 | expect(post.bullet_key).to eq("Post:#{post.id}") 12 | end 13 | 14 | if mongoid? 15 | it 'should return class with namespace and id composition' do 16 | post = Mongoid::Post.first 17 | expect(post.bullet_key).to eq("Mongoid::Post:#{post.id}") 18 | end 19 | end 20 | end 21 | 22 | context 'bullet_primary_key_value' do 23 | it 'should return id' do 24 | post = Post.first 25 | expect(post.bullet_primary_key_value).to eq(post.id) 26 | end 27 | 28 | it 'should return primary key value' do 29 | Post.primary_key = 'name' 30 | post = Post.first 31 | expect(post.bullet_primary_key_value).to eq(post.name) 32 | Post.primary_key = 'id' 33 | end 34 | 35 | it 'should return value for multiple primary keys from the composite_primary_key gem' do 36 | allow(Post).to receive(:primary_keys).and_return(%i[category_id writer_id]) 37 | post = Post.first 38 | expect(post.bullet_primary_key_value).to eq("#{post.category_id},#{post.writer_id}") 39 | end 40 | 41 | it 'should return empty value for multiple primary keys without values' do 42 | allow(Post).to receive(:primary_keys).and_return(%i[category_id writer_id]) 43 | post = Post.select('1 as myfield').first 44 | expect(post.bullet_primary_key_value).to eq("") 45 | end 46 | 47 | if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new('7.1') 48 | it 'should return value for multiple primary keys from ActiveRecord 7.1' do 49 | allow(Post).to receive(:primary_key).and_return(%i[category_id writer_id]) 50 | post = Post.first 51 | 52 | expect(post.bullet_primary_key_value).to eq("#{post.category_id},#{post.writer_id}") 53 | end 54 | end 55 | 56 | it 'should return nil for unpersisted records' do 57 | post = Post.new(id: 123) 58 | expect(post.bullet_primary_key_value).to be_nil 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/bullet/ext/string_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | using Bullet::Ext::String 6 | 7 | describe String do 8 | context 'bullet_class_name' do 9 | it 'should only return class name' do 10 | expect('Post:1'.bullet_class_name).to eq('Post') 11 | end 12 | 13 | it 'should return class name with namespace' do 14 | expect('Mongoid::Post:1234567890'.bullet_class_name).to eq('Mongoid::Post') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/bullet/notification/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Notification 7 | describe Base do 8 | subject { Base.new(Post, %i[comments votes]) } 9 | 10 | context '#title' do 11 | it 'should raise NoMethodError' do 12 | expect { subject.title } 13 | .to raise_error(NoMethodError) 14 | end 15 | end 16 | 17 | context '#body' do 18 | it 'should raise NoMethodError' do 19 | expect { subject.body } 20 | .to raise_error(NoMethodError) 21 | end 22 | end 23 | 24 | context '#whoami' do 25 | it 'should display user name' do 26 | user = `whoami`.chomp 27 | expect(subject.whoami).to eq("user: #{user}") 28 | end 29 | 30 | it 'should leverage ENV parameter' do 31 | temp_env_variable('USER', 'bogus') { expect(subject.whoami).to eq('user: bogus') } 32 | end 33 | 34 | it 'should return blank if no user available' do 35 | temp_env_variable('USER', '') do 36 | expect(subject).to receive(:`).with('whoami').and_return('') 37 | expect(subject.whoami).to eq('') 38 | end 39 | end 40 | 41 | it 'should return blank if whoami is not available' do 42 | temp_env_variable('USER', '') do 43 | expect(subject).to receive(:`).with('whoami').and_raise(Errno::ENOENT) 44 | expect(subject.whoami).to eq('') 45 | end 46 | end 47 | 48 | def temp_env_variable(name, value) 49 | old_value = ENV[name] 50 | ENV[name] = value 51 | yield 52 | ensure 53 | ENV[name] = old_value 54 | end 55 | end 56 | 57 | context '#body_with_caller' do 58 | it 'should return body' do 59 | allow(subject).to receive(:body).and_return('body') 60 | allow(subject).to receive(:call_stack_messages).and_return('call_stack_messages') 61 | expect(subject.body_with_caller).to eq("body\ncall_stack_messages\n") 62 | end 63 | end 64 | 65 | context '#notification_data' do 66 | it 'should return notification data' do 67 | allow(subject).to receive(:whoami).and_return('whoami') 68 | allow(subject).to receive(:url).and_return('url') 69 | allow(subject).to receive(:title).and_return('title') 70 | allow(subject).to receive(:body_with_caller).and_return('body_with_caller') 71 | expect(subject.notification_data).to eq(user: 'whoami', url: 'url', title: 'title', body: 'body_with_caller') 72 | end 73 | 74 | context 'when skip_user_in_notification is true' do 75 | before { allow(Bullet).to receive(:skip_user_in_notification).and_return(true) } 76 | 77 | it 'should return notification data without user' do 78 | allow(subject).to receive(:url).and_return('url') 79 | allow(subject).to receive(:title).and_return('title') 80 | allow(subject).to receive(:body_with_caller).and_return('body_with_caller') 81 | 82 | expect(subject.notification_data).to eq(url: 'url', title: 'title', body: 'body_with_caller') 83 | end 84 | end 85 | end 86 | 87 | context '#notify_inline' do 88 | it 'should send full_notice to notifier' do 89 | notifier = double 90 | allow(subject).to receive(:notifier).and_return(notifier) 91 | allow(subject).to receive(:notification_data).and_return({ foo: :bar }) 92 | expect(notifier).to receive(:inline_notify).with({ foo: :bar }) 93 | subject.notify_inline 94 | end 95 | end 96 | 97 | context '#notify_out_of_channel' do 98 | it 'should send full_out_of_channel to notifier' do 99 | notifier = double 100 | allow(subject).to receive(:notifier).and_return(notifier) 101 | allow(subject).to receive(:notification_data).and_return({ foo: :bar }) 102 | expect(notifier).to receive(:out_of_channel_notify).with({ foo: :bar }) 103 | subject.notify_out_of_channel 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/bullet/notification/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Notification 7 | describe CounterCache do 8 | subject { CounterCache.new(Post, %i[comments votes]) } 9 | 10 | it { expect(subject.body).to eq(' Post => [:comments, :votes]') } 11 | it { expect(subject.title).to eq('Need Counter Cache with Active Record size') } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/bullet/notification/n_plus_one_query_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Notification 7 | describe NPlusOneQuery do 8 | subject { NPlusOneQuery.new([%w[caller1 caller2]], Post, %i[comments votes], 'path') } 9 | 10 | it do 11 | expect(subject.body_with_caller).to eq( 12 | " Post => [:comments, :votes]\n Add to your query: .includes([:comments, :votes])\nCall stack\n caller1\n caller2\n" 13 | ) 14 | end 15 | it do 16 | expect([subject.body_with_caller, subject.body_with_caller]).to eq( 17 | [ 18 | " Post => [:comments, :votes]\n Add to your query: .includes([:comments, :votes])\nCall stack\n caller1\n caller2\n", 19 | " Post => [:comments, :votes]\n Add to your query: .includes([:comments, :votes])\nCall stack\n caller1\n caller2\n" 20 | ] 21 | ) 22 | end 23 | it do 24 | expect(subject.body).to eq(" Post => [:comments, :votes]\n Add to your query: .includes([:comments, :votes])") 25 | end 26 | it { expect(subject.title).to eq('USE eager loading in path') } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/bullet/notification/unused_eager_loading_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Notification 7 | describe UnusedEagerLoading do 8 | subject { UnusedEagerLoading.new([''], Post, %i[comments votes], 'path') } 9 | 10 | it do 11 | expect(subject.body).to eq( 12 | " Post => [:comments, :votes]\n Remove from your query: .includes([:comments, :votes])" 13 | ) 14 | end 15 | it { expect(subject.title).to eq('AVOID eager loading in path') } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/bullet/notification_collector_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | describe NotificationCollector do 7 | subject { NotificationCollector.new.tap { |collector| collector.add('value') } } 8 | 9 | context '#add' do 10 | it 'should add a value' do 11 | subject.add('value1') 12 | expect(subject.collection).to be_include('value1') 13 | end 14 | end 15 | 16 | context '#reset' do 17 | it 'should reset collector' do 18 | subject.reset 19 | expect(subject.collection).to be_empty 20 | end 21 | end 22 | 23 | context '#notifications_present?' do 24 | it 'should be true if collection is not empty' do 25 | expect(subject).to be_notifications_present 26 | end 27 | 28 | it 'should be false if collection is empty' do 29 | subject.reset 30 | expect(subject).not_to be_notifications_present 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/bullet/registry/association_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Registry 7 | describe Association do 8 | subject { Association.new.tap { |association| association.add(%w[key1 key2], 'value') } } 9 | 10 | context '#merge' do 11 | it 'should merge key/value' do 12 | subject.merge('key0', 'value0') 13 | expect(subject['key0']).to be_include('value0') 14 | end 15 | end 16 | 17 | context '#similarly_associated' do 18 | it 'should return similarly associated keys' do 19 | expect(subject.similarly_associated('key1', Set.new(%w[value]))).to eq(%w[key1 key2]) 20 | end 21 | 22 | it 'should return empty if key does not exist' do 23 | expect(subject.similarly_associated('key3', Set.new(%w[value]))).to be_empty 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/bullet/registry/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | module Registry 7 | describe Base do 8 | subject { Base.new.tap { |base| base.add('key', 'value') } } 9 | 10 | context '#[]' do 11 | it 'should get value by key' do 12 | expect(subject['key']).to eq(Set.new(%w[value])) 13 | end 14 | end 15 | 16 | context '#delete' do 17 | it 'should delete key' do 18 | subject.delete('key') 19 | expect(subject['key']).to be_nil 20 | end 21 | end 22 | 23 | context '#add' do 24 | it 'should add value with string' do 25 | subject.add('key', 'new_value') 26 | expect(subject['key']).to eq(Set.new(%w[value new_value])) 27 | end 28 | 29 | it 'should add value with array' do 30 | subject.add('key', %w[value1 value2]) 31 | expect(subject['key']).to eq(Set.new(%w[value value1 value2])) 32 | end 33 | end 34 | 35 | context '#include?' do 36 | it 'should include key/value' do 37 | expect(subject.include?('key', 'value')).to eq true 38 | end 39 | 40 | it 'should not include wrong key/value' do 41 | expect(subject.include?('key', 'val')).to eq false 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/bullet/registry/object_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | using Bullet::Ext::Object 6 | 7 | module Bullet 8 | module Registry 9 | describe Object do 10 | let(:post) { Post.first } 11 | let(:another_post) { Post.last } 12 | subject { Object.new.tap { |object| object.add(post.bullet_key) } } 13 | 14 | context '#include?' do 15 | it 'should include the object' do 16 | expect(subject).to be_include(post.bullet_key) 17 | end 18 | end 19 | 20 | context '#add' do 21 | it 'should add an object' do 22 | subject.add(another_post.bullet_key) 23 | expect(subject).to be_include(another_post.bullet_key) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/bullet/stack_trace_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Bullet 6 | RSpec.describe StackTraceFilter do 7 | let(:dummy_class) { Class.new { extend StackTraceFilter } } 8 | let(:root_path) { Dir.pwd } 9 | let(:bundler_path) { Bundler.bundle_path } 10 | 11 | describe '#caller_in_project' do 12 | it 'gets the caller in the project' do 13 | expect(dummy_class).to receive(:call_stacks).and_return( 14 | { 15 | 'Post:1' => [ 16 | File.join(root_path, 'lib/bullet.rb'), 17 | File.join(root_path, 'vendor/uniform_notifier.rb'), 18 | File.join(bundler_path, 'rack.rb') 19 | ] 20 | } 21 | ) 22 | expect(dummy_class.caller_in_project('Post:1')).to eq([File.join(root_path, 'lib/bullet.rb')]) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/bullet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Bullet do 6 | subject { Bullet } 7 | 8 | describe '#enable' do 9 | context 'enable Bullet' do 10 | before do 11 | # Bullet.enable 12 | # Do nothing. Bullet has already been enabled for the whole test suite. 13 | end 14 | 15 | it 'should be enabled' do 16 | expect(subject).to be_enable 17 | end 18 | 19 | context 'disable Bullet' do 20 | before { Bullet.enable = false } 21 | 22 | it 'should be disabled' do 23 | expect(subject).to_not be_enable 24 | end 25 | 26 | context 'enable Bullet again without patching again the orms' do 27 | before do 28 | expect(Bullet::Mongoid).not_to receive(:enable) if defined?(Bullet::Mongoid) 29 | expect(Bullet::ActiveRecord).not_to receive(:enable) if defined?(Bullet::ActiveRecord) 30 | Bullet.enable = true 31 | end 32 | 33 | it 'should be enabled again' do 34 | expect(subject).to be_enable 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | # Testing the aliases. 42 | describe '#enabled' do 43 | context 'enable Bullet' do 44 | before do 45 | # Bullet.enable 46 | # Do nothing. Bullet has already been enabled for the whole test suite. 47 | end 48 | 49 | it 'should be enabled' do 50 | expect(subject).to be_enabled 51 | end 52 | 53 | context 'disable Bullet' do 54 | before { Bullet.enabled = false } 55 | 56 | it 'should be disabled' do 57 | expect(subject).to_not be_enabled 58 | end 59 | 60 | context 'enable Bullet again without patching again the orms' do 61 | before do 62 | expect(Bullet::Mongoid).not_to receive(:enabled) if defined?(Bullet::Mongoid) 63 | expect(Bullet::ActiveRecord).not_to receive(:enabled) if defined?(Bullet::ActiveRecord) 64 | Bullet.enabled = true 65 | end 66 | 67 | it 'should be enabled again' do 68 | expect(subject).to be_enabled 69 | end 70 | end 71 | end 72 | end 73 | end 74 | 75 | describe '#start?' do 76 | context 'when bullet is disabled' do 77 | before(:each) { Bullet.enable = false } 78 | 79 | it 'should not be started' do 80 | expect(Bullet).not_to be_start 81 | end 82 | end 83 | end 84 | 85 | describe '#debug' do 86 | before(:each) { $stdout = StringIO.new } 87 | 88 | after(:each) { $stdout = STDOUT } 89 | 90 | context 'when debug is enabled' do 91 | before(:each) { ENV['BULLET_DEBUG'] = 'true' } 92 | 93 | after(:each) { ENV['BULLET_DEBUG'] = 'false' } 94 | 95 | it 'should output debug information' do 96 | Bullet.debug('debug_message', 'this is helpful information') 97 | 98 | expect($stdout.string).to eq("[Bullet][debug_message] this is helpful information\n") 99 | end 100 | end 101 | 102 | context 'when debug is disabled' do 103 | it 'should output debug information' do 104 | Bullet.debug('debug_message', 'this is helpful information') 105 | 106 | expect($stdout.string).to be_empty 107 | end 108 | end 109 | end 110 | 111 | describe '#add_safelist' do 112 | context "for 'special' class names" do 113 | it 'is added to the safelist successfully' do 114 | Bullet.add_safelist(type: :n_plus_one_query, class_name: 'Klass', association: :department) 115 | expect(Bullet.get_safelist_associations(:n_plus_one_query, 'Klass')).to include :department 116 | end 117 | end 118 | end 119 | 120 | describe '#delete_safelist' do 121 | context "for 'special' class names" do 122 | it 'is deleted from the safelist successfully' do 123 | Bullet.add_safelist(type: :n_plus_one_query, class_name: 'Klass', association: :department) 124 | Bullet.delete_safelist(type: :n_plus_one_query, class_name: 'Klass', association: :department) 125 | expect(Bullet.safelist[:n_plus_one_query]).to eq({}) 126 | end 127 | end 128 | 129 | context 'when exists multiple definitions' do 130 | it 'is deleted from the safelist successfully' do 131 | Bullet.add_safelist(type: :n_plus_one_query, class_name: 'Klass', association: :department) 132 | Bullet.add_safelist(type: :n_plus_one_query, class_name: 'Klass', association: :team) 133 | Bullet.delete_safelist(type: :n_plus_one_query, class_name: 'Klass', association: :team) 134 | expect(Bullet.get_safelist_associations(:n_plus_one_query, 'Klass')).to include :department 135 | expect(Bullet.get_safelist_associations(:n_plus_one_query, 'Klass')).to_not include :team 136 | end 137 | end 138 | end 139 | 140 | describe '#perform_out_of_channel_notifications' do 141 | let(:notification) { double } 142 | 143 | before do 144 | allow(Bullet).to receive(:for_each_active_notifier_with_notification).and_yield(notification) 145 | allow(notification).to receive(:notify_out_of_channel) 146 | end 147 | 148 | context 'when called with Rack environment hash' do 149 | let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/path', 'QUERY_STRING' => 'foo=bar' } } 150 | 151 | context "when env['REQUEST_URI'] is nil" do 152 | before { env['REQUEST_URI'] = nil } 153 | 154 | it 'should notification.url is built' do 155 | expect(notification).to receive(:url=).with('GET /path?foo=bar') 156 | Bullet.perform_out_of_channel_notifications(env) 157 | end 158 | end 159 | 160 | context "when env['REQUEST_URI'] is present" do 161 | before { env['REQUEST_URI'] = 'http://example.com/path' } 162 | 163 | it "should notification.url is env['REQUEST_URI']" do 164 | expect(notification).to receive(:url=).with('GET http://example.com/path') 165 | Bullet.perform_out_of_channel_notifications(env) 166 | end 167 | end 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/integration/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | if !mongoid? && active_record? 6 | describe Bullet::Detector::CounterCache do 7 | before(:each) { Bullet.start_request } 8 | 9 | after(:each) { Bullet.end_request } 10 | 11 | it 'should need counter cache with all cities' do 12 | Country.all.each { |country| country.cities.size } 13 | expect(Bullet.collected_counter_cache_notifications).not_to be_empty 14 | end 15 | 16 | it 'should not need counter cache if already define counter_cache' do 17 | Person.all.each { |person| person.pets.size } 18 | expect(Bullet.collected_counter_cache_notifications).to be_empty 19 | end 20 | 21 | it 'should not need counter cache with only one object' do 22 | Country.first.cities.size 23 | expect(Bullet.collected_counter_cache_notifications).to be_empty 24 | end 25 | 26 | it 'should not need counter cache without size' do 27 | Country.includes(:cities).each { |country| country.cities.empty? } 28 | expect(Bullet.collected_counter_cache_notifications).to be_empty 29 | end 30 | 31 | if ActiveRecord::VERSION::MAJOR > 4 32 | it 'should not need counter cache for has_many through' do 33 | Client.all.each { |client| client.firms.size } 34 | expect(Bullet.collected_counter_cache_notifications).to be_empty 35 | end 36 | else 37 | it 'should need counter cache for has_many through' do 38 | Client.all.each { |client| client.firms.size } 39 | expect(Bullet.collected_counter_cache_notifications).not_to be_empty 40 | end 41 | end 42 | 43 | it 'should not need counter cache with part of cities' do 44 | Country.all.each { |country| country.cities.where(name: 'first').size } 45 | expect(Bullet.collected_counter_cache_notifications).to be_empty 46 | end 47 | 48 | context 'disable' do 49 | before { Bullet.counter_cache_enable = false } 50 | after { Bullet.counter_cache_enable = true } 51 | 52 | it 'should not detect counter cache' do 53 | Country.all.each { |country| country.cities.size } 54 | expect(Bullet.collected_counter_cache_notifications).to be_empty 55 | end 56 | end 57 | 58 | context 'safelist' do 59 | before { Bullet.add_safelist type: :counter_cache, class_name: 'Country', association: :cities } 60 | after { Bullet.clear_safelist } 61 | 62 | it 'should not detect counter cache' do 63 | Country.all.each { |country| country.cities.size } 64 | expect(Bullet.collected_counter_cache_notifications).to be_empty 65 | end 66 | end 67 | 68 | describe 'with count' do 69 | it 'should need counter cache' do 70 | Country.all.each { |country| country.cities.count } 71 | expect(Bullet.collected_counter_cache_notifications).not_to be_empty 72 | end 73 | 74 | it 'should notify even with counter cache' do 75 | Person.all.each { |person| person.pets.count } 76 | expect(Bullet.collected_counter_cache_notifications).not_to be_empty 77 | end 78 | 79 | if ActiveRecord::VERSION::MAJOR > 4 80 | it 'should not need counter cache for has_many through' do 81 | Client.all.each { |client| client.firms.count } 82 | expect(Bullet.collected_counter_cache_notifications).to be_empty 83 | end 84 | else 85 | it 'should need counter cache for has_many through' do 86 | Client.all.each { |client| client.firms.count } 87 | expect(Bullet.collected_counter_cache_notifications).not_to be_empty 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/models/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Address < ActiveRecord::Base 4 | belongs_to :company 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/attachment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Attachment < ActiveRecord::Base 4 | belongs_to :submission 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/author.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Author < ActiveRecord::Base 4 | has_many :documents 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/base_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BaseUser < ActiveRecord::Base 4 | has_many :comments 5 | has_many :posts 6 | belongs_to :newspaper 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/category.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Category < ActiveRecord::Base 4 | has_many :posts, inverse_of: :category 5 | has_many :entries 6 | 7 | has_many :users 8 | 9 | def draft_post 10 | posts.draft.first_or_create 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/city.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class City < ActiveRecord::Base 4 | belongs_to :country 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Client < ActiveRecord::Base 4 | belongs_to :group 5 | 6 | has_many :relationships 7 | has_many :firms, through: :relationships 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Comment < ActiveRecord::Base 4 | belongs_to :post, inverse_of: :comments 5 | belongs_to :author, class_name: 'BaseUser' 6 | 7 | validates :post, presence: true 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/company.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Company < ActiveRecord::Base 4 | has_one :address 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Country < ActiveRecord::Base 4 | has_many :cities 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/deal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Deal < ActiveRecord::Base 4 | has_and_belongs_to_many :posts 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Document < ActiveRecord::Base 4 | has_many :children, class_name: 'Document', foreign_key: 'parent_id' 5 | belongs_to :parent, class_name: 'Document', foreign_key: 'parent_id' 6 | belongs_to :author 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Entry < ActiveRecord::Base 4 | belongs_to :category 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/firm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Firm < ActiveRecord::Base 4 | has_many :relationships 5 | has_many :clients, through: :relationships 6 | has_many :groups, through: :clients 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/folder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Folder < Document 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Group < ActiveRecord::Base 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/mongoid/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::Address 4 | include Mongoid::Document 5 | 6 | field :name 7 | 8 | belongs_to :company, class_name: 'Mongoid::Company' 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/mongoid/category.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::Category 4 | include Mongoid::Document 5 | 6 | field :name 7 | 8 | has_many :posts, class_name: 'Mongoid::Post' 9 | has_many :entries, class_name: 'Mongoid::Entry' 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/mongoid/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::Comment 4 | include Mongoid::Document 5 | 6 | field :name 7 | 8 | belongs_to :post, class_name: 'Mongoid::Post' 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/mongoid/company.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::Company 4 | include Mongoid::Document 5 | 6 | field :name 7 | 8 | has_one :address, class_name: 'Mongoid::Address' 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/mongoid/entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::Entry 4 | include Mongoid::Document 5 | 6 | field :name 7 | 8 | belongs_to :category, class_name: 'Mongoid::Category' 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/mongoid/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::Post 4 | include Mongoid::Document 5 | 6 | field :name 7 | 8 | has_many :comments, class_name: 'Mongoid::Comment' 9 | belongs_to :category, class_name: 'Mongoid::Category' 10 | 11 | embeds_many :users, class_name: 'Mongoid::User' 12 | 13 | scope :preload_comments, -> { includes(:comments) } 14 | end 15 | -------------------------------------------------------------------------------- /spec/models/mongoid/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mongoid::User 4 | include Mongoid::Document 5 | 6 | field :name 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/newspaper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Newspaper < ActiveRecord::Base 4 | has_many :writers, class_name: 'BaseUser' 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Page < Document 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/person.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Person < ActiveRecord::Base 4 | has_many :pets 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/pet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Pet < ActiveRecord::Base 4 | belongs_to :person, counter_cache: true 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Post < ActiveRecord::Base 4 | belongs_to :category, inverse_of: :posts 5 | belongs_to :writer 6 | has_many :comments, inverse_of: :post 7 | has_and_belongs_to_many :deals 8 | 9 | validates :category, presence: true 10 | 11 | scope :preload_comments, -> { includes(:comments) } 12 | scope :in_category_name, ->(name) { where(['categories.name = ?', name]).includes(:category) } 13 | scope :draft, -> { where(active: false) } 14 | 15 | def link=(*) 16 | comments.new 17 | end 18 | 19 | # see association_spec.rb 'should not detect newly assigned object in an after_save' 20 | attr_accessor :trigger_after_save 21 | 22 | after_save do 23 | next unless trigger_after_save 24 | 25 | temp_comment = Comment.new(post: self) 26 | 27 | # this triggers self to be "possible", even though it's 28 | # not saved yet 29 | temp_comment.post 30 | 31 | # category should NOT whine about not being pre-loaded, because 32 | # it's obviously attached to a new object 33 | category 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/models/relationship.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Relationship < ActiveRecord::Base 4 | belongs_to :firm 5 | belongs_to :client 6 | end 7 | -------------------------------------------------------------------------------- /spec/models/reply.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Reply < ActiveRecord::Base 4 | belongs_to :submission 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/role.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Role < ActiveRecord::Base 4 | has_and_belongs_to_many :users 5 | 6 | belongs_to :resource, polymorphic: true 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/student.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Student < ActiveRecord::Base 4 | has_and_belongs_to_many :teachers 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/submission.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Submission < ActiveRecord::Base 4 | belongs_to :user 5 | has_many :replies 6 | has_one :attachment 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/teacher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Teacher < ActiveRecord::Base 4 | has_and_belongs_to_many :students 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ActiveRecord::Base 4 | has_one :submission 5 | has_one :submission_attachment, through: :submission, source: :attachment, class_name: 'Attachment' 6 | belongs_to :category 7 | has_and_belongs_to_many :roles 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/writer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Writer < BaseUser 4 | end 5 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec' 4 | require 'logger' 5 | begin 6 | require 'active_record' 7 | rescue LoadError 8 | end 9 | begin 10 | require 'mongoid' 11 | rescue LoadError 12 | end 13 | 14 | module Rails 15 | class << self 16 | def root 17 | File.expand_path(__FILE__).split('/')[0..-3].join('/') 18 | end 19 | 20 | def env 21 | 'test' 22 | end 23 | end 24 | end 25 | 26 | $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib')) 27 | require 'bullet' 28 | extend Bullet::Dependency 29 | Bullet.enable = true 30 | 31 | MODELS = File.join(File.dirname(__FILE__), 'models') 32 | $LOAD_PATH.unshift(MODELS) 33 | SUPPORT = File.join(File.dirname(__FILE__), 'support') 34 | Dir[File.join(SUPPORT, '*.rb')].reject { |filename| filename =~ /_seed.rb$/ } 35 | .sort.each { |file| require file } 36 | 37 | RSpec.configure do |config| 38 | config.extend Bullet::Dependency 39 | 40 | config.filter_run focus: true 41 | config.run_all_when_everything_filtered = true 42 | end 43 | 44 | if active_record? 45 | ActiveRecord::Migration.verbose = false 46 | 47 | # Autoload every active_record model for the test suite that sits in spec/models. 48 | Dir[File.join(MODELS, '*.rb')].sort.each do |filename| 49 | name = File.basename(filename, '.rb') 50 | autoload name.camelize.to_sym, name 51 | end 52 | require File.join(SUPPORT, 'sqlite_seed.rb') 53 | 54 | RSpec.configure do |config| 55 | config.before(:suite) do 56 | Support::SqliteSeed.setup_db 57 | Support::SqliteSeed.seed_db 58 | end 59 | 60 | config.before(:example) do 61 | Bullet.start_request 62 | Bullet.enable = true 63 | end 64 | 65 | config.after(:example) { Bullet.end_request } 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) { Bullet.start_request } 91 | 92 | config.after(:each) { Bullet.end_request } 93 | end 94 | 95 | if ENV['BULLET_LOG'] 96 | Mongoid.logger = Logger.new(STDOUT) 97 | Moped.logger = Logger.new(STDOUT) 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/support/bullet_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | using Bullet::Ext::Object 4 | 5 | module Bullet 6 | def self.collected_notifications_of_class(notification_class) 7 | Bullet.notification_collector.collection.select { |notification| notification.is_a? notification_class } 8 | end 9 | 10 | def self.collected_counter_cache_notifications 11 | collected_notifications_of_class Bullet::Notification::CounterCache 12 | end 13 | 14 | def self.collected_n_plus_one_query_notifications 15 | collected_notifications_of_class Bullet::Notification::NPlusOneQuery 16 | end 17 | 18 | def self.collected_unused_eager_association_notifications 19 | collected_notifications_of_class Bullet::Notification::UnusedEagerLoading 20 | end 21 | end 22 | 23 | module Bullet 24 | module Detector 25 | class Association 26 | class << self 27 | # returns true if all associations are preloaded 28 | def completely_preloading_associations? 29 | Bullet.collected_n_plus_one_query_notifications.empty? 30 | end 31 | 32 | def has_unused_preload_associations? 33 | Bullet.collected_unused_eager_association_notifications.present? 34 | end 35 | 36 | # returns true if a given object has a specific association 37 | def creating_object_association_for?(object, association) 38 | object_associations[object.bullet_key].present? && 39 | object_associations[object.bullet_key].include?(association) 40 | end 41 | 42 | # returns true if a given class includes the specific unpreloaded association 43 | def detecting_unpreloaded_association_for?(klass, association) 44 | Bullet.collected_n_plus_one_query_notifications.select do |notification| 45 | notification.base_class == klass.to_s && notification.associations.include?(association) 46 | end.present? 47 | end 48 | 49 | # returns true if the given class includes the specific unused preloaded association 50 | def unused_preload_associations_for?(klass, association) 51 | Bullet.collected_unused_eager_association_notifications.select do |notification| 52 | notification.base_class == klass.to_s && notification.associations.include?(association) 53 | end.present? 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/support/mongo_seed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Support 4 | module MongoSeed 5 | module_function 6 | 7 | def seed_db 8 | category1 = Mongoid::Category.create(name: 'first') 9 | category2 = Mongoid::Category.create(name: 'second') 10 | 11 | post1 = category1.posts.create(name: 'first') 12 | post1a = category1.posts.create(name: 'like first') 13 | post2 = category2.posts.create(name: 'second') 14 | 15 | post1.users << Mongoid::User.create(name: 'first') 16 | post1.users << Mongoid::User.create(name: 'another') 17 | post2.users << Mongoid::User.create(name: 'second') 18 | 19 | comment1 = post1.comments.create(name: 'first') 20 | comment2 = post1.comments.create(name: 'first2') 21 | comment3 = post1.comments.create(name: 'first3') 22 | comment4 = post1.comments.create(name: 'second') 23 | comment8 = post1a.comments.create(name: 'like first 1') 24 | comment9 = post1a.comments.create(name: 'like first 2') 25 | comment5 = post2.comments.create(name: 'third') 26 | comment6 = post2.comments.create(name: 'fourth') 27 | comment7 = post2.comments.create(name: 'fourth') 28 | 29 | entry1 = category1.entries.create(name: 'first') 30 | entry2 = category1.entries.create(name: 'second') 31 | 32 | company1 = Mongoid::Company.create(name: 'first') 33 | company2 = Mongoid::Company.create(name: 'second') 34 | 35 | Mongoid::Address.create(name: 'first', company: company1) 36 | Mongoid::Address.create(name: 'second', company: company2) 37 | end 38 | 39 | def setup_db 40 | if Mongoid::VERSION =~ /\A4/ 41 | Mongoid.configure do |config| 42 | config.load_configuration(sessions: { default: { database: 'bullet', hosts: %w[localhost:27017] } }) 43 | end 44 | else 45 | if %w[7.1 7.2 7.3 7.4 7.5 8 8.1 9.0].any? { |version| Mongoid::VERSION =~ /\A#{Regexp.quote(version)}/ } 46 | Mongoid.logger = 47 | Logger.new(STDERR).tap do |logger| 48 | logger.level = Logger::WARN 49 | end 50 | end 51 | 52 | Mongoid.configure do |config| 53 | config.load_configuration(clients: { default: { database: 'bullet', hosts: %w[localhost:27017] } }) 54 | end 55 | 56 | # Increase the level from DEBUG in order to avoid excessive logging to the screen 57 | Mongo::Logger.logger.level = Logger::WARN 58 | end 59 | end 60 | 61 | def teardown_db 62 | Mongoid.purge! 63 | Mongoid::IdentityMap.clear if Mongoid.const_defined?(:IdentityMap) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/support/rack_double.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Support 4 | class AppDouble 5 | def call(_env) 6 | env = @env 7 | [status, headers, response] 8 | end 9 | 10 | attr_writer :status 11 | 12 | attr_writer :headers 13 | 14 | def headers 15 | @headers ||= { 'Content-Type' => 'text/html' } 16 | @headers 17 | end 18 | 19 | attr_writer :response 20 | 21 | private 22 | 23 | def status 24 | @status || 200 25 | end 26 | 27 | def response 28 | @response || ResponseDouble.new 29 | end 30 | end 31 | 32 | class ResponseDouble 33 | def initialize(actual_body = nil) 34 | @actual_body = actual_body 35 | end 36 | 37 | def body 38 | @body ||= '' 39 | end 40 | 41 | attr_writer :body 42 | 43 | def each 44 | yield body 45 | end 46 | 47 | def close; end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/support/sqlite_seed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Support 4 | module SqliteSeed 5 | module_function 6 | 7 | def seed_db 8 | newspaper1 = Newspaper.create(name: 'First Newspaper') 9 | newspaper2 = Newspaper.create(name: 'Second Newspaper') 10 | 11 | writer1 = Writer.create(name: 'first', newspaper: newspaper1) 12 | writer2 = Writer.create(name: 'second', newspaper: newspaper2) 13 | user1 = BaseUser.create(name: 'third', newspaper: newspaper1) 14 | user2 = BaseUser.create(name: 'fourth', newspaper: newspaper2) 15 | 16 | category1 = Category.create(name: 'first') 17 | category2 = Category.create(name: 'second') 18 | 19 | post1 = category1.posts.create(name: 'first', writer: writer1) 20 | post1a = category1.posts.create(name: 'like first', writer: writer2, active: false) 21 | post2 = category2.posts.create(name: 'second', writer: writer2) 22 | post3 = category2.posts.create(name: 'third', writer: writer2) 23 | 24 | deal1 = Deal.new(name: 'Deal 1') 25 | deal1.posts << post1 26 | deal1.posts << post2 27 | deal2 = Deal.new(name: 'Deal 2') 28 | post1.deals << deal1 29 | post1.deals << deal2 30 | 31 | comment1 = post1.comments.create(name: 'first', author: writer1) 32 | comment2 = post1.comments.create(name: 'first2', author: writer1) 33 | comment3 = post1.comments.create(name: 'first3', author: writer1) 34 | comment4 = post1.comments.create(name: 'second', author: writer2) 35 | comment8 = post1a.comments.create(name: 'like first 1', author: writer1) 36 | comment9 = post1a.comments.create(name: 'like first 2', author: writer2) 37 | comment5 = post2.comments.create(name: 'third', author: user1) 38 | comment6 = post2.comments.create(name: 'fourth', author: user2) 39 | comment7 = post2.comments.create(name: 'fourth', author: writer1) 40 | 41 | entry1 = category1.entries.create(name: 'first') 42 | entry2 = category1.entries.create(name: 'second') 43 | 44 | student1 = Student.create(name: 'first') 45 | student2 = Student.create(name: 'second') 46 | teacher1 = Teacher.create(name: 'first') 47 | teacher2 = Teacher.create(name: 'second') 48 | student1.teachers = [teacher1, teacher2] 49 | student2.teachers = [teacher1, teacher2] 50 | teacher1.students << student1 51 | teacher2.students << student2 52 | 53 | firm1 = Firm.create(name: 'first') 54 | firm2 = Firm.create(name: 'second') 55 | group1 = Group.create(name: 'first') 56 | group2 = Group.create(name: 'second') 57 | client1 = Client.create(name: 'first', group: group1) 58 | client2 = Client.create(name: 'second', group: group2) 59 | firm1.clients = [client1, client2] 60 | firm2.clients = [client1, client2] 61 | client1.firms << firm1 62 | client2.firms << firm2 63 | 64 | company1 = Company.create(name: 'first') 65 | company2 = Company.create(name: 'second') 66 | 67 | Address.create(name: 'first', company: company1) 68 | Address.create(name: 'second', company: company2) 69 | 70 | country1 = Country.create(name: 'first') 71 | country2 = Country.create(name: 'second') 72 | 73 | country1.cities.create(name: 'first') 74 | country1.cities.create(name: 'second') 75 | country2.cities.create(name: 'third') 76 | country2.cities.create(name: 'fourth') 77 | 78 | person1 = Person.create(name: 'first') 79 | person2 = Person.create(name: 'second') 80 | 81 | person1.pets.create(name: 'first') 82 | person1.pets.create(name: 'second') 83 | person2.pets.create(name: 'third') 84 | person2.pets.create(name: 'fourth') 85 | 86 | author1 = Author.create(name: 'author1') 87 | author2 = Author.create(name: 'author2') 88 | folder1 = Folder.create(name: 'folder1', author_id: author1.id) 89 | folder2 = Folder.create(name: 'folder2', author_id: author2.id) 90 | page1 = Page.create(name: 'page1', parent_id: folder1.id, author_id: author1.id) 91 | page2 = Page.create(name: 'page2', parent_id: folder1.id, author_id: author1.id) 92 | page3 = Page.create(name: 'page3', parent_id: folder2.id, author_id: author2.id) 93 | page4 = Page.create(name: 'page4', parent_id: folder2.id, author_id: author2.id) 94 | 95 | role1 = Role.create(name: 'Admin') 96 | role2 = Role.create(name: 'User') 97 | 98 | user1 = User.create(name: 'user1', category: category1) 99 | user2 = User.create(name: 'user2', category: category1) 100 | 101 | user1.roles << role1 102 | user1.roles << role2 103 | user2.roles << role2 104 | 105 | submission1 = user1.create_submission(name: 'submission1') 106 | submission2 = user2.create_submission(name: 'submission2') 107 | 108 | submission1.replies.create(name: 'reply1') 109 | submission1.replies.create(name: 'reply2') 110 | submission2.replies.create(name: 'reply3') 111 | submission2.replies.create(name: 'reply4') 112 | 113 | submission1.create_attachment(file_name: 'submission1 file') 114 | submission2.create_attachment(file_name: 'submission2 file') 115 | end 116 | 117 | def setup_db 118 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 119 | 120 | ActiveRecord::Schema.define(version: 1) do 121 | create_table :addresses do |t| 122 | t.column :name, :string 123 | t.column :company_id, :integer 124 | end 125 | 126 | create_table :authors do |t| 127 | t.string :name 128 | end 129 | 130 | create_table :base_users do |t| 131 | t.column :name, :string 132 | t.column :type, :string 133 | t.column :newspaper_id, :integer 134 | end 135 | 136 | create_table :categories do |t| 137 | t.column :name, :string 138 | end 139 | 140 | create_table :cities do |t| 141 | t.string :name 142 | t.integer :country_id 143 | end 144 | 145 | create_table :clients do |t| 146 | t.column :name, :string 147 | t.column :group_id, :integer 148 | end 149 | 150 | create_table :comments do |t| 151 | t.column :name, :string 152 | t.column :post_id, :integer 153 | t.column :author_id, :integer 154 | end 155 | 156 | create_table :companies do |t| 157 | t.column :name, :string 158 | end 159 | 160 | create_table :contacts do |t| 161 | t.column :name, :string 162 | end 163 | 164 | create_table :countries do |t| 165 | t.string :name 166 | end 167 | 168 | create_table :deals do |t| 169 | t.column :name, :string 170 | t.column :hotel_id, :integer 171 | end 172 | 173 | create_table :deals_posts do |t| 174 | t.column :deal_id, :integer 175 | t.column :post_id, :integer 176 | end 177 | 178 | create_table :documents do |t| 179 | t.string :name 180 | t.string :type 181 | t.integer :parent_id 182 | t.integer :author_id 183 | end 184 | 185 | create_table :emails do |t| 186 | t.column :name, :string 187 | t.column :contact_id, :integer 188 | end 189 | 190 | create_table :entries do |t| 191 | t.column :name, :string 192 | t.column :category_id, :integer 193 | end 194 | 195 | create_table :firms do |t| 196 | t.column :name, :string 197 | end 198 | 199 | create_table :groups do |t| 200 | t.column :name, :string 201 | end 202 | 203 | create_table :hotels do |t| 204 | t.column :name, :string 205 | t.column :location_id, :integer 206 | end 207 | 208 | create_table :locations do |t| 209 | t.column :name, :string 210 | end 211 | 212 | create_table :newspapers do |t| 213 | t.column :name, :string 214 | end 215 | 216 | create_table :people do |t| 217 | t.string :name 218 | t.integer :pets_count 219 | end 220 | 221 | create_table :pets do |t| 222 | t.string :name 223 | t.integer :person_id 224 | end 225 | 226 | create_table :posts do |t| 227 | t.column :name, :string 228 | t.column :category_id, :integer 229 | t.column :writer_id, :integer 230 | t.column :active, :boolean, default: true 231 | end 232 | 233 | create_table :relationships do |t| 234 | t.column :firm_id, :integer 235 | t.column :client_id, :integer 236 | end 237 | 238 | create_table :students do |t| 239 | t.column :name, :string 240 | end 241 | 242 | create_table :students_teachers, id: false do |t| 243 | t.column :student_id, :integer 244 | t.column :teacher_id, :integer 245 | end 246 | 247 | create_table :teachers do |t| 248 | t.column :name, :string 249 | end 250 | 251 | create_table :replies do |t| 252 | t.column :name, :string 253 | t.column :submission_id, :integer 254 | end 255 | 256 | create_table :roles do |t| 257 | t.column :name, :string 258 | t.column :resource_id, :integer 259 | t.column :resource_type, :string 260 | end 261 | 262 | create_table :roles_users do |t| 263 | t.column :role_id, :integer 264 | t.column :user_id, :integer 265 | end 266 | 267 | create_table :submissions do |t| 268 | t.column :name, :string 269 | t.column :user_id, :integer 270 | end 271 | 272 | create_table :users do |t| 273 | t.column :name, :string 274 | t.column :category_id, :integer 275 | end 276 | 277 | create_table :attachments do |t| 278 | t.column :file_name, :string 279 | t.column :submission_id, :integer 280 | end 281 | end 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /tasks/bullet_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :bullet do 4 | namespace :log do 5 | desc 'Truncates the bullet log file to zero bytes' 6 | task :clear do 7 | f = File.open('log/bullet.log', 'w') 8 | f.close 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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-8.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-8.0 bundle exec rspec spec 4 | BUNDLE_GEMFILE=Gemfile.rails-7.2 bundle && BUNDLE_GEMFILE=Gemfile.rails-7.2 bundle exec rspec spec 5 | BUNDLE_GEMFILE=Gemfile.rails-7.1 bundle && BUNDLE_GEMFILE=Gemfile.rails-7.1 bundle exec rspec spec 6 | BUNDLE_GEMFILE=Gemfile.rails-7.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-7.0 bundle exec rspec spec 7 | BUNDLE_GEMFILE=Gemfile.rails-6.1 bundle && BUNDLE_GEMFILE=Gemfile.rails-6.1 bundle exec rspec spec 8 | BUNDLE_GEMFILE=Gemfile.rails-6.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-6.0 bundle exec rspec spec 9 | BUNDLE_GEMFILE=Gemfile.rails-5.2 bundle && BUNDLE_GEMFILE=Gemfile.rails-5.2 bundle exec rspec spec 10 | BUNDLE_GEMFILE=Gemfile.rails-5.1 bundle && BUNDLE_GEMFILE=Gemfile.rails-5.1 bundle exec rspec spec 11 | BUNDLE_GEMFILE=Gemfile.rails-5.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-5.0 bundle exec rspec spec 12 | BUNDLE_GEMFILE=Gemfile.rails-4.2 bundle && BUNDLE_GEMFILE=Gemfile.rails-4.2 bundle exec rspec spec 13 | BUNDLE_GEMFILE=Gemfile.rails-4.1 bundle && BUNDLE_GEMFILE=Gemfile.rails-4.1 bundle exec rspec spec 14 | BUNDLE_GEMFILE=Gemfile.rails-4.0 bundle && BUNDLE_GEMFILE=Gemfile.rails-4.0 bundle exec rspec spec 15 | BUNDLE_GEMFILE=Gemfile.mongoid-9.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-9.0 bundle exec rspec spec 16 | BUNDLE_GEMFILE=Gemfile.mongoid-8.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-8.0 bundle exec rspec spec 17 | BUNDLE_GEMFILE=Gemfile.mongoid-7.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-7.0 bundle exec rspec spec 18 | BUNDLE_GEMFILE=Gemfile.mongoid-6.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-6.0 bundle exec rspec spec 19 | BUNDLE_GEMFILE=Gemfile.mongoid-5.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-5.0 bundle exec rspec spec 20 | BUNDLE_GEMFILE=Gemfile.mongoid-4.0 bundle && BUNDLE_GEMFILE=Gemfile.mongoid-4.0 bundle exec rspec spec 21 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | BUNDLE_GEMFILE=Gemfile.rails-5.2 bundle update 2 | BUNDLE_GEMFILE=Gemfile.rails-5.1 bundle update 3 | BUNDLE_GEMFILE=Gemfile.rails-5.0 bundle update 4 | BUNDLE_GEMFILE=Gemfile.rails-4.2 bundle update 5 | BUNDLE_GEMFILE=Gemfile.rails-4.1 bundle update 6 | BUNDLE_GEMFILE=Gemfile.rails-4.0 bundle update 7 | BUNDLE_GEMFILE=Gemfile.mongoid-9.0 bundle update 8 | BUNDLE_GEMFILE=Gemfile.mongoid-8.0 bundle update 9 | BUNDLE_GEMFILE=Gemfile.mongoid-7.0 bundle update 10 | BUNDLE_GEMFILE=Gemfile.mongoid-6.0 bundle update 11 | BUNDLE_GEMFILE=Gemfile.mongoid-5.0 bundle update 12 | BUNDLE_GEMFILE=Gemfile.mongoid-4.0 bundle update 13 | --------------------------------------------------------------------------------