eager_eye 1.2.3 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fbde5efcfa326afaf12645241e817095fbe2ac39ad69576f101d81f294b8337
4
- data.tar.gz: dbd0a6d263aab997fe358cbf2194ce55629d587feffcd4652e7f13d58d459343
3
+ metadata.gz: 6fa0e6e3acb2c37751e7ab49a58826a016a68c7a33cc6305ddb6b82d6787c462
4
+ data.tar.gz: af81d8c0cfde13914b00a9f64b4b1bef6731b12902a1358c7f27473c2ab9a68d
5
5
  SHA512:
6
- metadata.gz: 4b0ddfa32f3e65955cc6da31339ca86985554709f229a7cc2ebc1ce84262f767c5104451df34e359e3d9f9470d4c916192652ed44ea958dbfcfe680b057b8996
7
- data.tar.gz: 1cf0da613bed275088977144260c1b44ddd3ddadc72f063bce5ac3706a827a63e526dd043cc1e76a544fabe809b7c48432782900ac1602f25c3f218d44b426d0
6
+ metadata.gz: a0e233e5c53c6ee3c7a32239ca20625a58deb1b385af022cb582433d02070f7b861873f44cef5fe786ede0307caee4105202e49844902e8d75e14039623bdc74
7
+ data.tar.gz: 238ca3ba9a777686867752bc4abbf1d10081d73215e3c0f7ed0e9360f7c380298b4736177c703f1c921fb22b4296e7544379bef69021408f9e9ad86280a3716c
data/.rubocop.yml CHANGED
@@ -20,7 +20,7 @@ Metrics/BlockLength:
20
20
  - "lib/eager_eye/railtie.rb"
21
21
 
22
22
  Metrics/ClassLength:
23
- Max: 165
23
+ Max: 170
24
24
 
25
25
  Metrics/ParameterLists:
26
26
  Max: 6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.5] - 2026-02-21
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `DecoratorNPlusOne`** - Detects N+1 queries inside decorator/presenter classes
15
+ - Catches `object.comments.map(...)`, `__getobj__.items.each { ... }`, `model.posts`, `source.tags` patterns
16
+ - Identifies decorator classes by inheritance (`Draper::Decorator`, `SimpleDelegator`, `Delegator`) or name suffix (`Decorator`, `Presenter`, `ViewObject`)
17
+ - Targets all four object reference styles: `object`, `__getobj__`, `source`, `model`
18
+ - Skips ActiveStorage methods (`attached?`, `blob`, `variant`, etc.) to prevent false positives
19
+ - Suggests eager loading in the controller before decorating the collection
20
+
21
+ ## [1.2.4] - 2026-02-21
22
+
23
+ ### Added
24
+
25
+ - **New Detector: `DelegationNPlusOne`** - Detects hidden N+1 queries caused by `delegate :method, to: :association`
26
+ - `delegate :name, :email, to: :user` calls inside loops load the target association on each iteration
27
+ - EagerEye previously could not catch these because `order.name` looks like a plain attribute, not an association access
28
+ - Parses model files for `delegate` declarations and tracks delegated method → association mappings
29
+ - Detects delegated method calls in `each`, `map`, `select`, `flat_map`, `find_each`, and all other iteration methods
30
+ - Respects `includes`, `preload`, and `eager_load` — suppresses warnings when the target association is preloaded
31
+ - Supports both local (same-file) delegate declarations and cross-file detection via model parsing
32
+
10
33
  ## [1.2.3] - 2026-02-15
11
34
 
12
35
  ### Added
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.2.3-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.5-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>
@@ -42,7 +42,7 @@
42
42
 
43
43
  ## Features
44
44
 
45
- ✨ **Detects 7 types of N+1 problems:**
45
+ ✨ **Detects 9 types of N+1 problems:**
46
46
  - Loop associations (queries in iterations)
47
47
  - Serializer nesting issues
48
48
  - Missing counter caches
@@ -50,6 +50,8 @@
50
50
  - Count in iteration patterns
51
51
  - Callback query N+1s
52
52
  - Pluck to array misuse
53
+ - Delegation N+1s (hidden via `delegate :method, to: :association`)
54
+ - Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
53
55
 
