bullet 6.1.4 → 7.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +82 -0
  3. data/CHANGELOG.md +40 -0
  4. data/Gemfile.rails-7.0 +10 -0
  5. data/MIT-LICENSE +1 -1
  6. data/README.md +32 -26
  7. data/lib/bullet/active_record41.rb +1 -0
  8. data/lib/bullet/active_record42.rb +1 -0
  9. data/lib/bullet/active_record5.rb +10 -8
  10. data/lib/bullet/active_record52.rb +21 -25
  11. data/lib/bullet/active_record60.rb +20 -24
  12. data/lib/bullet/active_record61.rb +20 -24
  13. data/lib/bullet/active_record70.rb +284 -0
  14. data/lib/bullet/bullet_xhr.js +3 -2
  15. data/lib/bullet/dependency.rb +10 -0
  16. data/lib/bullet/detector/association.rb +8 -0
  17. data/lib/bullet/detector/base.rb +2 -1
  18. data/lib/bullet/detector/counter_cache.rb +2 -2
  19. data/lib/bullet/detector/n_plus_one_query.rb +24 -13
  20. data/lib/bullet/detector/unused_eager_loading.rb +3 -3
  21. data/lib/bullet/mongoid7x.rb +34 -19
  22. data/lib/bullet/notification.rb +2 -1
  23. data/lib/bullet/rack.rb +42 -7
  24. data/lib/bullet/registry/call_stack.rb +12 -0
  25. data/lib/bullet/registry.rb +1 -0
  26. data/lib/bullet/stack_trace_filter.rb +14 -10
  27. data/lib/bullet/version.rb +1 -1
  28. data/lib/bullet.rb +28 -24
  29. data/lib/generators/bullet/install_generator.rb +0 -1
  30. data/perf/benchmark.rb +4 -1
  31. data/spec/bullet/detector/n_plus_one_query_spec.rb +1 -33
  32. data/spec/bullet/detector/unused_eager_loading_spec.rb +11 -2
  33. data/spec/bullet/ext/object_spec.rb +1 -1
  34. data/spec/bullet/notification/base_spec.rb +4 -4
  35. data/spec/bullet/rack_spec.rb +50 -18
  36. data/spec/bullet/stack_trace_filter_spec.rb +26 -0
  37. data/spec/bullet_spec.rb +15 -15
  38. data/spec/integration/active_record/association_spec.rb +58 -10
  39. data/spec/integration/counter_cache_spec.rb +4 -4
  40. data/spec/integration/mongoid/association_spec.rb +1 -1
  41. data/spec/models/deal.rb +5 -0
  42. data/spec/models/folder.rb +2 -1
  43. data/spec/models/group.rb +2 -1
  44. data/spec/models/page.rb +2 -1
  45. data/spec/models/post.rb +2 -0
  46. data/spec/models/role.rb +7 -0
  47. data/spec/models/user.rb +1 -0
  48. data/spec/models/writer.rb +2 -1
  49. data/spec/spec_helper.rb +0 -2
  50. data/spec/support/mongo_seed.rb +1 -0
  51. data/spec/support/sqlite_seed.rb +30 -0
  52. data/test.sh +2 -0
  53. metadata +13 -4
  54. data/.travis.yml +0 -33
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bullet
4
+ module SaveWithBulletSupport
5
+ def _create_record(*)
6
+ super do
7
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(self)
8
+ yield(self) if block_given?
9
+ end
10
+ end
11
+ end
12
+
13
+ module ActiveRecord
14
+ def self.enable
15
+ require 'active_record'
16
+ ::ActiveRecord::Base.extend(
17
+ Module.new do
18
+ def find_by_sql(sql, binds = [], preparable: nil, &block)
19
+ result = super
20
+ if Bullet.start?
21
+ if result.is_a? Array
22
+ if result.size > 1
23
+ Bullet::Detector::NPlusOneQuery.add_possible_objects(result)
24
+ Bullet::Detector::CounterCache.add_possible_objects(result)
25
+ elsif result.size == 1
26
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first)
27
+ Bullet::Detector::CounterCache.add_impossible_object(result.first)
28
+ end
29
+ elsif result.is_a? ::ActiveRecord::Base
30
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(result)
31
+ Bullet::Detector::CounterCache.add_impossible_object(result)
32
+ end
33
+ end
34
+ result
35
+ end
36
+ end
37
+ )
38
+
39
+ ::ActiveRecord::Base.prepend(SaveWithBulletSupport)
40
+
41
+ ::ActiveRecord::Relation.prepend(
42
+ Module.new do
43
+ # if select a collection of objects, then these objects have possible to cause N+1 query.
44
+ # if select only one object, then the only one object has impossible to cause N+1 query.
45
+ def records
46
+ result = super
47
+ if Bullet.start?
48
+ if result.first.class.name !~ /^HABTM_/
49
+ if result.size > 1
50
+ Bullet::Detector::NPlusOneQuery.add_possible_objects(result)
51
+ Bullet::Detector::CounterCache.add_possible_objects(result)
52
+ elsif result.size == 1
53
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first)
54
+ Bullet::Detector::CounterCache.add_impossible_object(result.first)
55
+ end
56
+ end
57
+ end
58
+ result
59
+ end
60
+ end
61
+ )
62
+
63
+ ::ActiveRecord::Associations::Preloader::Batch.prepend(
64
+ Module.new do
65
+ def call
66
+ if Bullet.start?
67
+ @preloaders.each do |preloader|
68
+ preloader.records.each { |record| Bullet::Detector::Association.add_object_associations(record, preloader.associations) }
69
+ Bullet::Detector::UnusedEagerLoading.add_eager_loadings(preloader.records, preloader.associations)
70
+ end
71
+ end
72
+ super
73
+ end
74
+ end
75
+ )
76
+
77
+ ::ActiveRecord::Associations::Preloader::Branch.prepend(
78
+ Module.new do
79
+ def preloaders_for_reflection(reflection, reflection_records)
80
+ if Bullet.start?
81
+ reflection_records.compact!
82
+ if reflection_records.first.class.name !~ /^HABTM_/
83
+ reflection_records.each { |record| Bullet::Detector::Association.add_object_associations(record, reflection.name) }
84
+ Bullet::Detector::UnusedEagerLoading.add_eager_loadings(reflection_records, reflection.name)
85
+ end
86
+ end
87
+ super
88
+ end
89
+ end
90
+ )
91
+
92
+ ::ActiveRecord::Associations::Preloader::ThroughAssociation.prepend(
93
+ Module.new do
94
+ def preloaded_records
95
+ if Bullet.start? && !defined?(@preloaded_records)
96
+ source_preloaders.each do |source_preloader|
97
+ reflection_name = source_preloader.send(:reflection).name
98
+ source_preloader.send(:owners).each do |owner|
99
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection_name)
100
+ end
101
+ end
102
+ end
103
+ super
104
+ end
105
+ end
106
+ )
107
+
108
+ ::ActiveRecord::Associations::JoinDependency.prepend(
109
+ Module.new do
110
+ def instantiate(result_set, strict_loading_value, &block)
111
+ @bullet_eager_loadings = {}
112
+ records = super
113
+
114
+ if Bullet.start?
115
+ @bullet_eager_loadings.each do |_klazz, eager_loadings_hash|
116
+ objects = eager_loadings_hash.keys
117
+ Bullet::Detector::UnusedEagerLoading.add_eager_loadings(
118
+ objects,
119
+ eager_loadings_hash[objects.first].to_a
120
+ )
121
+ end
122
+ end
123
+ records
124
+ end
125
+
126
+ def construct(ar_parent, parent, row, seen, model_cache, strict_loading_value)
127
+ if Bullet.start?
128
+ unless ar_parent.nil?
129
+ parent.children.each do |node|
130
+ key = aliases.column_alias(node, node.primary_key)
131
+ id = row[key]
132
+ next unless id.nil?
133
+
134
+ associations = node.reflection.name
135
+ Bullet::Detector::Association.add_object_associations(ar_parent, associations)
136
+ Bullet::Detector::NPlusOneQuery.call_association(ar_parent, associations)
137
+ @bullet_eager_loadings[ar_parent.class] ||= {}
138
+ @bullet_eager_loadings[ar_parent.class][ar_parent] ||= Set.new
139
+ @bullet_eager_loadings[ar_parent.class][ar_parent] << associations
140
+ end
141
+ end
142
+ end
143
+
144
+ super
145
+ end
146
+
147
+ # call join associations
148
+ def construct_model(record, node, row, model_cache, id, strict_loading_value)
149
+ result = super
150
+
151
+ if Bullet.start?
152
+ associations = node.reflection.name
153
+ Bullet::Detector::Association.add_object_associations(record, associations)
154
+ Bullet::Detector::NPlusOneQuery.call_association(record, associations)
155
+ @bullet_eager_loadings[record.class] ||= {}
156
+ @bullet_eager_loadings[record.class][record] ||= Set.new
157
+ @bullet_eager_loadings[record.class][record] << associations
158
+ end
159
+
160
+ result
161
+ end
162
+ end
163
+ )
164
+
165
+ ::ActiveRecord::Associations::Association.prepend(
166
+ Module.new do
167
+ def inversed_from(record)
168
+ if Bullet.start?
169
+ Bullet::Detector::NPlusOneQuery.add_inversed_object(owner, reflection.name)
170
+ end
171
+ super
172
+ end
173
+
174
+ def inversed_from_queries(record)
175
+ if Bullet.start? && inversable?(record)
176
+ Bullet::Detector::NPlusOneQuery.add_inversed_object(owner, reflection.name)
177
+ end
178
+ super
179
+ end
180
+ end
181
+ )
182
+
183
+ ::ActiveRecord::Associations::CollectionAssociation.prepend(
184
+ Module.new do
185
+ def load_target
186
+ records = super
187
+
188
+ if Bullet.start?
189
+ if is_a? ::ActiveRecord::Associations::ThroughAssociation
190
+ association = owner.association(reflection.through_reflection.name)
191
+ if association.loaded?
192
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.through_reflection.name)
193
+ Array.wrap(association.target).each do |through_record|
194
+ Bullet::Detector::NPlusOneQuery.call_association(through_record, source_reflection.name)
195
+ end
196
+
197
+ if reflection.through_reflection != through_reflection
198
+ Bullet::Detector::NPlusOneQuery.call_association(owner, through_reflection.name)
199
+ end
200
+ end
201
+ end
202
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name)
203
+ if records.first.class.name !~ /^HABTM_/
204
+ if records.size > 1
205
+ Bullet::Detector::NPlusOneQuery.add_possible_objects(records)
206
+ Bullet::Detector::CounterCache.add_possible_objects(records)
207
+ elsif records.size == 1
208
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(records.first)
209
+ Bullet::Detector::CounterCache.add_impossible_object(records.first)
210
+ end
211
+ end
212
+ end
213
+ records
214
+ end
215
+
216
+ def empty?
217
+ if Bullet.start? && !reflection.has_cached_counter?
218
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name)
219
+ end
220
+ super
221
+ end
222
+
223
+ def include?(object)
224
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name) if Bullet.start?
225
+ super
226
+ end
227
+ end
228
+ )
229
+
230
+ ::ActiveRecord::Associations::SingularAssociation.prepend(
231
+ Module.new do
232
+ # call has_one and belongs_to associations
233
+ def reader
234
+ result = super
235
+
236
+ if Bullet.start?
237
+ if owner.class.name !~ /^HABTM_/
238
+ if is_a? ::ActiveRecord::Associations::ThroughAssociation
239
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.through_reflection.name)
240
+ association = owner.association(reflection.through_reflection.name)
241
+ Array.wrap(association.target).each do |through_record|
242
+ Bullet::Detector::NPlusOneQuery.call_association(through_record, source_reflection.name)
243
+ end
244
+
245
+ if reflection.through_reflection != through_reflection
246
+ Bullet::Detector::NPlusOneQuery.call_association(owner, through_reflection.name)
247
+ end
248
+ end
249
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name)
250
+
251
+ if Bullet::Detector::NPlusOneQuery.impossible?(owner)
252
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result
253
+ else
254
+ Bullet::Detector::NPlusOneQuery.add_possible_objects(result) if result
255
+ end
256
+ end
257
+ end
258
+ result
259
+ end
260
+ end
261
+ )
262
+
263
+ ::ActiveRecord::Associations::HasManyAssociation.prepend(
264
+ Module.new do
265
+ def empty?
266
+ result = super
267
+ if Bullet.start? && !reflection.has_cached_counter?
268
+ Bullet::Detector::NPlusOneQuery.call_association(owner, reflection.name)
269
+ end
270
+ result
271
+ end
272
+
273
+ def count_records
274
+ result = reflection.has_cached_counter?
275
+ if Bullet.start? && !result && !is_a?(::ActiveRecord::Associations::ThroughAssociation)
276
+ Bullet::Detector::CounterCache.add_counter_cache(owner, reflection.name)
277
+ end
278
+ super
279
+ end
280
+ end
281
+ )
282
+ end
283
+ end
284
+ end
@@ -20,6 +20,7 @@
20
20
  if (this.onload) {
21
21
  this._storedOnload = this.onload;
22
22
  }
