eager_eye 1.0.4 → 1.0.6

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: f7ebed3daa9046b0b6bbda1cbb7a9cce182af014ac31334973b4d98a5a1059bc
4
- data.tar.gz: 61e220f6737089f52ab495d422cac8b91bf4720d6551c6a42abad7801c7d3c79
3
+ metadata.gz: c0eccf7e9da999d8e273e9b41a11c96eb1284fdd944ce6af0e547f4201a3fbb9
4
+ data.tar.gz: 55b8ab8e753245bc5b638705bff5a0c10115c0f39f44d69f25f5b50dcb823ffa
5
5
  SHA512:
6
- metadata.gz: 198f7d4c064f940a73e2ac41f401869274482f1388f9031f405c89cdeddd3e8e1cf829f8d107c7c3fa1a88fa1a5de07fe44a3d08acf4cb269aa063ebfafae729
7
- data.tar.gz: 814ce257bfc024ca1d83b61cddd3df9b56ed877dfb40d9eaf5564c951bebf11f4351d0c4965ff1510509f62aad3f97af8f0e4933b5a23b4daf048b186d5c748b
6
+ metadata.gz: 9f41668f38474f848cffe0e70473c689c27eb9a102a20cdc932f1039a2c4dce71cb91eda4fa2bb1012136d48b93976cb33ad8455a3e01db2a2c400037a75420e
7
+ data.tar.gz: 2d8f591ee31948302c8a14fe2deef868bc98e2d7fd23083c39a6ee8e9242296e25080938f4e00cef24757c2d2ec6df512d7e6ef144945420de593168e0d71e96
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.6] - 2025-12-22
11
+
12
+ ### Fixed
13
+
14
+ - Fixed false positive in `MissingCounterCache` detector for single `.count`/`.size`/`.length` calls
15
+ - Now only detects count calls **inside iterations** where N+1 queries actually occur
16
+ - Single calls like `post.comments.count` are no longer flagged (not N+1)
17
+ - Iteration patterns like `posts.each { |p| p.comments.count }` are correctly detected
18
+
19
+ ## [1.0.5] - 2025-12-21
20
+
21
+ ### Fixed
22
+
23
+ - Fixed false positive N+1 warnings when iterating over a single record's associations
24
+ - Now correctly skips warnings for patterns like `User.find(id).posts.each { |p| p.comments }`
25
+ - Supports `find`, `find_by`, `find_by!`, `first`, `last`, `take`, `second`, `third`, etc.
26
+ - Works with both inline chains and variable assignments
27
+
10
28
  ## [1.0.4] - 2025-12-21
11
29
 
12
30
  ### Fixed
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # EagerEye
2
2
 
3
3
  [![CI](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg)](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml)