54
56
  🔧 **Developer-friendly:**
55
57
  - Inline suppression (like RuboCop)
@@ -339,6 +341,63 @@ Post.where(user_id: User.active.select(:id))
339
341
  - ⚠️ **Warning** - Scoped `.pluck(:id)` (two queries, memory overhead)
340
342
  - 🔴 **Error** - Unscoped `.all.pluck(:id)` (loads entire table)
341
343
 
344
+ ### 8. Delegation N+1
345
+
346
+ Detects when methods delegated via `delegate :method, to: :association` are called inside loops without preloading the target association. These are invisible to `LoopAssociation` because `order.name` looks like a plain attribute, not an association access.
347
+
348
+ ```ruby
349
+ # Model
350
+ class Order < ApplicationRecord
351
+ belongs_to :user
352
+ delegate :full_name, :email, to: :user
353
+ end
354
+
355
+ # Bad - N+1 (each call hits the database for user)
356
+ orders.each do |order|
357
+ order.full_name # actually: order.user.full_name — loads user for each order!
358
+ order.email # actually: order.user.email — another load!
359
+ end
360
+
361
+ # Good - Eager load the delegated-to association
362
+ orders.includes(:user).each do |order|
363
+ order.full_name # no N+1 — user is already loaded
364
+ order.email # no N+1 — user is already loaded
365
+ end
366
+ ```
367
+
368
+ EagerEye detects these by:
369
+ 1. Scanning model files for `delegate :method, to: :assoc` declarations
370
+ 2. Tracking which methods delegate to which associations
371
+ 3. Flagging calls to those methods inside iteration blocks when the association is not preloaded
372
+
373
+ ### 9. Decorator N+1
374
+
375
+ Detects N+1 queries inside Draper decorators, SimpleDelegator subclasses, and classes named `Decorator`, `Presenter`, or `ViewObject`. Each decorator wraps a single record — when a collection is decorated without preloading, every method that accesses an association triggers a new query per record.
376
+
377
+ ```ruby
378
+ # Bad - N+1 on each decorated post
379
+ class PostDecorator < Draper::Decorator
380
+ def comment_summary
381
+ object.comments.map(&:body).join(", ") # Query for each post!
382
+ end
383
+
384
+ def tag_list
385
+ object.tags.map(&:name).join(", ") # Another query for each post!
386
+ end
387
+ end
388
+
389
+ # Controller - no includes = N+1
390
+ @posts = Post.all.decorate
391
+
392
+ # Good - Eager load before decorating
393
+ @posts = Post.includes(:comments, :tags).all.decorate
394
+ ```
395
+
396
+ Supports the following object references inside decorators:
397
+ - `object` — Draper standard
398
+ - `__getobj__` — SimpleDelegator standard
399
+ - `source`, `model` — alternative Draper aliases
400
+
342
401
  ## Inline Suppression
343
402
 
344
403
  Suppress false positives using inline comments (RuboCop-style):
@@ -381,6 +440,8 @@ Both CamelCase and snake_case formats are accepted:
381
440
  | Count in Iteration | `CountInIteration` | `count_in_iteration` |
382
441
  | Callback Query | `CallbackQuery` | `callback_query` |
383
442
  | Pluck to Array | `PluckToArray` | `pluck_to_array` |
443
+ | Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
444
+ | Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
384
445
  | All Detectors | `all` | `all` |
385
446
 
386
447
  ## Auto-fix (Experimental)
@@ -483,6 +544,8 @@ enabled_detectors:
483
544
  - count_in_iteration
484
545
  - callback_query
485
546
  - pluck_to_array
547
+ - delegation_n_plus_one
548
+ - decorator_n_plus_one
486
549
 
487
550
  # Severity levels per detector (error, warning, info)
488
551
  severity_levels:
@@ -492,6 +555,8 @@ severity_levels:
492
555
  count_in_iteration: warning
493
556
  callback_query: warning
494
557
  pluck_to_array: warning # Optimization
558
+ delegation_n_plus_one: warning # Hidden delegation N+1
559
+ decorator_n_plus_one: warning # Decorator/Presenter N+1
495
560
  missing_counter_cache: info # Suggestion
