eager_eye 1.2.3 → 1.2.4
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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +35 -2
- data/lib/eager_eye/analyzer.rb +26 -6
- data/lib/eager_eye/configuration.rb +3 -2
- data/lib/eager_eye/delegation_parser.rb +73 -0
- data/lib/eager_eye/detectors/delegation_n_plus_one.rb +176 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2dee00b4a1b227d3fe506af3663b4a00730c312d61f9a1db876c4a59e7cbbd62
|
|
4
|
+
data.tar.gz: 1ce8596e9752c3b2f4fc2fa2d9463fcb9eeca1f50f9031c9ef1acbaef95ba08f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c453c88107dfa5b3fbbdf1c66a2f2ca5fc2d2df3a99f91e27827f36a720e59d4974e9f880fad2668adf05116a81bcd69b8cdca6d3a0c0e24b8f3332f3c886a1
|
|
7
|
+
data.tar.gz: a1bc480d5b3d1d82eb8eccb7697ff50cab9ba94fbf127a08cd39f3278dfe90a95ebc14df28cf518e26bac86ad2ba59eaacdb5efc783e58caae8187d670b4f071
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.4] - 2026-02-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **New Detector: `DelegationNPlusOne`** - Detects hidden N+1 queries caused by `delegate :method, to: :association`
|
|
15
|
+
- `delegate :name, :email, to: :user` calls inside loops load the target association on each iteration
|
|
16
|
+
- EagerEye previously could not catch these because `order.name` looks like a plain attribute, not an association access
|
|
17
|
+
- Parses model files for `delegate` declarations and tracks delegated method → association mappings
|
|
18
|
+
- Detects delegated method calls in `each`, `map`, `select`, `flat_map`, `find_each`, and all other iteration methods
|
|
19
|
+
- Respects `includes`, `preload`, and `eager_load` — suppresses warnings when the target association is preloaded
|
|
20
|
+
- Supports both local (same-file) delegate declarations and cross-file detection via model parsing
|
|
21
|
+
|
|
10
22
|
## [1.2.3] - 2026-02-15
|
|
11
23
|
|
|
12
24
|
### 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.
|
|
13
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.4-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
|
|
45
|
+
✨ **Detects 8 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,7 @@
|
|
|
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`)
|
|
53
54
|
|
|
54
55
|
🔧 **Developer-friendly:**
|
|
55
56
|
- Inline suppression (like RuboCop)
|
|
@@ -339,6 +340,35 @@ Post.where(user_id: User.active.select(:id))
|
|
|
339
340
|
- ⚠️ **Warning** - Scoped `.pluck(:id)` (two queries, memory overhead)
|
|
340
341
|
- 🔴 **Error** - Unscoped `.all.pluck(:id)` (loads entire table)
|
|
341
342
|
|
|
343
|
+
### 8. Delegation N+1
|
|
344
|
+
|
|
345
|
+
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.
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
# Model
|
|
349
|
+
class Order < ApplicationRecord
|
|
350
|
+
belongs_to :user
|
|
351
|
+
delegate :full_name, :email, to: :user
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Bad - N+1 (each call hits the database for user)
|
|
355
|
+
orders.each do |order|
|
|
356
|
+
order.full_name # actually: order.user.full_name — loads user for each order!
|
|
357
|
+
order.email # actually: order.user.email — another load!
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Good - Eager load the delegated-to association
|
|
361
|
+
orders.includes(:user).each do |order|
|
|
362
|
+
order.full_name # no N+1 — user is already loaded
|
|
363
|
+
order.email # no N+1 — user is already loaded
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
EagerEye detects these by:
|
|
368
|
+
1. Scanning model files for `delegate :method, to: :assoc` declarations
|
|
369
|
+
2. Tracking which methods delegate to which associations
|
|
370
|
+
3. Flagging calls to those methods inside iteration blocks when the association is not preloaded
|
|
371
|
+
|
|
342
372
|
## Inline Suppression
|
|
343
373
|
|
|
344
374
|
Suppress false positives using inline comments (RuboCop-style):
|
|
@@ -381,6 +411,7 @@ Both CamelCase and snake_case formats are accepted:
|
|
|
381
411
|
| Count in Iteration | `CountInIteration` | `count_in_iteration` |
|
|
382
412
|
| Callback Query | `CallbackQuery` | `callback_query` |
|
|
383
413
|
| Pluck to Array | `PluckToArray` | `pluck_to_array` |
|
|
414
|
+
| Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
|
|
384
415
|
| All Detectors | `all` | `all` |
|
|
385
416
|
|
|
386
417
|
## Auto-fix (Experimental)
|
|
@@ -483,6 +514,7 @@ enabled_detectors:
|
|
|
483
514
|
- count_in_iteration
|
|
484
515
|
- callback_query
|
|
485
516
|
- pluck_to_array
|
|
517
|
+
- delegation_n_plus_one
|
|
486
518
|
|
|
487
519
|
# Severity levels per detector (error, warning, info)
|
|
488
520
|
severity_levels:
|
|
@@ -492,6 +524,7 @@ severity_levels:
|
|
|
492
524
|
count_in_iteration: warning
|
|
493
525
|
callback_query: warning
|
|
494
526
|
pluck_to_array: warning # Optimization
|
|
527
|
+
delegation_n_plus_one: warning # Hidden delegation N+1
|
|
495
528
|
missing_counter_cache: info # Suggestion
|
|
496
529
|
|
|
497
530
|
# Minimum severity to report (default: info)
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -11,20 +11,23 @@ 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
|
|
15
16
|
}.freeze
|
|
16
17
|
|
|
17
|
-
attr_reader :paths, :issues, :association_preloads
|
|
18
|
+
attr_reader :paths, :issues, :association_preloads, :delegation_maps
|
|
18
19
|
|
|
19
20
|
def initialize(paths: nil)
|
|
20
21
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
21
22
|
@issues = []
|
|
22
23
|
@association_preloads = {}
|
|
24
|
+
@delegation_maps = {}
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def run
|
|
26
28
|
@issues = []
|
|
27
29
|
collect_association_preloads
|
|
30
|
+
collect_delegation_maps
|
|
28
31
|
analyze_files
|
|
29
32
|
@issues
|
|
30
33
|
end
|
|
@@ -44,6 +47,19 @@ module EagerEye
|
|
|
44
47
|
nil
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
def collect_delegation_maps
|
|
51
|
+
model_files.each do |file_path|
|
|
52
|
+
ast = parse_source(File.read(file_path))
|
|
53
|
+
next unless ast
|
|
54
|
+
|
|
55
|
+
parser = DelegationParser.new
|
|
56
|
+
parser.parse_model(ast, extract_model_name(file_path))
|
|
57
|
+
@delegation_maps.merge!(parser.delegation_maps)
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
def model_files
|
|
48
64
|
Dir.glob(File.join(@paths[0], "models", "**", "*.rb"))
|
|
49
65
|
end
|
|
@@ -85,10 +101,7 @@ module EagerEye
|
|
|
85
101
|
min_severity = EagerEye.configuration.min_severity
|
|
86
102
|
|
|
87
103
|
enabled_detectors.each do |detector|
|
|
88
|
-
|
|
89
|
-
args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
|
|
90
|
-
|
|
91
|
-
file_issues = detector.detect(*args)
|
|
104
|
+
file_issues = detector.detect(*detector_args(detector, ast, file_path))
|
|
92
105
|
file_issues.reject! { |issue| comment_parser.disabled_at?(issue.line_number, issue.detector) }
|
|
93
106
|
file_issues.select! { |issue| issue.meets_minimum_severity?(min_severity) }
|
|
94
107
|
@issues.concat(file_issues)
|
|
@@ -103,6 +116,13 @@ module EagerEye
|
|
|
103
116
|
nil
|
|
104
117
|
end
|
|
105
118
|
|
|
119
|
+
def detector_args(detector, ast, file_path)
|
|
120
|
+
args = [ast, file_path]
|
|
121
|
+
args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
|
|
122
|
+
args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
|
|
123
|
+
args
|
|
124
|
+
end
|
|
125
|
+
|
|
106
126
|
def enabled_detectors
|
|
107
127
|
@enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
|
|
108
128
|
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
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
14
|
DEFAULT_SEVERITY_LEVELS = {
|
|
@@ -18,7 +18,8 @@ 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
|
|
22
23
|
}.freeze
|
|
23
24
|
|
|
24
25
|
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
|
|
@@ -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
|
data/lib/eager_eye/version.rb
CHANGED
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,7 @@ 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"
|
|
16
18
|
require_relative "eager_eye/comment_parser"
|
|
17
19
|
require_relative "eager_eye/analyzer"
|
|
18
20
|
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.
|
|
4
|
+
version: 1.2.4
|
|
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-
|
|
11
|
+
date: 2026-02-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -64,11 +64,13 @@ 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/delegation_n_plus_one.rb
|
|
72
74
|
- lib/eager_eye/detectors/loop_association.rb
|
|
73
75
|
- lib/eager_eye/detectors/missing_counter_cache.rb
|
|
74
76
|
- lib/eager_eye/detectors/pluck_to_array.rb
|