eager_eye 0.2.3 → 0.4.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 251f5ec97df15b5f85cbea7b1be3b7c95961f6ed133f4a69b721d76ebd7ad40e
4
- data.tar.gz: ce06033abc65e06113af34789bf17a97badcfe8076675a45bf5ee59f5e19af79
3
+ metadata.gz: a971d7fbafebc02b51a9767987541f67fdd216025ac04aa4bdf4f304cc3abad9
4
+ data.tar.gz: db6eeeb3e1f511856144c70031f63c5216c73b917c00a17a31dbcd44444757a8
5
5
  SHA512:
6
- metadata.gz: 9f24578b9ec1a121b1698e3d53d78ec570b12b3f5092289ca42adc421c89cd1a94566e5de6ff45309803c0be2b8bafc76b1ea3e9f78599da32aef87503c51c38
7
- data.tar.gz: 3f9dd3a3a3d4116840d8de2392e06afa78486ae049505f8706a7e5c9303d25e7efcf0ba7522b256bb9e504c1d8b709d93e06e92b1d0391cfd0e3bafdb7e00536
6
+ metadata.gz: b847b72aff2e758f0a32effdf68057c499503f97251802263d5a1109e5eac3afd8841044d5d144a48b351c4c0909789e5528574f911101f3949e8f78d015dd0a
7
+ data.tar.gz: 3938004802489b63234cc75ec7cfc79c580a0cf263c004762a63fb2d4d1a09350f49f158ab9f4e99cbe4a11b9a8772766be61531c1909d958bed5b82141b407c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2025-12-15
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `CallbackQuery`** - Detects database queries and iterations inside ActiveRecord callbacks
15
+ - Identifies potential bulk operation performance issues
16
+ - Detects query methods (`.count`, `.sum`, `.update!`, etc.) in callbacks
17
+ - Detects iterations (`.each`, `.map`, etc.) in callbacks as errors
18
+ - Severity: `:error` for iterations in callbacks, `:warning` for query methods
19
+ - Suggests moving to background jobs or using conditional callbacks
20
+
21
+ ### Changed
22
+
23
+ - Updated default `enabled_detectors` to include `:callback_query`
24
+ - Updated README with new detector documentation
25
+
26
+ ## [0.3.0] - 2025-12-15
27
+
28
+ ### Added
29
+
30
+ - **New Detector: `CountInIteration`** - Detects `.count` usage inside iterations
31
+ - `.count` always executes a COUNT query, even on preloaded associations
32
+ - Suggests using `.size` instead (uses loaded collection when available)
33
+ - Suggests `counter_cache: true` for frequently accessed counts
34
+ - Helps prevent unnecessary COUNT queries when associations are already loaded
35
+
36
+ ### Changed
37
+
38
+ - Updated default `enabled_detectors` to include `:count_in_iteration`
39
+ - Updated README with new detector documentation and comparison table
40
+
10
41
  ## [0.2.0] - 2025-12-15
11
42
 
12
43
  ### Added
data/README.md CHANGED
@@ -176,6 +176,76 @@ end
176
176
 
177
177
  **Detected methods:** `where`, `find_by`, `find_by!`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `ids`, `count`, `sum`, `average`, `minimum`, `maximum`
178
178
 
179
+ ### 5. Count in Iteration
180
+
181
+ Detects `.count` called on associations inside loops. Unlike `.size`, `.count` always executes a COUNT query even when the association is preloaded.
182
+
183
+ ```ruby
184
+ # Bad - COUNT query for each user, even with includes!
185
+ @users = User.includes(:posts)
186
+ @users.each do |user|
187
+ user.posts.count # Executes: SELECT COUNT(*) FROM posts WHERE user_id = ?
188
+ end
189
+
190
+ # Good - Use .size (checks if loaded first)
191
+ @users.each do |user|
192
+ user.posts.size # No query - counts the loaded array
193
+ end
194
+
195
+ # Best - Use counter_cache for frequent counts
196
+ # In Post model: belongs_to :user, counter_cache: true
197
+ user.posts_count # Just reads the column
198
+ ```
199
+
200
+ **Key differences:**
201
+
202
+ | Method | Loaded Collection | Not Loaded |
203
+ |--------|------------------|------------|
204
+ | `.count` | COUNT query | COUNT query |
205
+ | `.size` | Array#size | COUNT query |
206
+ | `.length` | Array#length | Loads all, then counts |
207
+
208
+ ### 6. Callback Query Detection
209
+
210
+ Detects database queries and iterations inside ActiveRecord callbacks. These become performance disasters during bulk operations.
211
+
212
+ ```ruby
213
+ # Bad - Queries in callbacks
214
+ class Article < ApplicationRecord
215
+ after_save :recalculate_stats
216
+
217
+ def recalculate_stats
218
+ author.articles.published.count # Runs on EVERY save
219
+ category.update_article_count! # Another query on EVERY save
220
+ end
221
+ end
222
+
223
+ # Disaster scenario
224
+ Article.import(1000.times.map { |i| { title: "Post #{i}" } })
225
+ # = 2000+ queries from callbacks!
226
+
227
+ # Bad - N+1 in callback
228
+ class Order < ApplicationRecord
229
+ after_create :notify_subscribers
230
+
231
+ def notify_subscribers
232
+ customer.followers.each do |follower| # N+1!
233
+ NotificationMailer.new_order(follower).deliver_later
234
+ end
235
+ end
236
+ end
237
+
238
+ # Good - Use conditional callbacks
239
+ after_save :recalculate_stats, if: :should_recalculate?
240
+
241
+ # Good - Move to background job
242
+ after_commit :schedule_stats_update, on: :create
243
+
244
+ def schedule_stats_update
245
+ RecalculateStatsJob.perform_later(id)
246
+ end
247
+ ```
248
+
179
249
  ## Configuration
