├── .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-(?
')
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 | %(
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 ||= '