4
- [![Gem Version](https://img.shields.io/badge/gem-v1.0.4-red.svg)](https://rubygems.org/gems/eager_eye)
4
+ [![Gem Version](https://img.shields.io/badge/gem-v1.0.6-red.svg)](https://rubygems.org/gems/eager_eye)
5
5
  [![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg)](https://github.com/hamzagedikkaya/eager_eye)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg)](https://www.ruby-lang.org/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -114,6 +114,12 @@ end
114
114
  # Also works with preload and eager_load
115
115
  posts = Post.preload(:comments)
116
116
  posts.each { |post| post.comments.size } # No warning
117
+
118
+ # Single record context - no N+1 possible (also detected correctly!)
119
+ @user = User.find(params[:id])
120
+ @user.posts.each do |post|
121
+ post.comments # No warning - single user, no N+1
122
+ end
117
123
  ```
118
124
 
119
125
  ### 2. Serializer Nesting (N+1 in serializers)
@@ -144,19 +150,26 @@ Supports multiple serializer libraries:
144
150
 
145
151
  ### 3. Missing Counter Cache
146
152
 
147
- Detects `.count` or `.size` calls on associations that could benefit from counter caches.
153
+ Detects `.count`, `.size`, or `.length` calls on associations **inside iterations** that could benefit from counter caches. Single calls outside loops are not flagged since they don't cause N+1 issues.
148
154
 
149
155
  ```ruby
150
- # Bad - COUNT query every time
151
- post.comments.count
152
- post.comments.size
156
+ # Bad - COUNT query for each post in iteration
157
+ posts.each do |post|
158
+ post.comments.count # Detected: N+1 query!
159
+ post.likes.size # Detected: N+1 query!
160
+ end
153
161
 
154
- # Good - Add counter cache
162
+ # OK - Single count call (not in iteration, no N+1)
163
+ post.comments.count # Not flagged - single query is fine
164
+
165
+ # Good - Add counter cache for iteration use cases
155
166
  # In Comment model:
156
167
  belongs_to :post, counter_cache: true
157
168
 
158
169
  # Then this is a simple column read:
159
- post.comments_count
170
+ posts.each do |post|
171
+ post.comments_count # No query - just reads the column
172
+ end
160
173
  ```
161
174
 
162
175
  ### 4. Custom Method Query (N+1 in query methods)
@@ -5,22 +5,19 @@ module EagerEye
5
5
  class LoopAssociation < Base
6
6
  ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map].freeze
7
7
  PRELOAD_METHODS = %i[includes preload eager_load].freeze
8
-
9
- # Common singular association names (belongs_to pattern)
10
- SINGULAR_ASSOCIATIONS = %w[
11
- author user owner creator admin member customer client
12
- post article comment category tag parent company organization
13
- project task item order product account profile setting
14
- image avatar photo attachment document
15
- ].freeze
16
-
17
- # Common plural association names (has_many pattern)
18
- PLURAL_ASSOCIATIONS = %w[
19
- authors users owners creators admins members customers clients
20
- posts articles comments categories tags children companies organizations
21
- projects tasks items orders products accounts profiles settings
22
- images avatars photos attachments documents
23
- ].freeze
8
+ # Methods that return a single record (not a collection)
9
+ SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
10
+ forty_two sole find_sole_by].freeze
11
+
12
+ # Common association names (belongs_to = singular, has_many = plural)
13
+ ASSOCIATION_NAMES = Set.new(%w[
14
+ author user owner creator admin member customer client post article comment category tag
15
+ parent company organization project task item order product account profile setting image
16
+ avatar photo attachment document authors users owners creators admins members customers
17
+ clients posts articles comments categories tags children companies organizations projects
18
+ tasks items orders products accounts profiles settings images avatars photos attachments
19
+ documents
20
+ ]).freeze
24
21
 
25
22
  # Methods that should NOT be treated as associations
26
23
  EXCLUDED_METHODS = %i[
@@ -38,9 +35,7 @@ module EagerEye
38
35
  return [] unless ast
39
36
 
40
37
  issues = []
41
-
42
- # Build a map of variable names to their preloaded associations
43
- @variable_preloads = build_variable_preloads_map(ast)
38
+ build_variable_maps(ast)
44
39
 
45
40
  traverse_ast(ast) do |node|
46
41
  next unless iteration_block?(node)
@@ -51,15 +46,13 @@ module EagerEye
51
46
  block_body = node.children[2]
52
47
  next unless block_body
53
48
 
54
- # Check if the collection already has includes (both chained and from variable assignment)
55
49
  collection_node = node.children[0]
56
- included_associations = extract_included_associations(collection_node)
50
+ next if single_record_iteration?(collection_node)
57
51
 
58
- # Also check if the collection comes from a variable that was assigned with preloads
59
- variable_preloads = extract_variable_preloads(collection_node)
60
- included_associations.merge(variable_preloads)
52
+ included = extract_included_associations(collection_node)
53
+ included.merge(extract_variable_preloads(collection_node))
61
54
 
62
- find_association_calls(block_body, block_var, file_path, issues, included_associations)
55
+ find_association_calls(block_body, block_var, file_path, issues, included)
63
56
  end
64
57
 
65
58
  issues
@@ -104,38 +97,28 @@ module EagerEye
104
97
  included
105
98
  end
106
99
 
107
- def build_variable_preloads_map(ast)
108
- preloads_map = {}
100
+ def build_variable_maps(ast)
101
+ @variable_preloads = {}
102
+ @single_record_variables = Set.new
109
103
 
110
104
  traverse_ast(ast) do |node|
111
- case node.type
112
- when :lvasgn
113
- record_variable_preloads(preloads_map, :lvar, node)
114
- when :ivasgn
115
- record_variable_preloads(preloads_map, :ivar, node)
116
- end
117
- end
118
-
119
- preloads_map
120
- end
105
+ next unless %i[lvasgn ivasgn].include?(node.type)
121
106
 
122
- def record_variable_preloads(preloads_map, var_type, node)
123
- var_name = node.children[0]
124
- value_node = node.children[1]
125
- return unless value_node
107
+ var_type = node.type == :lvasgn ? :lvar : :ivar
108
+ var_name = node.children[0]
109
+ value_node = node.children[1]
110
+ next unless value_node
126
111
 
127
- preloaded = extract_included_associations(value_node)
128
- preloads_map[[var_type, var_name]] = preloaded unless preloaded.empty?
112
+ key = [var_type, var_name]
113
+ preloaded = extract_included_associations(value_node)
114
+ @variable_preloads[key] = preloaded unless preloaded.empty?
115
+ @single_record_variables.add(key) if single_record_query?(value_node)
116
+ end
129
117
  end
130
118
 
131
- def extract_variable_preloads(collection_node)
132
- preloads = Set.new
133
- return preloads unless @variable_preloads
134
-
135
- key = variable_key_for_node(collection_node)
136
- merge_preloads_for_key(preloads, key) if key
137
-
138
- preloads
119
+ def extract_variable_preloads(node)
120
+ key = variable_key_for_node(node)
121
+ (key && @variable_preloads&.[](key)) || Set.new
139
122
  end
140
123
 
141
124
  def variable_key_for_node(node)
@@ -146,8 +129,19 @@ module EagerEye
146
129
  end
147
130
  end
148
131
 
149
- def merge_preloads_for_key(preloads, key)
150
- preloads.merge(@variable_preloads[key]) if @variable_preloads[key]
132
+ def single_record_query?(node)
133
+ current = node
134
+ while current&.type == :send && !SINGLE_RECORD_METHODS.include?(current.children[1])
135
+ current = current.children[0]
136
+ end
137
+ current&.type == :send && SINGLE_RECORD_METHODS.include?(current.children[1])
138
+ end
139
+
140
+ def single_record_iteration?(node)
141
+ return false unless node&.type == :send && (receiver = node.children[0])
142
+
143
+ key = variable_key_for_node(receiver)
144
+ (key && @single_record_variables&.include?(key)) || single_record_query?(receiver)
151
145
  end
152
146
 
153
147
  def extract_includes_from_method(method_node, included_set)
@@ -211,9 +205,7 @@ module EagerEye
211
205
  def likely_association?(method_name)
212
206
  return false if EXCLUDED_METHODS.include?(method_name)
213
207
 
214
- name = method_name.to_s
215
-
216
- SINGULAR_ASSOCIATIONS.include?(name) || PLURAL_ASSOCIATIONS.include?(name)
208
+ ASSOCIATION_NAMES.include?(method_name.to_s)
217
209
  end
218
210
  end
219
211
  end
@@ -15,6 +15,11 @@ module EagerEye
15
15
  children replies responses answers questions
16
16
  ].freeze
17
17
 
18
+ # Iteration methods that indicate a loop context
19
+ ITERATION_METHODS = %i[each map collect select reject find_all
20
+ filter filter_map flat_map each_with_index
21
+ each_with_object reduce inject sum].freeze
22
+
18
23
  def self.detector_name
19
24
  :missing_counter_cache
20
25
  end
@@ -26,6 +31,7 @@ module EagerEye
26
31
 
27
32
  traverse_ast(ast) do |node|
28
33
  next unless count_on_association?(node)
34
+ next unless inside_iteration?(node)
29
35
 
30
36
  association_name = extract_association_name(node)
31
37
  next unless association_name
@@ -33,7 +39,7 @@ module EagerEye
33
39
  issues << create_issue(
34
40
  file_path: file_path,
35
41
  line_number: node.loc.line,
36
- message: "`.#{node.children[1]}` called on `#{association_name}` may cause N+1 queries",
42
+ message: "`.#{node.children[1]}` called on `#{association_name}` inside iteration may cause N+1 queries",
37
43
  suggestion: "Consider adding `counter_cache: true` to the belongs_to association"
38
44
  )
39
45
  end
@@ -68,6 +74,46 @@ module EagerEye
68
74
 
69
75
  receiver.children[1].to_s
70
76
  end
77
+
78
+ # Check if the node is inside an iteration block
79
+ def inside_iteration?(node)
80
+ parent = node
81
+ while (parent = find_parent(parent))
82
+ return true if iteration_block?(parent)
83
+ end
84
+ false
85
+ end
86
+
87
+ def find_parent(node)
88
+ @parent_map ||= {}
89
+ @parent_map[node]
90
+ end
91
+
92
+ # Override traverse_ast to build parent map
93
+ def traverse_ast(node, &block)
94
+ return unless node.is_a?(Parser::AST::Node)
95
+
96
+ @parent_map ||= {}
97
+
98
+ yield node
99
+
100
+ node.children.each do |child|
101
+ if child.is_a?(Parser::AST::Node)
102
+ @parent_map[child] = node
103
+ traverse_ast(child, &block)
104
+ end
105
+ end
106
+ end
107
+
108
+ def iteration_block?(node)
109
+ return false unless node.type == :block
110
+
111
+ send_node = node.children[0]
112
+ return false unless send_node&.type == :send
113
+
114
+ method_name = send_node.children[1]
115
+ ITERATION_METHODS.include?(method_name)
116
+ end
71
117
  end
72
118
  end
73
119
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.0.4"
4
+ VERSION = "1.0.6"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-21 00:00:00.000000000 Z
11
+ date: 2025-12-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast