eager_eye 1.0.9 → 1.1.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: 44b2f46a77bcc0bb742c6d169cebb7be6b9a8b722b64044b153397fcf7686872
4
- data.tar.gz: 25a589c3401fe25248b6fe429e0255e5d2d9ada9b50bd7bbf467ada161e0ca95
3
+ metadata.gz: 03025eeca47525318e9dbd1ffe0046bcd6647c16bc5696a1fd0b7906906ebc9d
4
+ data.tar.gz: c9fa809e3bcc95a6ea20d6f9ef18ca45241ad96e325fef76b0aa90e79b19e3a3
5
5
  SHA512:
6
- metadata.gz: 2ea1baa8c98a5362527e991d3712c0a3c0d821307a99e1b8b1a9a0ac76fbff5d4adcf4f4cd512990971956fcc3bd61ef97fb8c047d0b44609e4bd55ca385bfb4
7
- data.tar.gz: 8d1590b243420e3ea3534290f15725a0aa70f3ea537aa7da05cd4648521fe284718965d27e4b00ac77ea277d4ca400107e49a18a7759780dd532c560ef1b91b3
6
+ metadata.gz: c3faea6fb82cda3d118584970dba7bb88fa2fa1024dd6906d03f89699ff93b5f4d2a487b6c598eb9ee6bc18ae222b308f959d3cefbaed7d54c808aba93bc2fb2
7
+ data.tar.gz: 355d9b1c678b755ff679492183e7046b3edb511bff6e8fe3ff0990f091274917877cf8495984cb8315fe1096b0308a3528aff8c56fc6f1ed22270aa4257b0d03
data/.rubocop.yml CHANGED
@@ -19,6 +19,9 @@ Metrics/BlockLength:
19
19
  - "*.gemspec"
20
20
  - "lib/eager_eye/railtie.rb"
21
21
 
22
+ Metrics/ClassLength:
23
+ Max: 165
24
+
22
25
  Metrics/ParameterLists:
23
26
  Max: 6
24
27
 
@@ -28,8 +31,5 @@ Metrics/AbcSize:
28
31
  Metrics/MethodLength:
29
32
  Max: 30
30
33
 
31
- Metrics/ClassLength:
32
- Max: 150
33
-
34
34
  Layout/FirstArrayElementIndentation:
35
35
  EnforcedStyle: consistent
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.1.0] - 2025-12-28
11
+
12
+ ### Added
13
+
14
+ - **Association Scope Preloading Detection** - LoopAssociation now recognizes associations with built-in preloading
15
+ - Detects `has_many :posts, -> { includes(:comments) }` patterns
16
+ - Recognizes scope-defined preloads to reduce false positives
17
+ - Parses model files to extract association definitions and preload scopes
18
+
19
+ ## [1.0.10] - 2025-12-27
20
+
21
+ ### Changed
22
+
23
+ - **PluckToArray Severity Levels** - Scoped pluck is warning, `.all.pluck` is error
24
+ - Scoped `.pluck(:id)` → **Warning** (acceptable for small arrays)
25
+ - Unscoped `.all.pluck(:id)` → **Error** (loads entire table)
26
+ - Improved detection and suggestions for critical patterns
27
+
10
28
  ## [1.0.9] - 2025-12-26
11
29
 
12
30
  ### Added
@@ -19,12 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
37
  - Supports both CamelCase and snake_case detector names
20
38
  - Can disable all detectors with `all` keyword
21
39
 
22
- ### Improved
23
-
24
- - Enhanced README with table of contents and better organization
25
- - Improved code readability and documentation structure
26
- - Updated version badge to v1.0.9
27
-
28
40
  ## [1.0.8] - 2025-12-25
29
41
 
30
42
  ### Added
data/CONTRIBUTING.md CHANGED
@@ -74,7 +74,7 @@ end
74
74
  ## Code Style
75
75
 
76
76
  - Follow RuboCop rules (run `bundle exec rubocop`)
77
- - Maintain 90%+ test coverage
77
+ - Maintain 95%+ test coverage
78
78
  - Document public methods
79
79
  - Keep methods small and focused
80
80
 
