bullet 6.1.4 → 7.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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