eager_eye 0.2.2 → 0.3.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: 6329285c3bdd39b8c99bbf665ffef121ac0dc5530b26b8e125d71ca04239a7a4
4
- data.tar.gz: 78a608b043a06a58b22a54553d0c0b4ee459c2ee921405f01c5a216a77e801fb
3
+ metadata.gz: 037dbef0e4dc82641ae8c56bb8110f822900dd7afe76ddac6051b375bb547208
4
+ data.tar.gz: 1d0878ea0046406e264ddfa92d68ac0c9f1748aea4803d9a638554bb13ea77c9
5
5
  SHA512:
6
- metadata.gz: 5d985342e6d3bc4a09dab284b153807b4cda7a7dce898c4dca9ca3e0223e6fb2f575b369426a2a04c30f4d04b557749be8091ee4ed23de4c4a86522d410867ec
7
- data.tar.gz: 18ba07590dce273befec6295f0ce05702241ee46a727c05825dd573d44e3019839655f0da14dc9674f5ded7d56b7ee21d802b6264a2b2a85c5f6859928c887da
6
+ metadata.gz: b97082ff4f9176c773a1007e9045305ad4c1d9ae46898bc1969eb66e6dd9bb42605bf567875ba7ba429693b1cd61385274587c51d6d0dc0397fc15f304d49af7
7
+ data.tar.gz: ca8e47200e8d140361dfb57f37fff56616d1c75c284b19de43afeb2c1f179cdf506731866ccaf4b531e867877fd2815de2e6082134b8f6754bfe861068647ff4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2025-12-15
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `CountInIteration`** - Detects `.count` usage inside iterations
15
+ - `.count` always executes a COUNT query, even on preloaded associations
16
+ - Suggests using `.size` instead (uses loaded collection when available)
17
+ - Suggests `counter_cache: true` for frequently accessed counts
18
+ - Helps prevent unnecessary COUNT queries when associations are already loaded
19
+
20
+ ### Changed
21
+
22
+ - Updated default `enabled_detectors` to include `:count_in_iteration`
23
+ - Updated README with new detector documentation and comparison table
24
+
10
25
  ## [0.2.0] - 2025-12-15
11
26
 
12
27
  ### Added
data/README.md CHANGED
@@ -176,6 +176,35 @@ 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
+
179
208
  ## Configuration
180
209
 
181
210
  ### Config File (.eager_eye.yml)
@@ -192,6 +221,7 @@ enabled_detectors:
192
221
  - serializer_nesting
193
222
  - missing_counter_cache
194
223
  - custom_method_query
224
+ - count_in_iteration
195
225
 
196
226
  # Base path to analyze (default: app)
197
227
  app_path: app
@@ -8,7 +8,8 @@ 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
12
13
  }.freeze
13
14
 
14
15
  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
10
+ ].freeze
8
11
 
9
12
  def initialize
10
13
  @excluded_paths = []
@@ -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
@@ -21,6 +21,9 @@ module EagerEye
21
21
  maximum
22
22
  ].freeze
23
23
 
24
+ # Array-only methods that should not be flagged when collection is clearly an array
25
+ ARRAY_METHODS = %i[first last take].freeze
26
+
24
27
  ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map].freeze
25
28
 
26
29
  def self.detector_name
@@ -33,8 +36,9 @@ module EagerEye
33
36
  @issues = []
34
37
  @file_path = file_path
35
38
 
36
- find_iteration_blocks(ast) do |block_body, block_var|
37
- check_block_for_query_methods(block_body, block_var)
39
+ find_iteration_blocks(ast) do |block_body, block_var, collection|
40
+ is_array_collection = collection_is_array?(collection)
41
+ check_block_for_query_methods(block_body, block_var, is_array_collection)
38
42
  end
39
43
 
40
44
  @issues
@@ -48,7 +52,8 @@ module EagerEye
48
52
  if iteration_block?(node)
49
53
  block_var = extract_block_variable(node)
50
54
  block_body = extract_block_body(node)