data/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml"><img src="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg" alt="CI"></a>
13
- <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.0.9-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.1.0-red.svg" alt="Gem Version"></a>
14
14
  <a href="https://github.com/hamzagedikkaya/eager_eye"><img src="https://img.shields.io/badge/coverage-95%25-brightgreen.svg" alt="Coverage"></a>
15
15
  <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby"></a>
16
16
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
@@ -24,7 +24,6 @@
24
24
  ---
25
25
 
26
26
  ## Table of Contents
27
- - [Why EagerEye?](#why-eagereye)
28
27
  - [Features](#features)
29
28
  - [Installation](#installation)
30
29
  - [Quick Start](#quick-start)
@@ -41,31 +40,13 @@
41
40
  - [Development](#development)
42
41
  - [Contributing](#contributing)
43
42
 
44
- ## Why EagerEye?
45
-
46
- Unlike runtime tools like Bullet, EagerEye:
47
-
48
- - **Runs without executing code** - Works in CI pipelines without a test suite
49
- - **Catches more patterns** - Detects serializer N+1s, missing counter caches, and query methods in loops
50
- - **Proactive detection** - Finds issues at code review time, not after deployment
51
-
52
- | Feature | EagerEye | Bullet |
53
- |---------|----------|--------|
54
- | Detection method | Static analysis | Runtime |
55
- | Requires test suite | No | Yes |
56
- | Serializer N+1 detection | Yes | Limited |
57
- | Counter cache suggestions | Yes | No |
58
- | Query methods in loops | Yes | No |
59
- | CI integration | Native | Requires tests |
60
- | False positive rate | Higher | Lower |
61
-
62
43
  ## Features
63
44
 
64
45
  ✨ **Detects 7 types of N+1 problems:**
65
46
  - Loop associations (queries in iterations)
66
47
  - Serializer nesting issues
67
48
  - Missing counter caches
68
- - Custom method queries (invisible to Bullet)
49
+ - Custom method queries
69
50
  - Count in iteration patterns
70
51
  - Callback query N+1s
71
52
  - Pluck to array misuse
@@ -172,6 +153,17 @@ posts.each { |post| post.comments.size } # No warning
172
153
  @user.posts.each do |post|
173
154
  post.comments # No warning - single user, no N+1
174
155
  end
156
+
157
+ # Scope-defined preloads are recognized (v1.1.0+)
158
+ # In Post model:
159
+ class Post < ApplicationRecord
160
+ has_many :comments, -> { includes(:author) }
161
+ end
162
+
163
+ # In controller - EagerEye recognizes comments are already preloaded via scope!
164
+ posts.each do |post|
165
+ post.comments.map(&:author) # No warning - preloaded via scope
166
+ end
175
167
  ```
176
168
 
177
169
  ### 2. Serializer Nesting (N+1 in serializers)
@@ -226,10 +218,10 @@ end
226
218
 
227
219
  ### 4. Custom Method Query (N+1 in query methods)
228
220
 
229
- Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops. **These patterns are invisible to Bullet.**
221
+ Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
230
222
 
231
223
  ```ruby
232
- # Bad - Bullet CANNOT catch this
224
+ # Bad - where inside loop
233
225
  class User < ApplicationRecord
234
226
  def supports?(team_name)
235
227
  teams.where(name: team_name).exists?
@@ -332,25 +324,20 @@ Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses i
332
324
 
333
325
  ```ruby
334
326
  # Bad - Two queries + memory overhead
335
- user_ids = User.active.pluck(:id) # Query 1: SELECT id FROM users
336
- Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3...)
337
- # Also holds potentially thousands of IDs in memory
327
+ user_ids = User.active.pluck(:id)
328
+ Post.where(user_id: user_ids) # ⚠️ Warning
338
329
 
339
- # Bad - Same problem with map
340
- user_ids = users.map(&:id)
330
+ # Worse - Loads entire table! 🔴 Error
331
+ user_ids = User.all.pluck(:id)
341
332
  Post.where(user_id: user_ids)
342
333
 
343
- # Good - Single subquery, no memory overhead
334
+ # Good - Single subquery
344
335
  Post.where(user_id: User.active.select(:id))
345
- # Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
346
336
  ```
347
337
 
348
- **Performance comparison with 10,000 users:**
349
-
350
- | Approach | Queries | Memory | Time |
351
- |----------|---------|--------|------|
352
- | `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
353
- | `select` subquery | 1 | None | ~20ms |
338
+ **Severity:**
339
+ - ⚠️ **Warning** - Scoped `.pluck(:id)` (two queries, memory overhead)
340
+ - 🔴 **Error** - Unscoped `.all.pluck(:id)` (loads entire table)
354
341
 
355
342
  ## Inline Suppression
356
343
 
@@ -14,24 +14,49 @@ module EagerEye
14
14
  pluck_to_array: Detectors::PluckToArray
15
15
  }.freeze
16
16
 
17
- attr_reader :paths, :issues
17
+ attr_reader :paths, :issues, :association_preloads
18
18
 
19
19
  def initialize(paths: nil)
20
20
  @paths = Array(paths || EagerEye.configuration.app_path)
21
21
  @issues = []
22
+ @association_preloads = {}
22
23
  end
23
24
 
24
25
  def run
25
26
  @issues = []
27
+ collect_association_preloads
28
+ analyze_files
29
+ @issues
30
+ end
31
+
32
+ private
33
+
34
+ def collect_association_preloads
35
+ model_files.each do |file_path|
36
+ source = File.read(file_path)
37
+ ast = parse_source(source)
38
+ next unless ast
26
39
 
27
- ruby_files.each do |file_path|
28
- analyze_file(file_path)
40
+ model_name = extract_model_name(file_path)
41
+ parser = AssociationParser.new
42
+ parser.parse_model(ast, model_name)
43
+ @association_preloads.merge!(parser.preloaded_associations)
29
44
  end
45
+ rescue StandardError
46
+ # Silently skip errors in association parsing
47
+ end
30
48
 
31
- @issues
49
+ def model_files
50
+ Dir.glob(File.join(@paths[0], "models", "**", "*.rb"))
32
51
  end
33
52
 
34
- private
53
+ def extract_model_name(file_path)
54
+ File.basename(file_path, ".rb").camelize
55
+ end
56
+
57
+ def analyze_files
58
+ ruby_files.each { |file_path| analyze_file(file_path) }
59
+ end
35
60
 
36
61
  def ruby_files
37
62
  all_files = paths.flat_map do |path|
@@ -61,7 +86,10 @@ module EagerEye
61
86
  comment_parser = CommentParser.new(source)
62
87
 
63
88
  enabled_detectors.each do |detector|
64
- file_issues = detector.detect(ast, file_path)
89
+ args = [ast, file_path]
90
+ args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
91
+
92
+ file_issues = detector.detect(*args)
65
93
 
66
94
  # Filter suppressed issues
67
95
  file_issues.reject! do |issue|
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ class AssociationParser
5
+ ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
6
+
7
+ attr_reader :preloaded_associations
8
+
9
+ def initialize
10
+ @preloaded_associations = {}
11
+ end
12
+
13
+ def parse_model(ast, model_name)
14
+ return unless ast
15
+
16
+ traverse(ast, model_name)
17
+ end
18
+
19
+ private
20
+
21
+ def traverse(node, model_name)
22
+ return unless node.is_a?(Parser::AST::Node)
23
+
24
+ check_association_definition(node, model_name)
25
+ node.children.each { |child| traverse(child, model_name) }
26
+ end
27
+
28
+ def check_association_definition(node, model_name)
29
+ return unless node.type == :send
30
+ return unless node.children[0].nil? # receiver is nil (class context)
31
+
32
+ method_name = node.children[1]
33
+ return unless ASSOCIATION_METHODS.include?(method_name)
34
+
35
+ association_name = extract_association_name(node)
36
+ return unless association_name
37
+
38
+ preloaded = extract_preloaded_associations(node)
39
+ return if preloaded.empty?
40
+
41
+ @preloaded_associations["#{model_name}##{association_name}"] = preloaded
42
+ end
43
+
44
+ def extract_association_name(node)
45
+ args = node.children[2..]
46
+ return nil if args.empty?
47
+
48
+ first_arg = args[0]
49
+ return nil unless first_arg&.type == :sym
50
+
51
+ first_arg.children[0]
52
+ end
53
+
54
+ def extract_preloaded_associations(node)
55
+ preloaded = Set.new
56
+ args = node.children[2..]
57
+ return preloaded if args.empty?
58
+
59
+ # Check for block with includes/preload/eager_load
60
+ block_node = args.find { |arg| arg&.type == :block }
61
+ return preloaded unless block_node
62
+
63
+ extract_from_block(block_node, preloaded)
64
+ preloaded
65
+ end
66
+
67
+ def extract_from_block(block_node, preloaded)
68
+ block_body = block_node.children[2]
69
+ traverse_for_preloads(block_body, preloaded)
70
+ end
71
+
72
+ def traverse_for_preloads(node, preloaded)
73
+ return unless node.is_a?(Parser::AST::Node)
74
+
75
+ extract_includes_from_method(node, preloaded) if preload_call?(node)
76
+
77
+ node.children.each { |child| traverse_for_preloads(child, preloaded) }
78
+ end
79
+
80
+ def preload_call?(node)
81
+ return false unless node.type == :send
82
+
83
+ method = node.children[1]
84
+ %i[includes preload eager_load].include?(method)
85
+ end
86
+
87
+ def extract_includes_from_method(node, included)
88
+ args = node.children[2..]
89
+ return if args.empty?
90
+
91
+ args.each { |arg| add_included_sym(arg, included) }
92
+ end
93
+
94
+ def add_included_sym(arg, included)
95
+ case arg&.type
96
+ when :sym
97
+ included << arg.children[0]
98
+ when :hash
99
+ arg.children.each { |pair| extract_sym_from_pair(pair, included) }
100
+ end
101
+ end
102
+
103
+ def extract_sym_from_pair(pair, included)
104
+ return unless pair.type == :pair
105
+
106
+ key = pair.children[0]
107
+ included << key.children[0] if key&.type == :sym
108
+ end
109
+ end
110
+ end
@@ -72,22 +72,18 @@ module EagerEye
72
72
  end
73
73
 
74
74
  def detect_block_or_inline_directive(line)
75
- # Check for eager_eye:disable-block explicitly
76
75
  if line =~ BLOCK_START_PATTERN && !code_before_comment?(line)
77
76
  detectors = parse_detector_names(::Regexp.last_match(1) || "all")
78
77
  return { type: :block_start, detectors: detectors }
79
78
  end
80
79
 
81
- # Check for eager_eye:disable (either inline or block start)
82
80
  return unless line =~ INLINE_DISABLE_PATTERN
83
81
 
84
82
  detectors = parse_detector_names(::Regexp.last_match(1) || "all")
85
83
 
86
- # If there's code before the comment, it's inline (same line only)
87
84
  if code_before_comment?(line)
88
85
  { type: :inline, detectors: detectors }
89
86
  else
90
- # No code before comment, so it's a block start
91
87
  { type: :block_start, detectors: detectors }
92
88
  end
93
89
  end
@@ -52,6 +52,34 @@ module EagerEye
52
52
  rescue Parser::SyntaxError
53
53
  nil
54
54
  end
55
+
56
+ def extract_method_args(node)
57
+ return [] unless node&.type == :send
58
+
59
+ node.children[2..]
60
+ end
61
+
62
+ def extract_symbols_from_args(args)
63
+ symbols = Set.new
64
+ return symbols if args.empty?
65
+
66
+ args.each do |arg|
67
+ case arg&.type
68
+ when :sym
69
+ symbols.add(arg.children[0])
70
+ when :hash
71
+ extract_symbols_from_hash(arg, symbols)
72
+ end
73
+ end
74
+ symbols
75
+ end
76
+
77
+ def extract_symbols_from_hash(hash_node, symbols)
78
+ hash_node.children.each do |pair|
79
+ key = pair.children[0]
80
+ symbols.add(key.children[0]) if key&.type == :sym
81
+ end
82
+ end
55
83
  end
56
84
  end
57
85
  end
@@ -31,10 +31,11 @@ module EagerEye
31
31
  :loop_association
32
32
  end
33
33
 
34
- def detect(ast, file_path)
34
+ def detect(ast, file_path, association_preloads = {})
35
35
  return [] unless ast
36
36
 
37
37
  issues = []
38
+ @association_preloads = association_preloads
38
39
  build_variable_maps(ast)
39
40
 
40
41
  traverse_ast(ast) do |node|
@@ -51,6 +52,8 @@ module EagerEye
51
52
 
52
53
  included = extract_included_associations(collection_node)
53
54
  included.merge(extract_variable_preloads(collection_node))
55
+ model_name = infer_model_name_from_collection(collection_node)
56
+ included.merge(get_association_preloads(model_name))
54
57
 
55
58
  find_association_calls(block_body, block_var, file_path, issues, included)
56
59
  end
@@ -60,6 +63,27 @@ module EagerEye
60
63
 
61
64
  private
62
65
 
66
+ def get_association_preloads(model_name)
67
+ key = "#{model_name}#*"
68
+ preloaded = Set.new
69
+ @association_preloads&.each do |assoc_key, assocs|
70
+ preloaded.merge(assocs) if assoc_key.start_with?(key)
71
+ end
72
+ preloaded
73
+ end
74
+
75
+ def infer_model_name_from_collection(node)
76
+ # Infer model name from collection (posts -> Post, users -> User)
77
+ return nil unless node&.type == :send
78
+
79
+ # Handle: Model.includes, @var.method calls
80
+ receiver = node.children[0]
81
+ case receiver&.type
82
+ when :const
83
+ receiver.children[1].to_s
84
+ end
85
+ end
86
+
63
87
  def iteration_block?(node)
64
88
  return false unless node.type == :block
65
89
 
@@ -71,14 +95,9 @@ module EagerEye
71
95
  end
72
96
 
73
97
  def extract_block_variable(block_node)
74
- args_node = block_node.children[1]
75
- return nil unless args_node&.type == :args
76
- return nil if args_node.children.empty?
77
-
78
- first_arg = args_node.children[0]
79
- return nil unless first_arg&.type == :arg
80
-
81
- first_arg.children[0]
98
+ args = block_node&.children&.fetch(1, nil)
99
+ first_child = args&.children&.first
100
+ first_child&.type == :arg ? first_child.children[0] : nil
82
101
  end
83
102
 
84
103
  def extract_included_associations(collection_node)
@@ -101,19 +120,21 @@ module EagerEye
101
120
  @variable_preloads = {}
102
121
  @single_record_variables = Set.new
103
122
 
104
- traverse_ast(ast) do |node|
105
- next unless %i[lvasgn ivasgn].include?(node.type)
123
+ traverse_ast(ast) { |node| process_variable_assignment(node) }
124
+ end
106
125
 
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
+ def process_variable_assignment(node)
127
+ return unless %i[lvasgn ivasgn].include?(node.type)
111
128
 
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
+ var_type = node.type == :lvasgn ? :lvar : :ivar
130
+ var_name = node.children[0]
131
+ value_node = node.children[1]
132
+ return unless value_node
133
+
134
+ key = [var_type, var_name]
135
+ preloaded = extract_included_associations(value_node)
136
+ @variable_preloads[key] = preloaded unless preloaded.empty?
137
+ @single_record_variables.add(key) if single_record_query?(value_node)
117
138
  end
118
139
 
119
140
  def extract_variable_preloads(node)
@@ -130,11 +151,18 @@ module EagerEye
130
151
  end
131
152
 
132
153
  def single_record_query?(node)
154
+ last_send = find_last_send_method(node)
155
+ last_send && SINGLE_RECORD_METHODS.include?(last_send)
156
+ end
157
+
158
+ def find_last_send_method(node)
133
159
  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])
160
+ current = current.children[0] while current&.type == :send && !single_record_method?(current)
161
+ current&.type == :send ? current.children[1] : nil
162
+ end
163
+
164
+ def single_record_method?(node)
165
+ SINGLE_RECORD_METHODS.include?(node.children[1])
138
166
  end
139
167
 
140
168
  def single_record_iteration?(node)
@@ -145,67 +173,50 @@ module EagerEye
145
173
  end
146
174
 
147
175
  def extract_includes_from_method(method_node, included_set)
148
- args = method_node.children[2..]
149
- args&.each do |arg|
150
- case arg&.type
151
- when :sym
152
- # includes(:product)
153
- included_set.add(arg.children[0])
154
- when :hash
155
- # includes(product: :manufacturer)
156
- extract_from_hash(arg, included_set)
157
- end
158
- end
176
+ args = extract_method_args(method_node)
177
+ included_set.merge(extract_symbols_from_args(args))
159
178
  end
160
179
 
161
180
  def extract_from_hash(hash_node, included_set)
162
- hash_node.children.each do |pair|
163
- key = pair.children[0]
164
- included_set.add(key.children[0]) if key&.type == :sym
165
- end
181
+ extract_symbols_from_hash(hash_node, included_set)
166
182
  end
167
183
 
168
184
  def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
169
- reported_associations = Set.new
170
-
185
+ reported = Set.new
171
186
  traverse_ast(node) do |child|
172
- next unless child.type == :send
187
+ next unless should_report_issue?(child, block_var, reported, included_associations)
173
188
 
174
- receiver = child.children[0]
175
- method_name = child.children[1]
176
-
177
- # Only detect direct calls on block variable (post.author, not post.author.name)
178
- next unless direct_call_on_block_var?(receiver, block_var)
179
- next unless likely_association?(method_name)
180
-
181
- # Skip if association is already included
182
- next if included_associations.include?(method_name)
183
-
184
- # Avoid duplicate reports for same association on same line
185
- report_key = "#{child.loc.line}:#{method_name}"
186
- next if reported_associations.include?(report_key)
187
-
188
- reported_associations << report_key
189
-
190
- issues << create_issue(
191
- file_path: file_path,
192
- line_number: child.loc.line,
193
- message: "Potential N+1 query: `#{block_var}.#{method_name}` called inside iteration",
194
- suggestion: "Consider using `includes(:#{method_name})` on the collection before iterating"
195
- )
189
+ add_n_plus_one_issue(child, block_var, file_path, issues, reported)
196
190
  end
197
191
  end
198
192
 
199
- def direct_call_on_block_var?(receiver, block_var)
200
- return false unless receiver
193
+ def should_report_issue?(child, block_var, reported, included)
194
+ return false unless child.type == :send
195
+
196
+ receiver = child.children[0]
197
+ method = child.children[1]
198
+ return false unless receiver&.type == :lvar && receiver.children[0] == block_var
199
+ return false if excluded?(method, included)
201
200
 
202
- receiver.type == :lvar && receiver.children[0] == block_var
201
+ key = "#{child.loc.line}:#{method}"
202
+ !reported.include?(key) && reported.add(key)
203
203
  end
204
204
 
205
- def likely_association?(method_name)
206
- return false if EXCLUDED_METHODS.include?(method_name)
205
+ def excluded?(method, included)
206
+ EXCLUDED_METHODS.include?(method) ||
207
+ !ASSOCIATION_NAMES.include?(method.to_s) ||
208
+ included.include?(method)
209
+ end
207
210
 
208
- ASSOCIATION_NAMES.include?(method_name.to_s)
211
+ def add_n_plus_one_issue(node, block_var, file_path, issues, reported)
212
+ method = node.children[1]
213
+ reported << "#{node.loc.line}:#{method}"
214
+ issues << create_issue(
215
+ file_path: file_path,
216
+ line_number: node.loc.line,
217
+ message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
218
+ suggestion: "Use `includes(:#{method})` before iterating"
219
+ )
209
220
  end
210
221
  end
211
222
  end
@@ -12,126 +12,136 @@ module EagerEye
12
12
  @file_path = file_path
13
13
  @pluck_variables = {}
14
14
  @map_id_variables = {}
15
+ @critical_pluck_variables = {}
15
16
 
16
17
  return @issues unless ast
17
18
 
18
- collect_pluck_assignments(ast)
19
- collect_map_id_assignments(ast)
20
- find_where_with_pluck_var(ast)
21
-
19
+ visit(ast)
22
20
  @issues
23
21
  end
24
22
 
25
23
  private
26
24
 
27
- def collect_pluck_assignments(node)
25
+ def visit(node)
28
26
  return unless node.is_a?(Parser::AST::Node)
29
27
 
30
- if local_variable_assignment?(node)
31
- var_name = node.children[0]
32
- value = node.children[1]
33
-
34
- @pluck_variables[var_name] = node.loc.line if pluck_call?(value)
35
- end
28
+ collect_assignments(node)
29
+ check_where_calls(node)
36
30
 
37
- node.children.each do |child|
38
- collect_pluck_assignments(child)
39
- end
31
+ node.children.each { |child| visit(child) }
40
32
  end
41
33
 
42
- def collect_map_id_assignments(node)
43
- return unless node.is_a?(Parser::AST::Node)
34
+ def collect_assignments(node)
35
+ return unless local_variable_assignment?(node)
44
36
 
45
- if local_variable_assignment?(node)
46
- var_name = node.children[0]
47
- value = node.children[1]
37
+ var_name = node.children[0]
38
+ value = node.children[1]
48
39
 
49
- @map_id_variables[var_name] = node.loc.line if map_id_call?(value)
50
- end
51
-
52
- node.children.each do |child|
53
- collect_map_id_assignments(child)
54
- end
40
+ @critical_pluck_variables[var_name] = node.loc.line if all_pluck_call?(value)
41
+ @pluck_variables[var_name] = node.loc.line if pluck_call?(value)
42
+ @map_id_variables[var_name] = node.loc.line if map_id_call?(value)
55
43
  end
56
44
 
57
- def find_where_with_pluck_var(node)
58
- return unless node.is_a?(Parser::AST::Node)
45
+ def check_where_calls(node)
46
+ return unless where_call?(node)
59
47
 
60
- add_issue(node) if where_call_with_pluck_var?(node)
61
-
62
- node.children.each do |child|
63
- find_where_with_pluck_var(child)
64
- end
48
+ add_critical_issue(node) if critical_pluck?(node)
49
+ add_issue(node) if regular_pluck?(node)
65
50
  end
66
51
 
67
52
  def local_variable_assignment?(node)
68
53
  node.type == :lvasgn
69
54
  end
70
55
 
56
+ def where_call?(node)
57
+ node.type == :send && node.children[1] == :where
58
+ end
59
+
71
60
  def pluck_call?(node)
72
- return false unless node.is_a?(Parser::AST::Node)
73
- return false unless node.type == :send
61
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :send
74
62
 
75
- method_name = node.children[1]
76
- %i[pluck ids].include?(method_name)
63
+ method = node.children[1]
64
+ %i[pluck ids].include?(method)
65
+ end
66
+
67
+ def all_pluck_call?(node)
68
+ return false unless pluck_call?(node)
69
+
70
+ receiver = node.children[0]
71
+ receiver.is_a?(Parser::AST::Node) && receiver.type == :send &&
72
+ receiver.children[1] == :all
77
73
  end
78
74
 
79
75
  def map_id_call?(node)
80
76
  return false unless node.is_a?(Parser::AST::Node)
81
77
 
82
- case node.type
83
- when :block then block_map_call?(node)
84
- when :send then send_map_id_call?(node)
85
- else false
86
- end
78
+ block_map?(node) || send_map?(node)
87
79
  end
88
80
 
89
- def block_map_call?(node)
90
- send_node = node.children[0]
91
- return false unless send_node&.type == :send
81
+ def block_map?(node)
82
+ return false unless node.type == :block
92
83
 
93
- %i[map collect].include?(send_node.children[1])
84
+ send_node = node.children[0]
85
+ send_node&.type == :send && %i[map collect].include?(send_node.children[1])
94
86
  end
95
87
 
96
- def send_map_id_call?(node)
97
- method_name = node.children[1]
98
- return false unless %i[map collect].include?(method_name)
88
+ def send_map?(node)
89
+ return false unless node.type == :send
99
90
 
100
- node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
91
+ method = node.children[1]
92
+ %i[map collect].include?(method) &&
93
+ node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
101
94
  end
102
95
 
103
96
  def symbol_to_proc_id?(node)
104
- return false unless node.is_a?(Parser::AST::Node)
105
- return false unless node.type == :block_pass
97
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :block_pass
106
98
 
107
- sym_node = node.children[0]
108
- return false unless sym_node&.type == :sym
99
+ sym = node.children[0]
100
+ sym&.type == :sym && %i[id to_i].include?(sym.children[0])
101
+ end
109
102
 
110
- %i[id to_i].include?(sym_node.children[0])
103
+ def regular_pluck?(node)
104
+ where_args = node.children[2..]
105
+ where_args.any? { |arg| pluck_var_in_hash?(arg) }
111
106
  end
112
107
 
113
- def where_call_with_pluck_var?(node)
114
- return false unless node.type == :send
115
- return false unless node.children[1] == :where
108
+ def critical_pluck?(node)
109
+ where_args = node.children[2..]
110
+ where_args.any? { |arg| critical_pluck_in_hash?(arg) }
111
+ end
112
+
113
+ def pluck_var_in_hash?(node)
114
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
116
115
 
117
- args = node.children[2..]
118
- args.any? { |arg| hash_with_pluck_var?(arg) }
116
+ node.children.any? do |pair|
117
+ next false unless pair.type == :pair
118
+
119
+ pluck_value?(pair.children[1])
120
+ end
119
121
  end
120
122
 
121
- def hash_with_pluck_var?(node)
122
- return false unless node.is_a?(Parser::AST::Node)
123
- return false unless node.type == :hash
123
+ def critical_pluck_in_hash?(node)
124
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
124
125
 
125
126
  node.children.any? do |pair|
126
127
  next false unless pair.type == :pair
127
128
 
128
- value = pair.children[1]
129
- if value.type == :lvar
130
- var_name = value.children[0]
131
- @pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
132
- else
133
- false
134
- end
129
+ critical_value?(pair.children[1])
130
+ end
131
+ end
132
+
133
+ def pluck_value?(value)
134
+ return false unless value.type == :lvar
135
+
136
+ var_name = value.children[0]
137
+ @pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
138
+ end
139
+
140
+ def critical_value?(value)
141
+ if value.type == :lvar
142
+ @critical_pluck_variables.key?(value.children[0])
143
+ else
144
+ all_pluck_call?(value)
135
145
  end
136
146
  end
137
147
 
@@ -139,8 +149,19 @@ module EagerEye
139
149
  @issues << create_issue(
140
150
  file_path: @file_path,
141
151
  line_number: node.loc.line,
142
- message: "Using plucked/mapped array in `where` causes two queries and holds IDs in memory",
143
- suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`"
152
+ message: "Using plucked array in `where` causes two queries and memory overhead",
153
+ suggestion: "Use `.select(:id)` subquery instead: `Model.where(col: OtherModel.select(:id))`",
154
+ severity: :warning
155
+ )
156
+ end
157
+
158
+ def add_critical_issue(node)
159
+ @issues << create_issue(
160
+ file_path: @file_path,
161
+ line_number: node.loc.line,
162
+ message: "Using `.all.pluck(:id)` loads entire table into memory - highly inefficient",
163
+ suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.select(:id))`",
164
+ severity: :error
144
165
  )
145
166
  end
146
167
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.0.9"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "eager_eye/version"
4
4
  require_relative "eager_eye/configuration"
5
5
  require_relative "eager_eye/issue"
6
+ require_relative "eager_eye/association_parser"
6
7
  require_relative "eager_eye/detectors/base"
7
8
  require_relative "eager_eye/detectors/loop_association"
8
9
  require_relative "eager_eye/detectors/serializer_nesting"
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.9
4
+ version: 1.1.0
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-26 00:00:00.000000000 Z
11
+ date: 2025-12-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -59,6 +59,7 @@ files:
59
59
  - exe/eager_eye
60
60
  - lib/eager_eye.rb
61
61
  - lib/eager_eye/analyzer.rb
62
+ - lib/eager_eye/association_parser.rb
62
63
  - lib/eager_eye/auto_fixer.rb
63
64
  - lib/eager_eye/cli.rb
64
65
  - lib/eager_eye/comment_parser.rb