180
250
 
181
251
  ### Config File (.eager_eye.yml)
@@ -192,6 +262,8 @@ enabled_detectors:
192
262
  - serializer_nesting
193
263
  - missing_counter_cache
194
264
  - custom_method_query
265
+ - count_in_iteration
266
+ - callback_query
195
267
 
196
268
  # Base path to analyze (default: app)
197
269
  app_path: app
@@ -8,7 +8,9 @@ module EagerEye
8
8
  loop_association: Detectors::LoopAssociation,
9
9
  serializer_nesting: Detectors::SerializerNesting,
10
10
  missing_counter_cache: Detectors::MissingCounterCache,
11
- custom_method_query: Detectors::CustomMethodQuery
11
+ custom_method_query: Detectors::CustomMethodQuery,
12
+ count_in_iteration: Detectors::CountInIteration,
13
+ callback_query: Detectors::CallbackQuery
12
14
  }.freeze
13
15
 
14
16
  attr_reader :paths, :issues
@@ -4,7 +4,10 @@ module EagerEye
4
4
  class Configuration
5
5
  attr_accessor :excluded_paths, :enabled_detectors, :app_path, :fail_on_issues
6
6
 
7
- DEFAULT_DETECTORS = %i[loop_association serializer_nesting missing_counter_cache custom_method_query].freeze
7
+ DEFAULT_DETECTORS = %i[
8
+ loop_association serializer_nesting missing_counter_cache
9
+ custom_method_query count_in_iteration callback_query
10
+ ].freeze
8
11
 
9
12
  def initialize