496
561
 
497
562
  # Minimum severity to report (default: info)
@@ -11,20 +11,24 @@ module EagerEye
11
11
  custom_method_query: Detectors::CustomMethodQuery,
12
12
  count_in_iteration: Detectors::CountInIteration,
13
13
  callback_query: Detectors::CallbackQuery,
14
- pluck_to_array: Detectors::PluckToArray
14
+ pluck_to_array: Detectors::PluckToArray,
15
+ delegation_n_plus_one: Detectors::DelegationNPlusOne,
16
+ decorator_n_plus_one: Detectors::DecoratorNPlusOne
15
17
  }.freeze
16
18
 
17
- attr_reader :paths, :issues, :association_preloads
19
+ attr_reader :paths, :issues, :association_preloads, :delegation_maps
18
20
 
19
21
  def initialize(paths: nil)
20
22
  @paths = Array(paths || EagerEye.configuration.app_path)
21
23
  @issues = []
22
24
  @association_preloads = {}
25
+ @delegation_maps = {}
23
26
  end
24
27
 
25
28
  def run
26
29
  @issues = []
27
30
  collect_association_preloads
31
+ collect_delegation_maps
28
32
  analyze_files
29
33
  @issues
30
34
  end
@@ -44,6 +48,19 @@ module EagerEye
44
48
  nil
45
49
  end
46
50
 
51
+ def collect_delegation_maps
52
+ model_files.each do |file_path|
53
+ ast = parse_source(File.read(file_path))
54
+ next unless ast
55
+
56
+ parser = DelegationParser.new
57
+ parser.parse_model(ast, extract_model_name(file_path))
58
+ @delegation_maps.merge!(parser.delegation_maps)
59
+ end
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
47
64
  def model_files
48
65
  Dir.glob(File.join(@paths[0], "models", "**", "*.rb"))
49
66
  end
@@ -85,10 +102,7 @@ module EagerEye
85
102
  min_severity = EagerEye.configuration.min_severity
86
103
 
87
104
  enabled_detectors.each do |detector|
88
- args = [ast, file_path]
89
- args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
90
-
91
- file_issues = detector.detect(*args)
105
+ file_issues = detector.detect(*detector_args(detector, ast, file_path))
92
106
  file_issues.reject! { |issue| comment_parser.disabled_at?(issue.line_number, issue.detector) }
93
107
  file_issues.select! { |issue| issue.meets_minimum_severity?(min_severity) }
94
108
  @issues.concat(file_issues)
@@ -103,6 +117,13 @@ module EagerEye
103
117
  nil
104
118
  end
105
119
 
120
+ def detector_args(detector, ast, file_path)
121
+ args = [ast, file_path]
122
+ args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
123
+ args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
124
+ args
125
+ end
126
+
106
127
  def enabled_detectors
107
128
  @enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
108
129
  detector_class = DETECTOR_CLASSES[name]
@@ -8,7 +8,7 @@ module EagerEye
8
8
  DEFAULT_DETECTORS = %i[
9
9
  loop_association serializer_nesting missing_counter_cache
10
10
  custom_method_query count_in_iteration callback_query
11
- pluck_to_array
11
+ pluck_to_array delegation_n_plus_one decorator_n_plus_one
12
12
  ].freeze
13
13
 
14
14
  DEFAULT_SEVERITY_LEVELS = {
@@ -18,7 +18,9 @@ module EagerEye
18
18
  custom_method_query: :warning,
19
19
  count_in_iteration: :warning,
20
20
  callback_query: :warning,
21
- pluck_to_array: :warning
21
+ pluck_to_array: :warning,
22
+ delegation_n_plus_one: :warning,
23
+ decorator_n_plus_one: :warning
22
24
  }.freeze
23
25
 