51
- yield(block_body, block_var) if block_var && block_body
55
+ collection = extract_collection(node)
56
+ yield(block_body, block_var, collection) if block_var && block_body
52
57
  end
53
58
 
54
59
  node.children.each do |child|
@@ -66,26 +71,40 @@ module EagerEye
66
71
  ITERATION_METHODS.include?(method_name)
67
72
  end
68
73
 
69
- def check_block_for_query_methods(node, block_var)
74
+ def check_block_for_query_methods(node, block_var, is_array_collection = false) # rubocop:disable Style/OptionalBooleanParameter
70
75
  return unless node.is_a?(Parser::AST::Node)
71
76
 
72
- add_issue(node) if query_chain_on_association?(node, block_var)
77
+ add_issue(node) if query_chain_on_association?(node, block_var, is_array_collection)
73
78
 
74
79
  node.children.each do |child|
75
- check_block_for_query_methods(child, block_var)
80
+ check_block_for_query_methods(child, block_var, is_array_collection)
76
81
  end
77
82
  end
78
83
 
79
- def query_chain_on_association?(node, block_var)
84
+ def query_chain_on_association?(node, block_var, is_array_collection = false) # rubocop:disable Style/OptionalBooleanParameter
80
85
  return false unless node.type == :send
81
86
 
82
87
  method_name = node.children[1]
83
88
  return false unless QUERY_METHODS.include?(method_name)
84
89
 
90
+ # Skip array-only methods when collection is clearly an array (.map result)
91
+ # AND the receiver is only the block variable (not chained)
92
+ if is_array_collection && ARRAY_METHODS.include?(method_name) &&
93
+ receiver_is_only_block_var?(node.children[0], block_var)
94
+ return false
95
+ end
96
+
85
97
  receiver = node.children[0]
86
98
  receiver_chain_starts_with?(receiver, block_var)
87
99
  end
88
100
 
101
+ def receiver_is_only_block_var?(node, block_var)
102
+ # Returns true only if receiver is EXACTLY the block variable, not a chain
103
+ node.is_a?(Parser::AST::Node) &&
104
+ node.type == :lvar &&
105
+ node.children[0] == block_var
106
+ end
107
+
89
108
  def receiver_chain_starts_with?(node, block_var)
90
109
  return false unless node.is_a?(Parser::AST::Node)
91
110
 
@@ -113,6 +132,31 @@ module EagerEye
113
132
  block_node.children[2]
114
133
  end
115
134
 
135
+ def extract_collection(block_node)
136
+ # Extract the collection being iterated on
137
+ # For: collection.each { |item| ... }
138
+ # Returns: the send node representing the collection method call
139
+ block_node.children[0]
140
+ end
141
+
142
+ def collection_is_array?(collection_node)
143
+ return false unless collection_node.is_a?(Parser::AST::Node)
144
+
145
+ case collection_node.type
146
+ when :array
147
+ # Literal array: [1, 2, 3].each { |item| ... }
148
+ true
149
+ when :send
150
+ # Only consider these methods as definitely returning arrays when iterating
151
+ method_name = collection_node.children[1]
152
+ # map, select, collect, etc. on anything return arrays for iteration
153
+ %i[map select collect flat_map to_a uniq compact].include?(method_name)
154
+ else
155
+ # Block variable itself won't tell us if it's an array
156
+ false
157
+ end
158
+ end
159
+
116
160
  def add_issue(node)
117
161
  method_name = node.children[1]
118
162
  association_chain = reconstruct_chain(node.children[0])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -8,6 +8,7 @@ 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"
11
12
  require_relative "eager_eye/analyzer"
12
13
  require_relative "eager_eye/reporters/base"
13
14
  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.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
@@ -61,6 +61,7 @@ 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/count_in_iteration.rb
64
65
  - lib/eager_eye/detectors/custom_method_query.rb
65
66
  - lib/eager_eye/detectors/loop_association.rb
66
67
  - lib/eager_eye/detectors/missing_counter_cache.rb