10
13
  @excluded_paths = []
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class CallbackQuery < Base
6
+ CALLBACK_METHODS = %i[
7
+ before_validation after_validation
8
+ before_save after_save around_save
9
+ before_create after_create around_create
10
+ before_update after_update around_update
11
+ before_destroy after_destroy around_destroy
12
+ after_commit after_rollback
13
+ after_create_commit after_update_commit after_destroy_commit
14
+ after_save_commit
15
+ ].freeze
16
+
17
+ QUERY_INDICATORS = %i[
18
+ where find find_by find_by! first last take
19
+ exists? any? none? many? one?
20
+ count sum average minimum maximum
21
+ pluck ids select
22
+ update update_all update! update_attribute update_column update_columns
23
+ destroy destroy_all destroy! delete delete_all
24
+ create create! save save! insert insert_all insert! upsert upsert_all
25
+ increment! decrement! toggle!
26
+ reload
27
+ ].freeze
28
+
29
+ ITERATION_METHODS = %i[each map select find_all reject collect].freeze
30
+
31
+ def self.detector_name
32
+ :callback_query
33
+ end
34
+
35
+ def detect(ast, file_path)
36
+ @issues = []
37
+ @file_path = file_path
38
+ @callback_methods = {}
39
+
40
+ return @issues unless ast
41
+
42
+ find_callback_definitions(ast)
43
+ check_callback_methods(ast)
44
+
45
+ @issues
46
+ end
47
+
48
+ private
49
+
50
+ def find_callback_definitions(node)
51
+ return unless node.is_a?(Parser::AST::Node)
52
+
53
+ extract_callback_method_name(node) if callback_definition?(node)
54
+
55
+ node.children.each do |child|
56
+ find_callback_definitions(child)
57
+ end
58
+ end
59
+
60
+ def callback_definition?(node)
61
+ return false unless node.type == :send
62
+ return false unless node.children[0].nil?
63
+
64
+ method_name = node.children[1]
65
+ CALLBACK_METHODS.include?(method_name)
66
+ end
67
+
68
+ def extract_callback_method_name(node)
69
+ node.children[2..].each do |arg|
70
+ next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
71
+
72
+ method_name = arg.children[0]
73
+ callback_type = node.children[1]
74
+ @callback_methods[method_name] = callback_type
75
+ end
76
+ end
77
+
78
+ def check_callback_methods(node)
79
+ return unless node.is_a?(Parser::AST::Node)
80
+
81
+ if method_definition?(node)
82
+ method_name = node.children[0]
83
+ if @callback_methods.key?(method_name)
84
+ callback_type = @callback_methods[method_name]
85
+ check_method_body_for_queries(node, method_name, callback_type)
86
+ end
87
+ end
88
+
89
+ node.children.each do |child|
90
+ check_callback_methods(child)
91
+ end
92
+ end
93
+
94
+ def method_definition?(node)
95
+ node.type == :def
96
+ end
97
+
98
+ def check_method_body_for_queries(method_node, method_name, callback_type)
99
+ method_body = method_node.children[2]
100
+ return unless method_body
101
+
102
+ find_query_calls(method_body, method_name, callback_type)
103
+ find_iteration_with_queries(method_body, method_name, callback_type)
104
+ end
105
+
106
+ def find_query_calls(node, method_name, callback_type)
107
+ return unless node.is_a?(Parser::AST::Node)
108
+
109
+ add_query_issue(node, method_name, callback_type) if query_call?(node)
110
+
111
+ node.children.each do |child|
112
+ find_query_calls(child, method_name, callback_type)
113
+ end
114
+ end
115
+
116
+ def find_iteration_with_queries(node, method_name, callback_type)
117
+ return unless node.is_a?(Parser::AST::Node)
118
+
119
+ add_iteration_issue(node, method_name, callback_type) if iteration_block?(node)
120
+
121
+ node.children.each do |child|
122
+ find_iteration_with_queries(child, method_name, callback_type)
123
+ end
124
+ end
125
+
126
+ def query_call?(node)
127
+ return false unless node.type == :send
128
+
129
+ method = node.children[1]
130
+ QUERY_INDICATORS.include?(method)
131
+ end
132
+
133
+ def iteration_block?(node)
134
+ return false unless node.type == :block
135
+
136
+ send_node = node.children[0]
137
+ return false unless send_node&.type == :send
138
+
139
+ method_name = send_node.children[1]
140
+ ITERATION_METHODS.include?(method_name)
141
+ end
142
+
143
+ def add_query_issue(node, method_name, callback_type)
144
+ query_method = node.children[1]
145
+
146
+ @issues << create_issue(
147
+ file_path: @file_path,
148
+ line_number: node.loc.line,
149
+ message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
150
+ severity: :warning,
151
+ suggestion: "Callbacks run on every save/create/update. Consider moving to a background job"
152
+ )
153
+ end
154
+
155
+ def add_iteration_issue(node, method_name, callback_type)
156
+ @issues << create_issue(
157
+ file_path: @file_path,
158
+ line_number: node.loc.line,
159
+ message: "Iteration found in `#{callback_type}` callback `:#{method_name}` - potential N+1",
160
+ severity: :error,
161
+ suggestion: "Avoid iterations in callbacks. Use background jobs for bulk operations"
162
+ )
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class CountInIteration < Base
6
+ # count always executes a COUNT query
7
+ # size and length use memory when collection is loaded
8
+ COUNT_METHODS = %i[count].freeze
9
+
10
+ ITERATION_METHODS = %i[
11
+ each map select find_all reject collect
12
+ each_with_index each_with_object flat_map
13
+ ].freeze
14
+
15
+ def self.detector_name
16
+ :count_in_iteration
17
+ end
18
+
19
+ def detect(ast, file_path)
20
+ @issues = []
21
+ @file_path = file_path
22
+
23
+ return @issues unless ast
24
+
25
+ find_iteration_blocks(ast) do |block_body, block_var|
26
+ check_for_count_calls(block_body, block_var)
27
+ end
28
+
29
+ @issues
30
+ end
31
+
32
+ private
33
+
34
+ def find_iteration_blocks(node, &block)
35
+ return unless node.is_a?(Parser::AST::Node)
36
+
37
+ if iteration_block?(node)
38
+ block_var = extract_block_variable(node)
39
+ block_body = extract_block_body(node)
40
+ yield(block_body, block_var) if block_var && block_body
41
+ end
42
+
43
+ node.children.each do |child|
44
+ find_iteration_blocks(child, &block)
45
+ end
46
+ end
47
+
48
+ def iteration_block?(node)
49
+ return false unless node.type == :block
50
+
51
+ send_node = node.children[0]
52
+ return false unless send_node&.type == :send
53
+
54
+ method_name = send_node.children[1]
55
+ ITERATION_METHODS.include?(method_name)
56
+ end
57
+
58
+ def check_for_count_calls(node, block_var)
59
+ return unless node.is_a?(Parser::AST::Node)
60
+
61
+ add_issue(node) if count_on_association?(node, block_var)
62
+
63
+ node.children.each do |child|
64
+ check_for_count_calls(child, block_var)
65
+ end
66
+ end
67
+
68
+ def count_on_association?(node, block_var)
69
+ return false unless node.type == :send
70
+
71
+ method_name = node.children[1]
72
+ return false unless COUNT_METHODS.include?(method_name)
73
+
74
+ receiver = node.children[0]
75
+ association_call_on_block_var?(receiver, block_var)
76
+ end
77
+
78
+ def association_call_on_block_var?(node, block_var)
79
+ return false unless node.is_a?(Parser::AST::Node)
80
+ return false unless node.type == :send
81
+
82
+ receiver = node.children[0]
83
+ return false unless receiver.is_a?(Parser::AST::Node)
84
+
85
+ # post.comments.count -> receiver is post.comments
86
+ # post.comments -> receiver is post (lvar)
87
+ if receiver.type == :lvar && receiver.children[0] == block_var
88
+ true
89
+ elsif receiver.type == :send
90
+ # Nested: post.author.posts.count
91
+ chain_starts_with_block_var?(receiver, block_var)
92
+ else
93
+ false
94
+ end
95
+ end
96
+
97
+ def chain_starts_with_block_var?(node, block_var)
98
+ return false unless node.is_a?(Parser::AST::Node)
99
+
100
+ case node.type
101
+ when :lvar
102
+ node.children[0] == block_var
103
+ when :send
104
+ chain_starts_with_block_var?(node.children[0], block_var)
105
+ else
106
+ false
107
+ end
108
+ end
109
+
110
+ def extract_block_variable(block_node)
111
+ args_node = block_node.children[1]
112
+ return nil unless args_node&.type == :args
113
+
114
+ first_arg = args_node.children[0]
115
+ return nil unless first_arg&.type == :arg
116
+
117
+ first_arg.children[0]
118
+ end
119
+
120
+ def extract_block_body(block_node)
121
+ block_node.children[2]
122
+ end
123
+
124
+ def add_issue(node)
125
+ receiver_chain = reconstruct_chain(node.children[0])
126
+
127
+ @issues << create_issue(
128
+ file_path: @file_path,
129
+ line_number: node.loc.line,
130
+ message: "`.count` called on `#{receiver_chain}` inside iteration always executes a COUNT query",
131
+ severity: :warning,
132
+ suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
133
+ )
134
+ end
135
+
136
+ def reconstruct_chain(node)
137
+ return "" unless node.is_a?(Parser::AST::Node)
138
+
139
+ case node.type
140
+ when :lvar
141
+ node.children[0].to_s
142
+ when :send
143
+ receiver_str = reconstruct_chain(node.children[0])
144
+ method = node.children[1]
145
+ receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
146
+ else
147
+ ""
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "0.2.3"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -8,6 +8,8 @@ require_relative "eager_eye/detectors/loop_association"
8
8
  require_relative "eager_eye/detectors/serializer_nesting"
9
9
  require_relative "eager_eye/detectors/missing_counter_cache"
10
10
  require_relative "eager_eye/detectors/custom_method_query"
11
+ require_relative "eager_eye/detectors/count_in_iteration"
12
+ require_relative "eager_eye/detectors/callback_query"
11
13
  require_relative "eager_eye/analyzer"
12
14
  require_relative "eager_eye/reporters/base"
13
15
  require_relative "eager_eye/reporters/console"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
@@ -61,6 +61,8 @@ files:
61
61
  - lib/eager_eye/cli.rb
62
62
  - lib/eager_eye/configuration.rb
63
63
  - lib/eager_eye/detectors/base.rb
64
+ - lib/eager_eye/detectors/callback_query.rb
65
+ - lib/eager_eye/detectors/count_in_iteration.rb
64
66
  - lib/eager_eye/detectors/custom_method_query.rb
65
67
  - lib/eager_eye/detectors/loop_association.rb
66
68
  - lib/eager_eye/detectors/missing_counter_cache.rb