24
26
  VALID_SEVERITIES = %i[info warning error].freeze
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ class DelegationParser
5
+ attr_reader :delegation_maps
6
+
7
+ def initialize
8
+ @delegation_maps = {}
9
+ end
10
+
11
+ def parse_model(ast, model_name)
12
+ return unless ast
13
+
14
+ traverse(ast, model_name)
15
+ end
16
+
17
+ private
18
+
19
+ def traverse(node, model_name)
20
+ return unless node.is_a?(Parser::AST::Node)
21
+
22
+ check_delegate(node, model_name)
23
+ node.children.each { |child| traverse(child, model_name) }
24
+ end
25
+
26
+ def check_delegate(node, model_name)
27
+ return unless bare_delegate_call?(node)
28
+
29
+ args = node.children[2..]
30
+ methods = delegate_methods(args)
31
+ return if methods.empty?
32
+
33
+ to_target = extract_to_target(args)
34
+ return unless to_target
35
+
36
+ register_delegates(model_name, methods, to_target)
37
+ end
38
+
39
+ def bare_delegate_call?(node)
40
+ node.type == :send && node.children[0].nil? && node.children[1] == :delegate
41
+ end
42
+
43
+ def delegate_methods(args)
44
+ args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
45
+ end
46
+
47
+ def register_delegates(model_name, methods, to_target)
48
+ @delegation_maps[model_name] ||= {}
49
+ methods.each { |m| @delegation_maps[model_name][m] = to_target }
50
+ end
51
+
52
+ def extract_to_target(args)
53
+ hash_arg = args.find { |a| a&.type == :hash }
54
+ return unless hash_arg
55
+
56
+ to_pair = hash_arg.children.find { |p| to_key_pair?(p) }
57
+ extract_sym_value(to_pair)
58
+ end
59
+
60
+ def extract_sym_value(node)
61
+ return unless node
62
+
63
+ value = node.children[1]
64
+ value.children[0] if value&.type == :sym
65
+ end
66
+
67
+ def to_key_pair?(pair)
68
+ pair.type == :pair &&
69
+ pair.children[0]&.type == :sym &&
70
+ pair.children[0].children[0] == :to
71
+ end
72
+ end
73
+ end
@@ -29,6 +29,9 @@ module EagerEye
29
29
  ITERATION_METHODS = %i[each map select find_all reject collect
30
30
  find_each find_in_batches in_batches].freeze
31
31
  AR_BATCH_METHODS = %i[find_each find_in_batches in_batches].freeze
32
+ NON_AR_NAMESPACES = %w[Sidekiq Redis ActionCable ActionMailer Kafka].freeze
33
+ TRANSACTIONAL_CALLBACKS = %i[before_validation before_save before_create before_update before_destroy
34
+ around_save around_create around_update around_destroy].freeze
32
35
 
33
36
  def self.detector_name
34
37
  :callback_query
@@ -87,7 +90,8 @@ module EagerEye
87
90
 
88
91
  if iteration_block?(node)
89
92
  block_var = extract_block_variable(node)
90
- if block_var && contains_ar_query_on_variable?(node, block_var)
93
+ collection = node.children[0].children[0]
94
+ if block_var && !non_ar_collection?(collection) && contains_ar_query_on_variable?(node, block_var)
91
95
  add_iteration_issue(node, method_name, callback_type)
92
96
  find_query_calls_in_block(node, method_name, callback_type, block_var)
93
97
  end
@@ -128,26 +132,41 @@ module EagerEye
128
132
 
129
133
  def add_query_issue(node, method_name, callback_type)
130
134
  query_method = node.children[1]
135
+ suggestion = if transactional_callback?(callback_type)
136
+ "Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
137
+ else
138
+ "Callbacks run on every save/create/update. Consider moving to a background job"
139
+ end
131
140
 
132
141
  @issues << create_issue(
133
142
  file_path: @file_path,
134
143
  line_number: node.loc.line,
135
144
  message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
136
145
  severity: :warning,
137
- suggestion: "Callbacks run on every save/create/update. Consider moving to a background job"
146
+ suggestion: suggestion
138
147
  )
139
148
  end
140
149
 
141
150
  def add_iteration_issue(node, method_name, callback_type)
