eager_eye 1.2.4 → 1.2.6
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 +26 -0
- data/README.md +34 -2
- data/lib/eager_eye/analyzer.rb +27 -40
- data/lib/eager_eye/comment_parser.rb +0 -1
- data/lib/eager_eye/configuration.rb +3 -2
- data/lib/eager_eye/delegation_parser.rb +13 -21
- data/lib/eager_eye/detectors/base.rb +29 -2
- data/lib/eager_eye/detectors/callback_query.rb +31 -26
- data/lib/eager_eye/detectors/concerns/class_inspector.rb +51 -0
- data/lib/eager_eye/detectors/count_in_iteration.rb +9 -42
- data/lib/eager_eye/detectors/custom_method_query.rb +20 -57
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +84 -0
- data/lib/eager_eye/detectors/delegation_n_plus_one.rb +26 -59
- data/lib/eager_eye/detectors/loop_association.rb +25 -40
- data/lib/eager_eye/detectors/serializer_nesting.rb +5 -44
- data/lib/eager_eye/reporters/base.rb +5 -9
- data/lib/eager_eye/reporters/console.rb +6 -6
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +1 -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: f996747b6c12e539180e66cc74c11c70acfab4a6e4b84aed4039cc7b58dafe53
|
|
4
|
+
data.tar.gz: f23d5a21ceecc39c71b0303a5748472a8ea5e0f5f3256ffd5a10084a5af8a39c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a75d25669ab2d7e96dbccb107ae30f817e5fd6a291651b68bc56dbea21f50bd5c8b24b0633dd21d6faeeb5a8abd999abc6baf57ac9c7e8c3c06aa4542dc570d3
|
|
7
|
+
data.tar.gz: 7d2e5af151c02923bf4e64f05bc277711228729fa08ddee3dd79699623efc33f3689290c1739c025d7851db0b59d90feeac99e3a65c9a13862fac8f36ddf46b6
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.6] - 2026-02-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Extract shared class-inspection logic into `ClassInspector` concern to reduce duplication across detectors
|
|
15
|
+
- Refactor all detectors to use `ClassInspector` for parent class and naming convention checks
|
|
16
|
+
- Simplify `Analyzer` by removing redundant delegation and streamlining detector orchestration
|
|
17
|
+
- Clean up `DelegationParser` with leaner parsing logic
|
|
18
|
+
- Improve `Base` detector with consolidated helper methods
|
|
19
|
+
- Streamline reporter classes (`Base`, `Console`) for clarity and consistency
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- Remove duplicated class-matching code from `CallbackQuery`, `CountInIteration`, `CustomMethodQuery`, `DecoratorNPlusOne`, `DelegationNPlusOne`, `LoopAssociation`, and `SerializerNesting`
|
|
24
|
+
|
|
25
|
+
## [1.2.5] - 2026-02-21
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **New Detector: `DecoratorNPlusOne`** - Detects N+1 queries inside decorator/presenter classes
|
|
30
|
+
- Catches `object.comments.map(...)`, `__getobj__.items.each { ... }`, `model.posts`, `source.tags` patterns
|
|
31
|
+
- Identifies decorator classes by inheritance (`Draper::Decorator`, `SimpleDelegator`, `Delegator`) or name suffix (`Decorator`, `Presenter`, `ViewObject`)
|
|
32
|
+
- Targets all four object reference styles: `object`, `__getobj__`, `source`, `model`
|
|
33
|
+
- Skips ActiveStorage methods (`attached?`, `blob`, `variant`, etc.) to prevent false positives
|
|
34
|
+
- Suggests eager loading in the controller before decorating the collection
|
|
35
|
+
|
|
10
36
|
## [1.2.4] - 2026-02-21
|
|
11
37
|
|
|
12
38
|
### 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.6-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
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
- Callback query N+1s
|
|
52
52
|
- Pluck to array misuse
|
|
53
53
|
- Delegation N+1s (hidden via `delegate :method, to: :association`)
|
|
54
|
+
- Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
|
|
54
55
|
|
|
55
56
|
🔧 **Developer-friendly:**
|
|
56
57
|
- Inline suppression (like RuboCop)
|
|
@@ -369,6 +370,34 @@ EagerEye detects these by:
|
|
|
369
370
|
2. Tracking which methods delegate to which associations
|
|
370
371
|
3. Flagging calls to those methods inside iteration blocks when the association is not preloaded
|
|
371
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
|
+
|
|
372
401
|
## Inline Suppression
|
|
373
402
|
|
|
374
403
|
Suppress false positives using inline comments (RuboCop-style):
|
|
@@ -412,6 +441,7 @@ Both CamelCase and snake_case formats are accepted:
|
|
|
412
441
|
| Callback Query | `CallbackQuery` | `callback_query` |
|
|
413
442
|
| Pluck to Array | `PluckToArray` | `pluck_to_array` |
|
|
414
443
|
| Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
|
|
444
|
+
| Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
|
|
415
445
|
| All Detectors | `all` | `all` |
|
|
416
446
|
|
|
417
447
|
## Auto-fix (Experimental)
|
|
@@ -515,6 +545,7 @@ enabled_detectors:
|
|
|
515
545
|
- callback_query
|
|
516
546
|
- pluck_to_array
|
|
517
547
|
- delegation_n_plus_one
|
|
548
|
+
- decorator_n_plus_one
|
|
518
549
|
|
|
519
550
|
# Severity levels per detector (error, warning, info)
|
|
520
551
|
severity_levels:
|
|
@@ -525,6 +556,7 @@ severity_levels:
|
|
|
525
556
|
callback_query: warning
|
|
526
557
|
pluck_to_array: warning # Optimization
|
|
527
558
|
delegation_n_plus_one: warning # Hidden delegation N+1
|
|
559
|
+
decorator_n_plus_one: warning # Decorator/Presenter N+1
|
|
528
560
|
missing_counter_cache: info # Suggestion
|
|
529
561
|
|
|
530
562
|
# Minimum severity to report (default: info)
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -12,7 +12,8 @@ module EagerEye
|
|
|
12
12
|
count_in_iteration: Detectors::CountInIteration,
|
|
13
13
|
callback_query: Detectors::CallbackQuery,
|
|
14
14
|
pluck_to_array: Detectors::PluckToArray,
|
|
15
|
-
delegation_n_plus_one: Detectors::DelegationNPlusOne
|
|
15
|
+
delegation_n_plus_one: Detectors::DelegationNPlusOne,
|
|
16
|
+
decorator_n_plus_one: Detectors::DecoratorNPlusOne
|
|
16
17
|
}.freeze
|
|
17
18
|
|
|
18
19
|
attr_reader :paths, :issues, :association_preloads, :delegation_maps
|
|
@@ -26,38 +27,30 @@ module EagerEye
|
|
|
26
27
|
|
|
27
28
|
def run
|
|
28
29
|
@issues = []
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
analyze_files
|
|
30
|
+
collect_model_metadata
|
|
31
|
+
ruby_files.each { |file_path| analyze_file(file_path) }
|
|
32
32
|
@issues
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def collect_model_metadata
|
|
38
38
|
model_files.each do |file_path|
|
|
39
39
|
ast = parse_source(File.read(file_path))
|
|
40
40
|
next unless ast
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
parser.parse_model(ast, extract_model_name(file_path))
|
|
44
|
-
@association_preloads.merge!(parser.preloaded_associations)
|
|
45
|
-
end
|
|
46
|
-
rescue StandardError
|
|
47
|
-
nil
|
|
48
|
-
end
|
|
42
|
+
model_name = extract_model_name(file_path)
|
|
49
43
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
next unless ast
|
|
44
|
+
assoc_parser = AssociationParser.new
|
|
45
|
+
assoc_parser.parse_model(ast, model_name)
|
|
46
|
+
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@delegation_maps.merge!(
|
|
48
|
+
deleg_parser = DelegationParser.new
|
|
49
|
+
deleg_parser.parse_model(ast, model_name)
|
|
50
|
+
@delegation_maps.merge!(deleg_parser.delegation_maps)
|
|
51
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
52
|
+
next
|
|
58
53
|
end
|
|
59
|
-
rescue StandardError
|
|
60
|
-
nil
|
|
61
54
|
end
|
|
62
55
|
|
|
63
56
|
def model_files
|
|
@@ -65,25 +58,19 @@ module EagerEye
|
|
|
65
58
|
end
|
|
66
59
|
|
|
67
60
|
def extract_model_name(file_path)
|
|
68
|
-
File.basename(file_path, ".rb")
|
|
61
|
+
name = File.basename(file_path, ".rb")
|
|
62
|
+
name.respond_to?(:camelize) ? name.camelize : name.split("_").map(&:capitalize).join
|
|
69
63
|
end
|
|
70
64
|
|
|
71
|
-
def
|
|
72
|
-
|
|
65
|
+
def ruby_files
|
|
66
|
+
paths.flat_map { |path| resolve_path(path) }.reject { |file| excluded?(file) }
|
|
73
67
|
end
|
|
74
68
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
[path]
|
|
79
|
-
elsif File.directory?(path)
|
|
80
|
-
Dir.glob(File.join(path, "**", "*.rb"))
|
|
81
|
-
else
|
|
82
|
-
Dir.glob(path)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
69
|
+
def resolve_path(path)
|
|
70
|
+
return [path] if File.file?(path)
|
|
71
|
+
return Dir.glob(File.join(path, "**", "*.rb")) if File.directory?(path)
|
|
85
72
|
|
|
86
|
-
|
|
73
|
+
Dir.glob(path)
|
|
87
74
|
end
|
|
88
75
|
|
|
89
76
|
def excluded?(file_path)
|
|
@@ -102,9 +89,10 @@ module EagerEye
|
|
|
102
89
|
|
|
103
90
|
enabled_detectors.each do |detector|
|
|
104
91
|
file_issues = detector.detect(*detector_args(detector, ast, file_path))
|
|
105
|
-
file_issues.
|
|
106
|
-
|
|
107
|
-
|
|
92
|
+
@issues.concat(file_issues.select do |issue|
|
|
93
|
+
!comment_parser.disabled_at?(issue.line_number, issue.detector) &&
|
|
94
|
+
issue.meets_minimum_severity?(min_severity)
|
|
95
|
+
end)
|
|
108
96
|
end
|
|
109
97
|
rescue Errno::ENOENT, Errno::EACCES => e
|
|
110
98
|
warn "EagerEye: Could not read file #{file_path}: #{e.message}"
|
|
@@ -125,8 +113,7 @@ module EagerEye
|
|
|
125
113
|
|
|
126
114
|
def enabled_detectors
|
|
127
115
|
@enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
|
|
128
|
-
|
|
129
|
-
detector_class&.new
|
|
116
|
+
DETECTOR_CLASSES[name]&.new
|
|
130
117
|
end
|
|
131
118
|
end
|
|
132
119
|
end
|
|
@@ -5,7 +5,6 @@ module EagerEye
|
|
|
5
5
|
FILE_DISABLE_PATTERN = /eager_eye:disable-file\s+(.+?)(?:\s+--|$)/i
|
|
6
6
|
NEXT_LINE_PATTERN = /eager_eye:disable-next-line(?:\s+(.+?))?(?:\s+--|$)/i
|
|
7
7
|
BLOCK_START_PATTERN = /eager_eye:disable-block(?:\s+(.+?))?(?:\s+--|$)/i
|
|
8
|
-
BLOCK_END_PATTERN = /eager_eye:enable-block(?:\s+(.+?))?(?:\s+--|$)/i
|
|
9
8
|
INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
|
|
10
9
|
ENABLE_PATTERN = /eager_eye:enable(?:\s+(.+?))?(?:\s+--|$)/i
|
|
11
10
|
|
|
@@ -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 delegation_n_plus_one
|
|
11
|
+
pluck_to_array delegation_n_plus_one decorator_n_plus_one
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
14
|
DEFAULT_SEVERITY_LEVELS = {
|
|
@@ -19,7 +19,8 @@ module EagerEye
|
|
|
19
19
|
count_in_iteration: :warning,
|
|
20
20
|
callback_query: :warning,
|
|
21
21
|
pluck_to_array: :warning,
|
|
22
|
-
delegation_n_plus_one: :warning
|
|
22
|
+
delegation_n_plus_one: :warning,
|
|
23
|
+
decorator_n_plus_one: :warning
|
|
23
24
|
}.freeze
|
|
24
25
|
|
|
25
26
|
VALID_SEVERITIES = %i[info warning error].freeze
|
|
@@ -24,50 +24,42 @@ module EagerEye
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def check_delegate(node, model_name)
|
|
27
|
-
return unless
|
|
27
|
+
return unless delegate_call?(node)
|
|
28
28
|
|
|
29
29
|
args = node.children[2..]
|
|
30
|
-
methods =
|
|
30
|
+
methods = extract_sym_args(args)
|
|
31
31
|
return if methods.empty?
|
|
32
32
|
|
|
33
33
|
to_target = extract_to_target(args)
|
|
34
34
|
return unless to_target
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
@delegation_maps[model_name] ||= {}
|
|
37
|
+
methods.each { |m| @delegation_maps[model_name][m] = to_target }
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
def
|
|
40
|
+
def delegate_call?(node)
|
|
40
41
|
node.type == :send && node.children[0].nil? && node.children[1] == :delegate
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
def
|
|
44
|
+
def extract_sym_args(args)
|
|
44
45
|
args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
|
|
45
46
|
end
|
|
46
47
|
|
|
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
48
|
def extract_to_target(args)
|
|
53
49
|
hash_arg = args.find { |a| a&.type == :hash }
|
|
54
50
|
return unless hash_arg
|
|
55
51
|
|
|
56
|
-
to_pair = hash_arg
|
|
57
|
-
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def extract_sym_value(node)
|
|
61
|
-
return unless node
|
|
52
|
+
to_pair = find_to_pair(hash_arg)
|
|
53
|
+
return unless to_pair
|
|
62
54
|
|
|
63
|
-
value =
|
|
55
|
+
value = to_pair.children[1]
|
|
64
56
|
value.children[0] if value&.type == :sym
|
|
65
57
|
end
|
|
66
58
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
pair.children[0]&.type == :sym &&
|
|
70
|
-
|
|
59
|
+
def find_to_pair(hash_node)
|
|
60
|
+
hash_node.children.find do |p|
|
|
61
|
+
p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
|
|
62
|
+
end
|
|
71
63
|
end
|
|
72
64
|
end
|
|
73
65
|
end
|
|
@@ -22,13 +22,12 @@ module EagerEye
|
|
|
22
22
|
protected
|
|
23
23
|
|
|
24
24
|
def create_issue(file_path:, line_number:, message:, severity: nil, suggestion: nil)
|
|
25
|
-
resolved_severity = severity || configured_severity
|
|
26
25
|
Issue.new(
|
|
27
26
|
detector: self.class.detector_name,
|
|
28
27
|
file_path: file_path,
|
|
29
28
|
line_number: line_number,
|
|
30
29
|
message: message,
|
|
31
|
-
severity:
|
|
30
|
+
severity: severity || configured_severity,
|
|
32
31
|
suggestion: suggestion
|
|
33
32
|
)
|
|
34
33
|
end
|
|
@@ -73,6 +72,34 @@ module EagerEye
|
|
|
73
72
|
symbols.add(key.children[0]) if key&.type == :sym
|
|
74
73
|
end
|
|
75
74
|
end
|
|
75
|
+
|
|
76
|
+
def extract_block_variable(block_node)
|
|
77
|
+
args = block_node&.children&.[](1)
|
|
78
|
+
first_arg = args&.children&.first
|
|
79
|
+
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def receiver_chain_starts_with?(node, target_var)
|
|
83
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
84
|
+
|
|
85
|
+
case node.type
|
|
86
|
+
when :lvar then node.children[0] == target_var
|
|
87
|
+
when :send then receiver_chain_starts_with?(node.children[0], target_var)
|
|
88
|
+
else false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reconstruct_chain(node)
|
|
93
|
+
return "" unless node.is_a?(Parser::AST::Node)
|
|
94
|
+
|
|
95
|
+
case node.type
|
|
96
|
+
when :lvar then node.children[0].to_s
|
|
97
|
+
when :send
|
|
98
|
+
receiver_str = reconstruct_chain(node.children[0])
|
|
99
|
+
receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
|
|
100
|
+
else ""
|
|
101
|
+
end
|
|
102
|
+
end
|
|
76
103
|
end
|
|
77
104
|
end
|
|
78
105
|
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
|
|
@@ -38,7 +41,6 @@ module EagerEye
|
|
|
38
41
|
@issues = []
|
|
39
42
|
@file_path = file_path
|
|
40
43
|
@callback_methods = {}
|
|
41
|
-
|
|
42
44
|
return @issues unless ast
|
|
43
45
|
|
|
44
46
|
find_callback_definitions(ast)
|
|
@@ -64,9 +66,7 @@ module EagerEye
|
|
|
64
66
|
node.children[2..].each do |arg|
|
|
65
67
|
next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
callback_type = node.children[1]
|
|
69
|
-
@callback_methods[method_name] = callback_type
|
|
69
|
+
@callback_methods[arg.children[0]] = node.children[1]
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
@@ -74,9 +74,8 @@ module EagerEye
|
|
|
74
74
|
return unless node.is_a?(Parser::AST::Node)
|
|
75
75
|
|
|
76
76
|
if node.type == :def && @callback_methods.key?(node.children[0])
|
|
77
|
-
method_name = node.children[0]
|
|
78
77
|
body = node.children[2]
|
|
79
|
-
find_iterations_with_queries(body,
|
|
78
|
+
find_iterations_with_queries(body, node.children[0], @callback_methods[node.children[0]]) if body
|
|
80
79
|
end
|
|
81
80
|
|
|
82
81
|
node.children.each { |child| check_callback_methods(child) }
|
|
@@ -87,7 +86,8 @@ module EagerEye
|
|
|
87
86
|
|
|
88
87
|
if iteration_block?(node)
|
|
89
88
|
block_var = extract_block_variable(node)
|
|
90
|
-
|
|
89
|
+
collection = node.children[0].children[0]
|
|
90
|
+
if block_var && !non_ar_collection?(collection) && contains_ar_query_on_variable?(node, block_var)
|
|
91
91
|
add_iteration_issue(node, method_name, callback_type)
|
|
92
92
|
find_query_calls_in_block(node, method_name, callback_type, block_var)
|
|
93
93
|
end
|
|
@@ -127,47 +127,52 @@ module EagerEye
|
|
|
127
127
|
end
|
|
128
128
|
|
|
129
129
|
def add_query_issue(node, method_name, callback_type)
|
|
130
|
-
|
|
130
|
+
suggestion = if transactional_callback?(callback_type)
|
|
131
|
+
"Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
|
|
132
|
+
else
|
|
133
|
+
"Callbacks run on every save/create/update. Consider moving to a background job"
|
|
134
|
+
end
|
|
131
135
|
|
|
132
136
|
@issues << create_issue(
|
|
133
137
|
file_path: @file_path,
|
|
134
138
|
line_number: node.loc.line,
|
|
135
|
-
message: "Query method `.#{
|
|
139
|
+
message: "Query method `.#{node.children[1]}` found in `#{callback_type}` callback `:#{method_name}`",
|
|
136
140
|
severity: :warning,
|
|
137
|
-
suggestion:
|
|
141
|
+
suggestion: suggestion
|
|
138
142
|
)
|
|
139
143
|
end
|
|
140
144
|
|
|
141
145
|
def add_iteration_issue(node, method_name, callback_type)
|
|
146
|
+
suggestion = if transactional_callback?(callback_type)
|
|
147
|
+
"Avoid DB queries in before_*/around_* callbacks. Preload data outside the iteration instead"
|
|
148
|
+
else
|
|
149
|
+
"Avoid iterations in callbacks. Use background jobs for bulk operations"
|
|
150
|
+
end
|
|
151
|
+
|
|
142
152
|
@issues << create_issue(
|
|
143
153
|
file_path: @file_path,
|
|
144
154
|
line_number: node.loc.line,
|
|
145
155
|
message: "Iteration found in `#{callback_type}` callback `:#{method_name}` - potential N+1",
|
|
146
156
|
severity: :error,
|
|
147
|
-
suggestion:
|
|
157
|
+
suggestion: suggestion
|
|
148
158
|
)
|
|
149
159
|
end
|
|
150
160
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
first_arg = args_node.children[0]
|
|
156
|
-
return nil unless first_arg&.type == :arg
|
|
161
|
+
def transactional_callback?(callback_type)
|
|
162
|
+
TRANSACTIONAL_CALLBACKS.include?(callback_type)
|
|
163
|
+
end
|
|
157
164
|
|
|
158
|
-
|
|
165
|
+
def non_ar_collection?(node)
|
|
166
|
+
ns = root_namespace(node)
|
|
167
|
+
ns && NON_AR_NAMESPACES.include?(ns)
|
|
159
168
|
end
|
|
160
169
|
|
|
161
|
-
def
|
|
162
|
-
return
|
|
170
|
+
def root_namespace(node)
|
|
171
|
+
return nil unless node.is_a?(Parser::AST::Node)
|
|
163
172
|
|
|
164
173
|
case node.type
|
|
165
|
-
when :
|
|
166
|
-
|
|
167
|
-
when :send
|
|
168
|
-
receiver_chain_starts_with?(node.children[0], block_var)
|
|
169
|
-
else
|
|
170
|
-
false
|
|
174
|
+
when :const then node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
|
|
175
|
+
when :send, :block then root_namespace(node.children[0])
|
|
171
176
|
end
|
|
172
177
|
end
|
|
173
178
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
module Concerns
|
|
6
|
+
module ClassInspector
|
|
7
|
+
HAS_MANY_ASSOCIATIONS = %w[
|
|
8
|
+
authors users owners creators admins members customers clients
|
|
9
|
+
posts articles comments categories tags children companies organizations
|
|
10
|
+
projects tasks items orders products accounts profiles settings
|
|
11
|
+
images avatars photos attachments documents
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
ACTIVE_STORAGE_METHODS = %i[
|
|
15
|
+
attached? attach attachment attachments blob blobs purge purge_later variant preview
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def const_to_string(node)
|
|
21
|
+
return nil unless node&.type == :const
|
|
22
|
+
|
|
23
|
+
parts = []
|
|
24
|
+
current = node
|
|
25
|
+
while current&.type == :const
|
|
26
|
+
parts.unshift(current.children[1].to_s)
|
|
27
|
+
current = current.children[0]
|
|
28
|
+
end
|
|
29
|
+
parts.join("::")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def extract_class_name(class_node)
|
|
33
|
+
name_node = class_node.children[0]
|
|
34
|
+
name_node.children[1].to_s if name_node&.type == :const
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def likely_association?(method_name)
|
|
38
|
+
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def collect_active_storage_lines(body)
|
|
42
|
+
lines = Set.new
|
|
43
|
+
traverse_ast(body) do |node|
|
|
44
|
+
lines << node.loc.line if node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
|
|
45
|
+
end
|
|
46
|
+
lines
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -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
|
|
@@ -14,7 +15,6 @@ module EagerEye
|
|
|
14
15
|
def detect(ast, file_path)
|
|
15
16
|
@issues = []
|
|
16
17
|
@file_path = file_path
|
|
17
|
-
|
|
18
18
|
return @issues unless ast
|
|
19
19
|
|
|
20
20
|
find_iteration_blocks(ast) do |block_body, block_var|
|
|
@@ -52,63 +52,30 @@ module EagerEye
|
|
|
52
52
|
|
|
53
53
|
def count_on_association?(node, block_var)
|
|
54
54
|
node.type == :send && COUNT_METHODS.include?(node.children[1]) &&
|
|
55
|
+
!array_returning_method?(node.children[0]) &&
|
|
55
56
|
association_call_on_block_var?(node.children[0], block_var)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
|
-
def
|
|
59
|
+
def array_returning_method?(node)
|
|
59
60
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
return false unless receiver.is_a?(Parser::AST::Node)
|
|
63
|
-
|
|
64
|
-
return true if receiver.type == :lvar && receiver.children[0] == block_var
|
|
65
|
-
|
|
66
|
-
receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def chain_starts_with_block_var?(node, block_var)
|
|
70
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
71
|
-
|
|
72
|
-
case node.type
|
|
73
|
-
when :lvar then node.children[0] == block_var
|
|
74
|
-
when :send then chain_starts_with_block_var?(node.children[0], block_var)
|
|
75
|
-
else false
|
|
76
|
-
end
|
|
62
|
+
ARRAY_METHOD_SUFFIXES.any? { |suffix| node.children[1].to_s.end_with?(suffix) }
|
|
77
63
|
end
|
|
78
64
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
first_arg = args_node.children[0]
|
|
84
|
-
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
65
|
+
def association_call_on_block_var?(node, block_var)
|
|
66
|
+
node.is_a?(Parser::AST::Node) && node.type == :send &&
|
|
67
|
+
receiver_chain_starts_with?(node.children[0], block_var)
|
|
85
68
|
end
|
|
86
69
|
|
|
87
70
|
def add_issue(node)
|
|
88
|
-
|
|
89
|
-
|
|
71
|
+
chain = reconstruct_chain(node.children[0])
|
|
90
72
|
@issues << create_issue(
|
|
91
73
|
file_path: @file_path,
|
|
92
74
|
line_number: node.loc.line,
|
|
93
|
-
message: "`.count` called on `#{
|
|
75
|
+
message: "`.count` called on `#{chain}` inside iteration always executes a COUNT query",
|
|
94
76
|
suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
|
|
95
77
|
)
|
|
96
78
|
end
|
|
97
|
-
|
|
98
|
-
def reconstruct_chain(node)
|
|
99
|
-
return "" unless node.is_a?(Parser::AST::Node)
|
|
100
|
-
|
|
101
|
-
case node.type
|
|
102
|
-
when :lvar
|
|
103
|
-
node.children[0].to_s
|
|
104
|
-
when :send
|
|
105
|
-
receiver_str = reconstruct_chain(node.children[0])
|
|
106
|
-
method = node.children[1]
|
|
107
|
-
receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
|
|
108
|
-
else
|
|
109
|
-
""
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
79
|
end
|
|
113
80
|
end
|
|
114
81
|
end
|