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 +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +23 -0
- data/README.md +67 -2
- data/lib/eager_eye/analyzer.rb +27 -6
- data/lib/eager_eye/configuration.rb +4 -2
- data/lib/eager_eye/delegation_parser.rb +73 -0
- data/lib/eager_eye/detectors/callback_query.rb +38 -3
- data/lib/eager_eye/detectors/count_in_iteration.rb +9 -0
- data/lib/eager_eye/detectors/custom_method_query.rb +6 -1
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +137 -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 +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fa0e6e3acb2c37751e7ab49a58826a016a68c7a33cc6305ddb6b82d6787c462
|
|
4
|
+
data.tar.gz: af81d8c0cfde13914b00a9f64b4b1bef6731b12902a1358c7f27473c2ab9a68d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a0e233e5c53c6ee3c7a32239ca20625a58deb1b385af022cb582433d02070f7b861873f44cef5fe786ede0307caee4105202e49844902e8d75e14039623bdc74
|
|
7
|
+
data.tar.gz: 238ca3ba9a777686867752bc4abbf1d10081d73215e3c0f7ed0e9360f7c380298b4736177c703f1c921fb22b4296e7544379bef69021408f9e9ad86280a3716c
|
data/.rubocop.yml
CHANGED
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.
|
|
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
|
|
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)
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
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,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.
|
|
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-
|
|
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
|