151
+ suggestion = if transactional_callback?(callback_type)
152
+ "Avoid DB queries in before_*/around_* callbacks. Preload data outside the iteration instead"
153
+ else
154
+ "Avoid iterations in callbacks. Use background jobs for bulk operations"
155
+ end
156
+
142
157
  @issues << create_issue(
143
158
  file_path: @file_path,
144
159
  line_number: node.loc.line,
145
160
  message: "Iteration found in `#{callback_type}` callback `:#{method_name}` - potential N+1",
146
161
  severity: :error,
147
- suggestion: "Avoid iterations in callbacks. Use background jobs for bulk operations"
162
+ suggestion: suggestion
148
163
  )
149
164
  end
150
165
 
166
+ def transactional_callback?(callback_type)
167
+ TRANSACTIONAL_CALLBACKS.include?(callback_type)
168
+ end
169
+
151
170
  def extract_block_variable(block_node)
152
171
  args_node = block_node.children[1]
153
172
  return nil unless args_node&.type == :args
@@ -171,6 +190,22 @@ module EagerEye
171
190
  end
172
191
  end
173
192
 
193
+ def non_ar_collection?(node)
194
+ ns = root_namespace(node)
195
+ ns && NON_AR_NAMESPACES.include?(ns)
196
+ end
197
+
198
+ def root_namespace(node)
199
+ return nil unless node.is_a?(Parser::AST::Node)
200
+
201
+ case node.type
202
+ when :const
203
+ node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
204
+ when :send, :block
205
+ root_namespace(node.children[0])
206
+ end
207
+ end
208
+
174
209
  def contains_ar_query_on_variable?(node, block_var)
175
210
  return false unless node.is_a?(Parser::AST::Node)
176
211
  return true if query_call?(node) && receiver_chain_starts_with?(node.children[0], block_var)
@@ -6,6 +6,7 @@ module EagerEye
6
6
  COUNT_METHODS = %i[count].freeze
7
7
  ITERATION_METHODS = %i[each map select find_all reject collect each_with_index each_with_object flat_map
8
8
  find_each find_in_batches in_batches].freeze
9
+ ARRAY_METHOD_SUFFIXES = %w[_ids _tags _types _codes _names _values].freeze
9
10
 
10
11
  def self.detector_name
11
12
  :count_in_iteration
@@ -52,9 +53,17 @@ module EagerEye
52
53
 
53
54
  def count_on_association?(node, block_var)
54
55
  node.type == :send && COUNT_METHODS.include?(node.children[1]) &&
56
+ !array_returning_method?(node.children[0]) &&
55
57
  association_call_on_block_var?(node.children[0], block_var)
56
58
  end
57
59
 
60
+ def array_returning_method?(node)
61
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :send
62
+
63
+ method_name = node.children[1].to_s
64
+ ARRAY_METHOD_SUFFIXES.any? { |suffix| method_name.end_with?(suffix) }
65
+ end
66
+
58
67
  def association_call_on_block_var?(node, block_var)
59
68
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
60
69
 
@@ -7,7 +7,7 @@ module EagerEye
7
7
  maximum].freeze
8
8
  SAFE_QUERY_METHODS = %i[first last take count sum find size length ids].freeze
9
9
  SAFE_TRANSFORM_METHODS = %i[keys values split [] params sort pluck ids to_s to_a to_i chars bytes].freeze
10
- ARRAY_COLUMN_SUFFIXES = %w[_ids _tags _types _codes _names _values].freeze
10
+ ARRAY_COLUMN_SUFFIXES = %w[_ids _tags _types _codes _names _values _arr].freeze
11
11
  ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
12
12
  find_each find_in_batches in_batches].freeze
13
13
 
@@ -62,6 +62,7 @@ module EagerEye
62
62
  method_name = node.children[1]
63
63
  return false unless QUERY_METHODS.include?(method_name)
64
64
  return false if skip_array_method?(node, block_var, is_array_collection)
65
+ return false if receiver_is_query_chain?(node.children[0])
65
66
 
66
67
  receiver_chain_starts_with?(node.children[0], block_var)
67
68
  end
@@ -130,6 +131,10 @@ module EagerEye
130
131
  SAFE_TRANSFORM_METHODS.include?(method_name) || array_column_method?(method_name)
131
132
  end
132
133
 
134
+ def receiver_is_query_chain?(node)
135
+ node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
136
+ end
137
+
133
138
  def array_column_method?(method_name)
134
139
  method_str = method_name.to_s