23
+ this.onload = null;
23
24
  this.addEventListener("load", bulletXHROnload);
24
25
  return Reflect.apply(oldSend, this, arguments);
25
26
  }
@@ -30,7 +31,7 @@
30
31
  ) {
31
32
  var bulletFooterText = this.getResponseHeader("X-bullet-footer-text");
32
33
  if (bulletFooterText) {
33
- setTimeout(function() {
34
+ setTimeout(function () {
34
35
  var oldHtml = document.querySelector("#bullet-footer").innerHTML.split("<br>");
35
36
  var header = oldHtml[0];
36
37
  oldHtml = oldHtml.slice(1, oldHtml.length);
@@ -41,7 +42,7 @@
41
42
  }
42
43
  var bulletConsoleText = this.getResponseHeader("X-bullet-console-text");
43
44
  if (bulletConsoleText && typeof console !== "undefined" && console.log) {
44
- setTimeout(function() {
45
+ setTimeout(function () {
45
46
  JSON.parse(bulletConsoleText).forEach((message) => {
46
47
  if (console.groupCollapsed && console.groupEnd) {
47
48
  console.groupCollapsed("Uniform Notifier");
@@ -29,6 +29,8 @@ module Bullet
29
29
  'active_record60'
30
30
  elsif active_record61?
31
31
  'active_record61'
32
+ elsif active_record70?
33
+ 'active_record70'
32
34
  else
33
35
  raise "Bullet does not support active_record #{::ActiveRecord::VERSION::STRING} yet"
34
36
  end
@@ -64,6 +66,10 @@ module Bullet
64
66
  active_record? && ::ActiveRecord::VERSION::MAJOR == 6
65
67
  end
66
68
 
69
+ def active_record7?
70
+ active_record? && ::ActiveRecord::VERSION::MAJOR == 7
71
+ end
72
+
67
73
  def active_record40?
68
74
  active_record4? && ::ActiveRecord::VERSION::MINOR == 0
69
75
  end
@@ -96,6 +102,10 @@ module Bullet
96
102
  active_record6? && ::ActiveRecord::VERSION::MINOR == 1
97
103
  end
98
104
 
105
+ def active_record70?
106
+ active_record7? && ::ActiveRecord::VERSION::MINOR == 0
107
+ end
108
+
99
109
  def mongoid4x?
100
110
  mongoid? && ::Mongoid::VERSION =~ /\A4/
101
111
  end
@@ -13,6 +13,7 @@ module Bullet
13
13
  'Detector::Association#add_object_associations',
14
14
  "object: #{object.bullet_key}, associations: #{associations}"
15
15
  )
16
+ call_stacks.add(object.bullet_key)
16
17
  object_associations.add(object.bullet_key, associations)
17
18
  end
18
19
 
@@ -25,6 +26,7 @@ module Bullet
25
26
  'Detector::Association#add_call_object_associations',
26
27
  "object: #{object.bullet_key}, associations: #{associations}"
27
28
  )
29
+ call_stacks.add(object.bullet_key)
28
30
  call_object_associations.add(object.bullet_key, associations)
29
31
  end
30
32
 
@@ -76,6 +78,12 @@ module Bullet
76
78
  def eager_loadings
77
79
  Thread.current[:bullet_eager_loadings]
78
80
  end
81
+
82
+ # cal_stacks keeps stacktraces where querie-objects were called from.
83
+ # e.g. { 'Object:111' => [SomeProject/app/controllers/...] }
84
+ def call_stacks
85
+ Thread.current[:bullet_call_stacks]
86
+ end
79
87
  end
80
88
  end
81
89
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Bullet
4
4
  module Detector
5
- class Base; end
5
+ class Base
6
+ end
6
7
  end
7
8
  end
@@ -20,7 +20,7 @@ module Bullet
20
20
  return unless Bullet.start?
21
21
  return unless Bullet.counter_cache_enable?
22
22
 
23
- objects = Array(object_or_objects)
23
+ objects = Array.wrap(object_or_objects)
24
24
  return if objects.map(&:bullet_primary_key_value).compact.empty?
25
25
 
26
26
  Bullet.debug(
@@ -54,7 +54,7 @@ module Bullet
54
54
  private
55
55
 
56
56
  def create_notification(klazz, associations)
57
- notify_associations = Array(associations) - Bullet.get_whitelist_associations(:counter_cache, klazz)
57
+ notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations(:counter_cache, klazz)
58
58
 
59
59
  if notify_associations.present?
60
60
  notice = Bullet::Notification::CounterCache.new klazz, notify_associations
@@ -7,7 +7,7 @@ module Bullet
7
7
  extend StackTraceFilter
8
8
 
9
9
  class << self
10
- # executed when object.assocations is called.
10
+ # executed when object.associations is called.
11
11
  # first, it keeps this method call for object.association.
12
12
  # then, it checks if this associations call is unpreload.
13
13
  # if it is, keeps this unpreload associations and caller.
@@ -25,7 +25,7 @@ module Bullet
25
25
  )
26
26
  if !excluded_stacktrace_path? && conditions_met?(object, associations)
27
27
  Bullet.debug('detect n + 1 query', "object: #{object.bullet_key}, associations: #{associations}")
28
- create_notification caller_in_project, object.class.to_s, associations
28
+ create_notification caller_in_project(object.bullet_key), object.class.to_s, associations
29
29
  end
30
30
  end
31
31
 
@@ -33,14 +33,26 @@ module Bullet
33
33
  return unless Bullet.start?
34
34
  return unless Bullet.n_plus_one_query_enable?
35
35
 
36
- objects = Array(object_or_objects)
37
- return if objects.map(&:bullet_primary_key_value).compact.empty?
38
-
39
- Bullet.debug(
40
- 'Detector::NPlusOneQuery#add_possible_objects',
41
- "objects: #{objects.map(&:bullet_key).join(', ')}"
42
- )
43
- objects.each { |object| possible_objects.add object.bullet_key }
36
+ objects = Array.wrap(object_or_objects)
37
+ class_names_match_regex = true
38
+ primary_key_values_are_empty = true
39
+ keys_joined = ""
40
+ objects.each do |obj|
41
+ unless obj.class.name =~ /^HABTM_/
42
+ class_names_match_regex = false
43
+ end
44
+ unless obj.bullet_primary_key_value.nil?
45
+ primary_key_values_are_empty = false
46
+ end
47
+ keys_joined += "#{(keys_joined.empty?? '' : ', ')}#{obj.bullet_key}"
48
+ end
49
+ unless class_names_match_regex || primary_key_values_are_empty
50
+ Bullet.debug(
51
+ 'Detector::NPlusOneQuery#add_possible_objects',
52
+ "objects: #{keys_joined}"
53
+ )
54
+ objects.each { |object| possible_objects.add object.bullet_key }
55
+ end
44
56
  end
45
57
 
46
58
  def add_impossible_object(object)
@@ -84,8 +96,7 @@ module Bullet
84
96
  # associations == v comparison order is important here because
85
97
  # v variable might be a squeel node where :== method is redefined,
86
98
  # so it does not compare values at all and return unexpected results
87
- result =
88
- v.is_a?(Hash) ? v.key?(associations) : associations == v
99
+ result = v.is_a?(Hash) ? v.key?(associations) : associations == v
89
100
  return true if result
90
101
  end
91
102
 
@@ -95,7 +106,7 @@ module Bullet
95
106
  private
96
107
 
97
108
  def create_notification(callers, klazz, associations)
98
- notify_associations = Array(associations) - Bullet.get_whitelist_associations(:n_plus_one_query, klazz)
109
+ notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations(:n_plus_one_query, klazz)
99
110
 
100
111
  if notify_associations.present?
101
112
  notice = Bullet::Notification::NPlusOneQuery.new(callers, klazz, notify_associations)
@@ -10,7 +10,7 @@ module Bullet
10
10
  # check if there are unused preload associations.
11
11
  # get related_objects from eager_loadings associated with object and associations
12
12
  # get call_object_association from associations of call_object_associations whose object is in related_objects
13
- # if association not in call_object_association, then the object => association - call_object_association is ununsed preload assocations
13
+ # if association not in call_object_association, then the object => association - call_object_association is ununsed preload associations
14
14
  def check_unused_preload_associations
15
15
  return unless Bullet.start?
16
16
  return unless Bullet.unused_eager_loading_enable?
@@ -20,7 +20,7 @@ module Bullet
20
20
  next if object_association_diff.empty?
21
21
 
22
22
  Bullet.debug('detect unused preload', "object: #{bullet_key}, associations: #{object_association_diff}")
23
- create_notification(caller_in_project, bullet_key.bullet_class_name, object_association_diff)
23
+ create_notification(caller_in_project(bullet_key), bullet_key.bullet_class_name, object_association_diff)
24
24
  end
25
25
  end
26
26
 
@@ -65,7 +65,7 @@ module Bullet
65
65
  private
66
66
 
67
67
  def create_notification(callers, klazz, associations)
68
- notify_associations = Array(associations) - Bullet.get_whitelist_associations(:unused_eager_loading, klazz)
68
+ notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations(:unused_eager_loading, klazz)
69
69
 
70
70
  if notify_associations.present?
71
71
  notice = Bullet::Notification::UnusedEagerLoading.new(callers, klazz, notify_associations)
@@ -4,35 +4,50 @@ module Bullet
4
4
  module Mongoid
5
5
  def self.enable
6
6
  require 'mongoid'
7
+ require 'rubygems'
7
8
  ::Mongoid::Contextual::Mongo.class_eval do
8
9
  alias_method :origin_first, :first
9
10
  alias_method :origin_last, :last
10
11
  alias_method :origin_each, :each
11
12
  alias_method :origin_eager_load, :eager_load
12
13
 
13
- def first(opts = {})
14
- result = origin_first(opts)
15
- Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result
16
- result
17
- end
18
-
19
- def last(opts = {})
20
- result = origin_last(opts)
21
- Bullet::Detector::NPlusOneQuery.add_impossible_object(result) if result
22
- result
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
23
21
  end
24
22
 
25
23
  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)
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
34
43
  end
35
- records.each(&block)
44
+
45
+ if document_count == 1
46
+ Bullet::Detector::NPlusOneQuery.add_impossible_object(first_document)
47
+ yield(first_document)
48
+ end
49
+
50
+ self
36
51
  end
37
52
 
38
53
  def eager_load(docs)
@@ -7,6 +7,7 @@ module Bullet
7
7
  autoload :NPlusOneQuery, 'bullet/notification/n_plus_one_query'
8
8
  autoload :CounterCache, 'bullet/notification/counter_cache'
9
9
 
10
- class UnoptimizedQueryError < StandardError; end
10
+ class UnoptimizedQueryError < StandardError
11
+ end
11
12
  end
12
13
  end
data/lib/bullet/rack.rb CHANGED
@@ -4,6 +4,8 @@ module Bullet
4
4
  class Rack
5
5
  include Dependency
6
6
 
7
+ NONCE_MATCHER = /script-src .*'nonce-(?<nonce>[A-Za-z0-9+\/]+={0,2})'/
8
+
7
9
  def initialize(app)
8
10
  @app = app
9
11
  end
@@ -20,9 +22,15 @@ module Bullet
20
22
  if Bullet.inject_into_page? && !file?(headers) && !sse?(headers) && !empty?(response) && status == 200
21
23
  if html_request?(headers, response)
22
24
  response_body = response_body(response)
23
- response_body = append_to_html_body(response_body, footer_note) if Bullet.add_footer
24
- response_body = append_to_html_body(response_body, Bullet.gather_inline_notifications)
25
- response_body = append_to_html_body(response_body, xhr_script) if Bullet.add_footer && !Bullet.skip_http_headers
25
+
26
+ with_security_policy_nonce(headers) do |nonce|
27
+ response_body = append_to_html_body(response_body, footer_note) if Bullet.add_footer
28
+ response_body = append_to_html_body(response_body, Bullet.gather_inline_notifications)
29
+ if Bullet.add_footer && !Bullet.skip_http_headers
30
+ response_body = append_to_html_body(response_body, xhr_script(nonce))
31
+ end
32
+ end
33
+
26
34
  headers['Content-Length'] = response_body.bytesize.to_s
27
35
  elsif !Bullet.skip_http_headers
28
36
  set_header(headers, 'X-bullet-footer-text', Bullet.footer_info.uniq) if Bullet.add_footer
@@ -40,6 +48,7 @@ module Bullet
40
48
  def empty?(response)
41
49
  # response may be ["Not Found"], ["Move Permanently"], etc, but
42
50
  # those should not happen if the status is 200
51
+ return true if !response.respond_to?(:body) && !response.respond_to?(:first)
43
52
  body = response_body(response)
44
53
  body.nil? || body.empty?
45
54
  end
@@ -76,13 +85,13 @@ module Bullet
76
85
  end
77
86
 
78
87
  def html_request?(headers, response)
79
- headers['Content-Type']&.include?('text/html') && response_body(response).include?('<html')
88
+ headers['Content-Type']&.include?('text/html')
80
89
  end
81
90
 
82
91
  def response_body(response)
83
92
  if response.respond_to?(:body)
84
93
  Array === response.body ? response.body.first : response.body
85
- else
94
+ elsif response.respond_to?(:first)
86
95
  response.first
87
96
  end
88
97
  end
@@ -115,8 +124,34 @@ module Bullet
115
124
  end
116
125
 
117
126
  # Make footer work for XHR requests by appending data to the footer
118
- def xhr_script
119
- "<script type='text/javascript'>#{File.read("#{__dir__}/bullet_xhr.js")}</script>"
127
+ def xhr_script(nonce = nil)
128
+ script = File.read("#{__dir__}/bullet_xhr.js")
129
+
130
+ if nonce
131
+ "<script type='text/javascript' nonce='#{nonce}'>#{script}</script>"
132
+ else
133
+ "<script type='text/javascript'>#{script}</script>"
134
+ end
135
+ end
136
+
137
+ def with_security_policy_nonce(headers)
138
+ matched = (headers['Content-Security-Policy'] || '').match(NONCE_MATCHER)
139
+ nonce = matched[:nonce] if matched
140
+
141
+ if nonce
142
+ console_enabled = UniformNotifier.console
143
+ alert_enabled = UniformNotifier.alert
144
+
145
+ UniformNotifier.console = { attributes: { nonce: nonce } } if console_enabled
146
+ UniformNotifier.alert = { attributes: { nonce: nonce } } if alert_enabled
147
+
148
+ yield nonce
149
+
150
+ UniformNotifier.console = console_enabled
151
+ UniformNotifier.alert = alert_enabled
152
+ else
153
+ yield
154
+ end
120
155
  end
121
156
  end
122
157
  end
@@ -0,0 +1,12 @@
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