eager_eye 1.2.2 → 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 +24 -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/callback_query.rb +7 -2
- data/lib/eager_eye/detectors/count_in_iteration.rb +2 -1
- data/lib/eager_eye/detectors/custom_method_query.rb +2 -1
- data/lib/eager_eye/detectors/delegation_n_plus_one.rb +176 -0
- data/lib/eager_eye/detectors/loop_association.rb +2 -1
- data/lib/eager_eye/detectors/missing_counter_cache.rb +2 -1
- 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,30 @@ 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
|
+
|
|
22
|
+
## [1.2.3] - 2026-02-15
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Batch Iteration Support** - All detectors now recognize `find_each`, `find_in_batches`, and `in_batches`
|
|
27
|
+
- `LoopAssociation`: Detects association calls inside `find_each` blocks
|
|
28
|
+
- `CallbackQuery`: Detects query iterations inside `find_each` in callbacks
|
|
29
|
+
- `MissingCounterCache`: Detects `.count`/`.size`/`.length` inside `find_each` blocks
|
|
30
|
+
- `CountInIteration`: Detects `.count` inside `find_each` blocks
|
|
31
|
+
- `CustomMethodQuery`: Detects `.where`/`.find_by` etc. inside `find_each` blocks
|
|
32
|
+
- Previously, `User.find_each { |u| u.posts }` was not flagged — now correctly detected as N+1
|
|
33
|
+
|
|
10
34
|
## [1.2.2] - 2026-01-31
|
|
11
35
|
|
|
12
36
|
### Fixed
|
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
|
|
@@ -26,7 +26,9 @@ module EagerEye
|
|
|
26
26
|
reload
|
|
27
27
|
].freeze
|
|
28
28
|
|
|
29
|
-
ITERATION_METHODS = %i[each map select find_all reject collect
|
|
29
|
+
ITERATION_METHODS = %i[each map select find_all reject collect
|
|
30
|
+
find_each find_in_batches in_batches].freeze
|
|
31
|
+
AR_BATCH_METHODS = %i[find_each find_in_batches in_batches].freeze
|
|
30
32
|
|
|
31
33
|
def self.detector_name
|
|
32
34
|
:callback_query
|
|
@@ -110,7 +112,10 @@ module EagerEye
|
|
|
110
112
|
|
|
111
113
|
def iteration_block?(node)
|
|
112
114
|
return false unless node.type == :block && node.children[0]&.type == :send
|
|
113
|
-
|
|
115
|
+
|
|
116
|
+
method_name = node.children[0].children[1]
|
|
117
|
+
return false unless ITERATION_METHODS.include?(method_name)
|
|
118
|
+
return true if AR_BATCH_METHODS.include?(method_name)
|
|
114
119
|
|
|
115
120
|
!static_collection?(node.children[0].children[0])
|
|
116
121
|
end
|
|
@@ -4,7 +4,8 @@ module EagerEye
|
|
|
4
4
|
module Detectors
|
|
5
5
|
class CountInIteration < Base
|
|
6
6
|
COUNT_METHODS = %i[count].freeze
|
|
7
|
-
ITERATION_METHODS = %i[each map select find_all reject collect each_with_index each_with_object flat_map
|
|
7
|
+
ITERATION_METHODS = %i[each map select find_all reject collect each_with_index each_with_object flat_map
|
|
8
|
+
find_each find_in_batches in_batches].freeze
|
|
8
9
|
|
|
9
10
|
def self.detector_name
|
|
10
11
|
:count_in_iteration
|
|
@@ -8,7 +8,8 @@ module EagerEye
|
|
|
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
10
|
ARRAY_COLUMN_SUFFIXES = %w[_ids _tags _types _codes _names _values].freeze
|
|
11
|
-
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
|
|
11
|
+
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
|
|
12
|
+
find_each find_in_batches in_batches].freeze
|
|
12
13
|
|
|
13
14
|
def self.detector_name
|
|
14
15
|
:custom_method_query
|
|
@@ -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
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module EagerEye
|
|
4
4
|
module Detectors
|
|
5
5
|
class LoopAssociation < Base
|
|
6
|
-
ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map
|
|
6
|
+
ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map
|
|
7
|
+
find_each find_in_batches in_batches].freeze
|
|
7
8
|
PRELOAD_METHODS = %i[includes preload eager_load].freeze
|
|
8
9
|
SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
|
|
9
10
|
forty_two sole find_sole_by].freeze
|
|
@@ -10,7 +10,8 @@ module EagerEye
|
|
|
10
10
|
likes favorites bookmarks votes children replies responses answers questions
|
|
11
11
|
].freeze
|
|
12
12
|
ITERATION_METHODS = %i[each map collect select reject find_all filter filter_map flat_map
|
|
13
|
-
each_with_index each_with_object reduce inject sum
|
|
13
|
+
each_with_index each_with_object reduce inject sum
|
|
14
|
+
find_each find_in_batches in_batches].freeze
|
|
14
15
|
|
|
15
16
|
def self.detector_name
|
|
16
17
|
:missing_counter_cache
|
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
|