135
140
  ARRAY_COLUMN_SUFFIXES.any? { |suffix| method_str.end_with?(suffix) }
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class DecoratorNPlusOne < Base
6
+ DECORATOR_PATTERNS = %w[Draper::Decorator SimpleDelegator Delegator].freeze
7
+ OBJECT_REFS = %i[object __getobj__ source model].freeze
8
+ ACTIVE_STORAGE_METHODS = %i[attached? attach attachment attachments blob blobs purge purge_later variant
9
+ preview].freeze
10
+ HAS_MANY_ASSOCIATIONS = %w[
11
+ authors users owners creators admins members customers clients
12
+ posts articles comments categories tags children companies organizations
13
+ projects tasks items orders products accounts profiles settings
14
+ images avatars photos attachments documents
15
+ ].freeze
16
+
17
+ def self.detector_name
18
+ :decorator_n_plus_one
19
+ end
20
+
21
+ def detect(ast, file_path)
22
+ return [] unless ast
23
+
24
+ issues = []
25
+
26
+ traverse_ast(ast) do |node|
27
+ next unless decorator_class?(node)
28
+
29
+ find_association_accesses(node, file_path, issues)
30
+ end
31
+
32
+ issues
33
+ end
34
+
35
+ private
36
+
37
+ def decorator_class?(node)
38
+ return false unless node.type == :class
39
+
40
+ class_name = extract_class_name(node)
41
+ return false unless class_name
42
+
43
+ decorator_name_pattern?(class_name) || inherits_from_decorator?(node)
44
+ end
45
+
46
+ def decorator_name_pattern?(class_name)
47
+ class_name.end_with?("Decorator", "Presenter", "ViewObject")
48
+ end
49
+
50
+ def extract_class_name(class_node)
51
+ name_node = class_node.children[0]
52
+ name_node.children[1].to_s if name_node&.type == :const
53
+ end
54
+
55
+ def inherits_from_decorator?(class_node)
56
+ parent_node = class_node.children[1]
57
+ return false unless parent_node
58
+
59
+ parent_name = const_to_string(parent_node)
60
+ DECORATOR_PATTERNS.any? { |p| parent_name&.include?(p.split("::").last) }
61
+ end
62
+
63
+ def const_to_string(node)
64
+ return nil unless node&.type == :const
65
+
66
+ parts = []
67
+ current = node
68
+ while current&.type == :const
69
+ parts.unshift(current.children[1].to_s)
70
+ current = current.children[0]
71
+ end
72
+ parts.join("::")
73
+ end
74
+
75
+ def find_association_accesses(class_node, file_path, issues)
76
+ body = class_node.children[2]
77
+ return unless body
78
+
79
+ traverse_ast(body) do |node|
80
+ next unless node.type == :def
81
+
82
+ find_associations_in_method(node.children[2], file_path, issues)
83
+ end
84
+ end
85
+
86
+ def find_associations_in_method(method_body, file_path, issues)
87
+ return unless method_body
88
+
89
+ storage_lines = collect_active_storage_lines(method_body)
90
+ traverse_ast(method_body) do |node|
91
+ next unless association_access?(node, storage_lines)
92
+
93
+ receiver = node.children[0]
94
+ method_name = node.children[1]
95
+ issues << create_decorator_issue(file_path, node.loc.line, receiver, method_name)
96
+ end
97
+ end
98
+
99
+ def association_access?(node, storage_lines)
100
+ return false unless node.type == :send
101
+ return false if storage_lines.include?(node.loc.line)
102
+
103
+ object_reference?(node.children[0]) && likely_association?(node.children[1])
104
+ end
105
+
106
+ def collect_active_storage_lines(body)
107
+ lines = Set.new
108
+ traverse_ast(body) do |node|
109
+ next unless node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
110
+
111
+ lines << node.loc.line
112
+ end
113
+ lines
114
+ end
115
+
116
+ def object_reference?(node)
117
+ return false unless node&.type == :send
118
+
119
+ node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
120
+ end
121
+
122
+ def likely_association?(method_name)
123
+ HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
124
+ end
125
+
126
+ def create_decorator_issue(file_path, line, receiver, method_name)
127
+ ref = receiver.children[1]
128
+ create_issue(
129
+ file_path: file_path,
130
+ line_number: line,
131
+ message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
132
+ suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
133
+ )
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class DelegationNPlusOne < Base
6
+ ITERATION_METHODS = %i[
7
+ each map collect select find_all reject filter filter_map flat_map
8
+ find_each find_in_batches in_batches
9
+ ].freeze
10
+ PRELOAD_METHODS = %i[includes preload eager_load].freeze
11
+
12
+ def self.detector_name
13
+ :delegation_n_plus_one
14
+ end
15
+
16
+ def detect(ast, file_path, delegation_maps = {})
17
+ return [] unless ast
18
+
19
+ issues = []
20
+ local_delegates = collect_local_delegates(ast)
21
+
22
+ traverse_ast(ast) do |node|
23
+ next unless iteration_block?(node)
24
+
25
+ block_var = extract_block_variable(node)
26
+ next unless block_var
27
+
28
+ block_body = node.children[2]
29
+ next unless block_body
30
+
31
+ collection_node = node.children[0]
32
+ model_name = infer_model_name(collection_node)
33
+ delegates = build_delegates(model_name, delegation_maps, local_delegates)
34
+ next if delegates.empty?
35
+
36
+ included = extract_included_associations(collection_node)
37
+ find_delegated_calls(block_body, block_var, delegates, included, file_path, issues)
38
+ end
39
+
40
+ issues
41
+ end
42
+
43
+ private
44
+
45
+ def collect_local_delegates(ast)
46
+ delegates = {}
47
+ traverse_ast(ast) do |node|
48
+ next unless delegate_call?(node)
49
+
50
+ extract_delegate_info(node, delegates)
51
+ end
52
+ delegates
53
+ end
54
+
55
+ def delegate_call?(node)
56
+ node.type == :send && node.children[0].nil? && node.children[1] == :delegate
57
+ end
58
+
59
+ def extract_delegate_info(node, delegates)
60
+ args = node.children[2..]
61
+ methods = args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
62
+ return if methods.empty?
63
+
64
+ to_target = extract_to_target(args)
65
+ return unless to_target
66
+
67
+ methods.each { |m| delegates[m] = to_target }
68
+ end
69
+
70
+ def extract_to_target(args)
71
+ hash_arg = args.find { |a| a&.type == :hash }
72
+ return unless hash_arg
73
+
74
+ to_pair = hash_arg.children.find { |p| to_key_pair?(p) }
75
+ extract_sym_value(to_pair)
76
+ end
77
+
78
+ def extract_sym_value(node)
79
+ return unless node
80
+
81
+ value = node.children[1]
82
+ value.children[0] if value&.type == :sym
83
+ end
84
+
85
+ def to_key_pair?(pair)
86
+ pair.type == :pair &&
87
+ pair.children[0]&.type == :sym &&
88
+ pair.children[0].children[0] == :to
89
+ end
90
+
91
+ def build_delegates(model_name, delegation_maps, local_delegates)
92
+ cross_file = model_name ? (delegation_maps[model_name] || {}) : {}
93
+ cross_file.merge(local_delegates)
94
+ end
95
+
96
+ def iteration_block?(node)
97
+ node.type == :block &&
98
+ node.children[0]&.type == :send &&
99
+ ITERATION_METHODS.include?(node.children[0].children[1])
100
+ end
101
+
102
+ def extract_block_variable(block_node)
103
+ args = block_node&.children&.[](1)
104
+ first_arg = args&.children&.first
105
+ first_arg&.type == :arg ? first_arg.children[0] : nil
106
+ end
107
+
108
+ def infer_model_name(node)
109
+ root = find_root_receiver(node)
110
+ root&.type == :const ? root.children[1].to_s : nil
111
+ end
112
+
113
+ def find_root_receiver(node)
114
+ current = node
115
+ current = current.children[0] while current&.type == :send
116
+ current
117
+ end
118
+
119
+ def extract_included_associations(collection_node)
120
+ included = Set.new
121
+ return included unless collection_node&.type == :send
122
+
123
+ current = collection_node
124
+ while current&.type == :send
125
+ extract_from_preload(current, included) if PRELOAD_METHODS.include?(current.children[1])
126
+ current = current.children[0]
127
+ end
128
+ included
129
+ end
130
+
131
+ def extract_from_preload(method_node, included_set)
132
+ args = extract_method_args(method_node)
133
+ included_set.merge(extract_symbols_from_args(args))
134
+ end
135
+
136
+ def find_delegated_calls(block_body, block_var, delegates, included, file_path, issues)
137
+ reported = Set.new
138
+ traverse_ast(block_body) do |node|
139
+ target_assoc = delegation_target(node, block_var, delegates, included, reported)
140
+ next unless target_assoc
141
+
142
+ issues << create_delegation_issue(node, block_var, target_assoc, file_path)
143
+ end
144
+ end
145
+
146
+ def delegation_target(node, block_var, delegates, included, reported)
147
+ return unless node.type == :send
148
+ return unless block_var_receiver?(node, block_var)
149
+
150
+ method = node.children[1]
151
+ target_assoc = delegates[method]
152
+ return unless target_assoc
153
+ return if included.include?(target_assoc)
154
+ return unless reported.add?("#{node.loc.line}:#{method}")
155
+
156
+ target_assoc
157
+ end
158
+
159
+ def block_var_receiver?(node, block_var)
160
+ receiver = node.children[0]
161
+ receiver&.type == :lvar && receiver.children[0] == block_var
162
+ end
163
+
164
+ def create_delegation_issue(node, block_var, target_assoc, file_path)
165
+ method = node.children[1]
166
+ create_issue(
167
+ file_path: file_path,
168
+ line_number: node.loc.line,
169
+ message: "Potential N+1: `#{block_var}.#{method}` is delegated to `#{target_assoc}` — " \
170
+ "loads `#{target_assoc}` on each iteration",
171
+ suggestion: "Use `includes(:#{target_assoc})` before iterating"
172
+ )
173
+ end
174
+ end
175
+ end
176
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.3"
4
+ VERSION = "1.2.5"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "eager_eye/version"
5
5
  require_relative "eager_eye/configuration"
6
6
  require_relative "eager_eye/issue"
7
7
  require_relative "eager_eye/association_parser"
8
+ require_relative "eager_eye/delegation_parser"
8
9
  require_relative "eager_eye/detectors/base"
9
10
  require_relative "eager_eye/detectors/loop_association"
10
11
  require_relative "eager_eye/detectors/serializer_nesting"
@@ -13,6 +14,8 @@ require_relative "eager_eye/detectors/custom_method_query"
13
14
  require_relative "eager_eye/detectors/count_in_iteration"
14
15
  require_relative "eager_eye/detectors/callback_query"
15
16
  require_relative "eager_eye/detectors/pluck_to_array"
17
+ require_relative "eager_eye/detectors/delegation_n_plus_one"
18
+ require_relative "eager_eye/detectors/decorator_n_plus_one"
16
19
  require_relative "eager_eye/comment_parser"
17
20
  require_relative "eager_eye/analyzer"
18
21
  require_relative "eager_eye/fixers/base"
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.2.3
4
+ version: 1.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-15 00:00:00.000000000 Z
11
+ date: 2026-02-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -64,11 +64,14 @@ files:
64
64
  - lib/eager_eye/cli.rb
65
65
  - lib/eager_eye/comment_parser.rb
66
66
  - lib/eager_eye/configuration.rb
67
+ - lib/eager_eye/delegation_parser.rb
67
68
  - lib/eager_eye/detectors/base.rb
68
69
  - lib/eager_eye/detectors/callback_query.rb
69
70
  - lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
70
71
  - lib/eager_eye/detectors/count_in_iteration.rb
71
72
  - lib/eager_eye/detectors/custom_method_query.rb
73
+ - lib/eager_eye/detectors/decorator_n_plus_one.rb
74
+ - lib/eager_eye/detectors/delegation_n_plus_one.rb
72
75
  - lib/eager_eye/detectors/loop_association.rb
73
76
  - lib/eager_eye/detectors/missing_counter_cache.rb
74
77
  - lib/eager_eye/detectors/pluck_to_array.rb