eager_eye 1.2.5 → 1.2.7
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 +26 -0
- data/README.md +38 -7
- data/lib/eager_eye/analyzer.rb +34 -41
- data/lib/eager_eye/comment_parser.rb +0 -1
- data/lib/eager_eye/configuration.rb +3 -1
- 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 +5 -35
- data/lib/eager_eye/detectors/concerns/class_inspector.rb +51 -0
- data/lib/eager_eye/detectors/count_in_iteration.rb +5 -47
- data/lib/eager_eye/detectors/custom_method_query.rb +16 -58
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +18 -71
- 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 +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd5177a12d64b54cc6cd426440f79018da9ccc7dd931db70c491c9154bb385a1
|
|
4
|
+
data.tar.gz: dec16deb3b55ab821225d5d0e06a6fa308c41fd472dad405bf125c7e5839f2e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4336421ff71269732f594163dc3dbe29ca70d1b10c19dd333d972ea63bff9da36a4e83a041263fa6f61d758c8b0551912e075e00ce8f05978ab59c7db698af41
|
|
7
|
+
data.tar.gz: 51de528f0dca6b3279c0cb1fe6f21e5c35592dda81220419c209d8d43faf35b41375cbfffd33a463546f5e81e4807f8c48268f618d8383c82a570f81da823efa
|
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.7] - 2026-03-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **New Detector: `ScopeChainNPlusOne`** - Detects scope calls on associations inside iterations
|
|
15
|
+
- Catches `post.comments.recent`, `post.comments.approved.count` patterns
|
|
16
|
+
- Parses model files for `scope :name, -> { ... }` declarations
|
|
17
|
+
- Flags known scope names called on association chains inside loops
|
|
18
|
+
- Each scope call executes a new query per iteration — suggests preloading or joined queries
|
|
19
|
+
- **New Parser: `ScopeParser`** - Collects scope definitions from model files for cross-file detection
|
|
20
|
+
|
|
21
|
+
## [1.2.6] - 2026-02-25
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Extract shared class-inspection logic into `ClassInspector` concern to reduce duplication across detectors
|
|
26
|
+
- Refactor all detectors to use `ClassInspector` for parent class and naming convention checks
|
|
27
|
+
- Simplify `Analyzer` by removing redundant delegation and streamlining detector orchestration
|
|
28
|
+
- Clean up `DelegationParser` with leaner parsing logic
|
|
29
|
+
- Improve `Base` detector with consolidated helper methods
|
|
30
|
+
- Streamline reporter classes (`Base`, `Console`) for clarity and consistency
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
- Remove duplicated class-matching code from `CallbackQuery`, `CountInIteration`, `CustomMethodQuery`, `DecoratorNPlusOne`, `DelegationNPlusOne`, `LoopAssociation`, and `SerializerNesting`
|
|
35
|
+
|
|
10
36
|
## [1.2.5] - 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.7-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 10 types of N+1 problems:**
|
|
46
46
|
- Loop associations (queries in iterations)
|
|
47
47
|
- Serializer nesting issues
|
|
48
48
|
- Missing counter caches
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
- Pluck to array misuse
|
|
53
53
|
- Delegation N+1s (hidden via `delegate :method, to: :association`)
|
|
54
54
|
- Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
|
|
55
|
+
- Scope chain N+1s (named scopes on associations in loops)
|
|
55
56
|
|
|
56
57
|
🔧 **Developer-friendly:**
|
|
57
58
|
- Inline suppression (like RuboCop)
|
|
@@ -398,6 +399,33 @@ Supports the following object references inside decorators:
|
|
|
398
399
|
- `__getobj__` — SimpleDelegator standard
|
|
399
400
|
- `source`, `model` — alternative Draper aliases
|
|
400
401
|
|
|
402
|
+
### 10. Scope Chain N+1
|
|
403
|
+
|
|
404
|
+
Detects named scope calls on associations inside iterations. Unlike explicit query methods (`.where`, `.find_by`) caught by `CustomMethodQuery`, named scopes (`.recent`, `.active`, `.published`) are invisible query triggers.
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
# Model
|
|
408
|
+
class Comment < ApplicationRecord
|
|
409
|
+
scope :recent, -> { where("created_at > ?", 1.week.ago) }
|
|
410
|
+
scope :approved, -> { where(approved: true) }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Bad - scope call per iteration
|
|
414
|
+
posts.each do |post|
|
|
415
|
+
post.comments.recent # Query for each post!
|
|
416
|
+
post.comments.approved.count # Query for each post!
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Good - preload and filter in Ruby
|
|
420
|
+
posts.includes(:comments).each do |post|
|
|
421
|
+
post.comments.select { |c| c.created_at > 1.week.ago }
|
|
422
|
+
end
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
EagerEye detects these by:
|
|
426
|
+
1. Scanning model files for `scope :name, -> { ... }` declarations
|
|
427
|
+
2. Flagging known scope names called on association chains inside iteration blocks
|
|
428
|
+
|
|
401
429
|
## Inline Suppression
|
|
402
430
|
|
|
403
431
|
Suppress false positives using inline comments (RuboCop-style):
|
|
@@ -442,6 +470,7 @@ Both CamelCase and snake_case formats are accepted:
|
|
|
442
470
|
| Pluck to Array | `PluckToArray` | `pluck_to_array` |
|
|
443
471
|
| Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
|
|
444
472
|
| Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
|
|
473
|
+
| Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
|
|
445
474
|
| All Detectors | `all` | `all` |
|
|
446
475
|
|
|
447
476
|
## Auto-fix (Experimental)
|
|
@@ -546,18 +575,20 @@ enabled_detectors:
|
|
|
546
575
|
- pluck_to_array
|
|
547
576
|
- delegation_n_plus_one
|
|
548
577
|
- decorator_n_plus_one
|
|
578
|
+
- scope_chain_n_plus_one
|
|
549
579
|
|
|
550
580
|
# Severity levels per detector (error, warning, info)
|
|
551
581
|
severity_levels:
|
|
552
|
-
loop_association: error
|
|
582
|
+
loop_association: error # Definite N+1
|
|
553
583
|
serializer_nesting: warning
|
|
554
584
|
custom_method_query: warning
|
|
555
585
|
count_in_iteration: warning
|
|
556
586
|
callback_query: warning
|
|
557
|
-
pluck_to_array: warning
|
|
558
|
-
delegation_n_plus_one: warning
|
|
559
|
-
decorator_n_plus_one: warning
|
|
560
|
-
|
|
587
|
+
pluck_to_array: warning # Optimization
|
|
588
|
+
delegation_n_plus_one: warning # Hidden delegation N+1
|
|
589
|
+
decorator_n_plus_one: warning # Decorator/Presenter N+1
|
|
590
|
+
scope_chain_n_plus_one: warning # Scope chain on association
|
|
591
|
+
missing_counter_cache: info # Suggestion
|
|
561
592
|
|
|
562
593
|
# Minimum severity to report (default: info)
|
|
563
594
|
min_severity: warning
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -13,52 +13,50 @@ module EagerEye
|
|
|
13
13
|
callback_query: Detectors::CallbackQuery,
|
|
14
14
|
pluck_to_array: Detectors::PluckToArray,
|
|
15
15
|
delegation_n_plus_one: Detectors::DelegationNPlusOne,
|
|
16
|
-
decorator_n_plus_one: Detectors::DecoratorNPlusOne
|
|
16
|
+
decorator_n_plus_one: Detectors::DecoratorNPlusOne,
|
|
17
|
+
scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne
|
|
17
18
|
}.freeze
|
|
18
19
|
|
|
19
|
-
attr_reader :paths, :issues, :association_preloads, :delegation_maps
|
|
20
|
+
attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps
|
|
20
21
|
|
|
21
22
|
def initialize(paths: nil)
|
|
22
23
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
23
24
|
@issues = []
|
|
24
25
|
@association_preloads = {}
|
|
25
26
|
@delegation_maps = {}
|
|
27
|
+
@scope_maps = {}
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def run
|
|
29
31
|
@issues = []
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
analyze_files
|
|
32
|
+
collect_model_metadata
|
|
33
|
+
ruby_files.each { |file_path| analyze_file(file_path) }
|
|
33
34
|
@issues
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
private
|
|
37
38
|
|
|
38
|
-
def
|
|
39
|
+
def collect_model_metadata
|
|
39
40
|
model_files.each do |file_path|
|
|
40
41
|
ast = parse_source(File.read(file_path))
|
|
41
42
|
next unless ast
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
parser.parse_model(ast, extract_model_name(file_path))
|
|
45
|
-
@association_preloads.merge!(parser.preloaded_associations)
|
|
46
|
-
end
|
|
47
|
-
rescue StandardError
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
44
|
+
model_name = extract_model_name(file_path)
|
|
50
45
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
next unless ast
|
|
46
|
+
assoc_parser = AssociationParser.new
|
|
47
|
+
assoc_parser.parse_model(ast, model_name)
|
|
48
|
+
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
55
49
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@delegation_maps.merge!(
|
|
50
|
+
deleg_parser = DelegationParser.new
|
|
51
|
+
deleg_parser.parse_model(ast, model_name)
|
|
52
|
+
@delegation_maps.merge!(deleg_parser.delegation_maps)
|
|
53
|
+
|
|
54
|
+
scope_parser = ScopeParser.new
|
|
55
|
+
scope_parser.parse_model(ast, model_name)
|
|
56
|
+
@scope_maps.merge!(scope_parser.scope_maps)
|
|
57
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
58
|
+
next
|
|
59
59
|
end
|
|
60
|
-
rescue StandardError
|
|
61
|
-
nil
|
|
62
60
|
end
|
|
63
61
|
|
|
64
62
|
def model_files
|
|
@@ -66,25 +64,19 @@ module EagerEye
|
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
def extract_model_name(file_path)
|
|
69
|
-
File.basename(file_path, ".rb")
|
|
67
|
+
name = File.basename(file_path, ".rb")
|
|
68
|
+
name.respond_to?(:camelize) ? name.camelize : name.split("_").map(&:capitalize).join
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
def
|
|
73
|
-
|
|
71
|
+
def ruby_files
|
|
72
|
+
paths.flat_map { |path| resolve_path(path) }.reject { |file| excluded?(file) }
|
|
74
73
|
end
|
|
75
74
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
[path]
|
|
80
|
-
elsif File.directory?(path)
|
|
81
|
-
Dir.glob(File.join(path, "**", "*.rb"))
|
|
82
|
-
else
|
|
83
|
-
Dir.glob(path)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
75
|
+
def resolve_path(path)
|
|
76
|
+
return [path] if File.file?(path)
|
|
77
|
+
return Dir.glob(File.join(path, "**", "*.rb")) if File.directory?(path)
|
|
86
78
|
|
|
87
|
-
|
|
79
|
+
Dir.glob(path)
|
|
88
80
|
end
|
|
89
81
|
|
|
90
82
|
def excluded?(file_path)
|
|
@@ -103,9 +95,10 @@ module EagerEye
|
|
|
103
95
|
|
|
104
96
|
enabled_detectors.each do |detector|
|
|
105
97
|
file_issues = detector.detect(*detector_args(detector, ast, file_path))
|
|
106
|
-
file_issues.
|
|
107
|
-
|
|
108
|
-
|
|
98
|
+
@issues.concat(file_issues.select do |issue|
|
|
99
|
+
!comment_parser.disabled_at?(issue.line_number, issue.detector) &&
|
|
100
|
+
issue.meets_minimum_severity?(min_severity)
|
|
101
|
+
end)
|
|
109
102
|
end
|
|
110
103
|
rescue Errno::ENOENT, Errno::EACCES => e
|
|
111
104
|
warn "EagerEye: Could not read file #{file_path}: #{e.message}"
|
|
@@ -121,13 +114,13 @@ module EagerEye
|
|
|
121
114
|
args = [ast, file_path]
|
|
122
115
|
args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
|
|
123
116
|
args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
|
|
117
|
+
args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
|
|
124
118
|
args
|
|
125
119
|
end
|
|
126
120
|
|
|
127
121
|
def enabled_detectors
|
|
128
122
|
@enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
|
|
129
|
-
|
|
130
|
-
detector_class&.new
|
|
123
|
+
DETECTOR_CLASSES[name]&.new
|
|
131
124
|
end
|
|
132
125
|
end
|
|
133
126
|
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
|
|
|
@@ -9,6 +9,7 @@ module EagerEye
|
|
|
9
9
|
loop_association serializer_nesting missing_counter_cache
|
|
10
10
|
custom_method_query count_in_iteration callback_query
|
|
11
11
|
pluck_to_array delegation_n_plus_one decorator_n_plus_one
|
|
12
|
+
scope_chain_n_plus_one
|
|
12
13
|
].freeze
|
|
13
14
|
|
|
14
15
|
DEFAULT_SEVERITY_LEVELS = {
|
|
@@ -20,7 +21,8 @@ module EagerEye
|
|
|
20
21
|
callback_query: :warning,
|
|
21
22
|
pluck_to_array: :warning,
|
|
22
23
|
delegation_n_plus_one: :warning,
|
|
23
|
-
decorator_n_plus_one: :warning
|
|
24
|
+
decorator_n_plus_one: :warning,
|
|
25
|
+
scope_chain_n_plus_one: :warning
|
|
24
26
|
}.freeze
|
|
25
27
|
|
|
26
28
|
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
|
|
@@ -41,7 +41,6 @@ module EagerEye
|
|
|
41
41
|
@issues = []
|
|
42
42
|
@file_path = file_path
|
|
43
43
|
@callback_methods = {}
|
|
44
|
-
|
|
45
44
|
return @issues unless ast
|
|
46
45
|
|
|
47
46
|
find_callback_definitions(ast)
|
|
@@ -67,9 +66,7 @@ module EagerEye
|
|
|
67
66
|
node.children[2..].each do |arg|
|
|
68
67
|
next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
callback_type = node.children[1]
|
|
72
|
-
@callback_methods[method_name] = callback_type
|
|
69
|
+
@callback_methods[arg.children[0]] = node.children[1]
|
|
73
70
|
end
|
|
74
71
|
end
|
|
75
72
|
|
|
@@ -77,9 +74,8 @@ module EagerEye
|
|
|
77
74
|
return unless node.is_a?(Parser::AST::Node)
|
|
78
75
|
|
|
79
76
|
if node.type == :def && @callback_methods.key?(node.children[0])
|
|
80
|
-
method_name = node.children[0]
|
|
81
77
|
body = node.children[2]
|
|
82
|
-
find_iterations_with_queries(body,
|
|
78
|
+
find_iterations_with_queries(body, node.children[0], @callback_methods[node.children[0]]) if body
|
|
83
79
|
end
|
|
84
80
|
|
|
85
81
|
node.children.each { |child| check_callback_methods(child) }
|
|
@@ -131,7 +127,6 @@ module EagerEye
|
|
|
131
127
|
end
|
|
132
128
|
|
|
133
129
|
def add_query_issue(node, method_name, callback_type)
|
|
134
|
-
query_method = node.children[1]
|
|
135
130
|
suggestion = if transactional_callback?(callback_type)
|
|
136
131
|
"Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
|
|
137
132
|
else
|
|
@@ -141,7 +136,7 @@ module EagerEye
|
|
|
141
136
|
@issues << create_issue(
|
|
142
137
|
file_path: @file_path,
|
|
143
138
|
line_number: node.loc.line,
|
|
144
|
-
message: "Query method `.#{
|
|
139
|
+
message: "Query method `.#{node.children[1]}` found in `#{callback_type}` callback `:#{method_name}`",
|
|
145
140
|
severity: :warning,
|
|
146
141
|
suggestion: suggestion
|
|
147
142
|
)
|
|
@@ -167,29 +162,6 @@ module EagerEye
|
|
|
167
162
|
TRANSACTIONAL_CALLBACKS.include?(callback_type)
|
|
168
163
|
end
|
|
169
164
|
|
|
170
|
-
def extract_block_variable(block_node)
|
|
171
|
-
args_node = block_node.children[1]
|
|
172
|
-
return nil unless args_node&.type == :args
|
|
173
|
-
|
|
174
|
-
first_arg = args_node.children[0]
|
|
175
|
-
return nil unless first_arg&.type == :arg
|
|
176
|
-
|
|
177
|
-
first_arg.children[0]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def receiver_chain_starts_with?(node, block_var)
|
|
181
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
182
|
-
|
|
183
|
-
case node.type
|
|
184
|
-
when :lvar
|
|
185
|
-
node.children[0] == block_var
|
|
186
|
-
when :send
|
|
187
|
-
receiver_chain_starts_with?(node.children[0], block_var)
|
|
188
|
-
else
|
|
189
|
-
false
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
165
|
def non_ar_collection?(node)
|
|
194
166
|
ns = root_namespace(node)
|
|
195
167
|
ns && NON_AR_NAMESPACES.include?(ns)
|
|
@@ -199,10 +171,8 @@ module EagerEye
|
|
|
199
171
|
return nil unless node.is_a?(Parser::AST::Node)
|
|
200
172
|
|
|
201
173
|
case node.type
|
|
202
|
-
when :const
|
|
203
|
-
|
|
204
|
-
when :send, :block
|
|
205
|
-
root_namespace(node.children[0])
|
|
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])
|
|
206
176
|
end
|
|
207
177
|
end
|
|
208
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
|
|
@@ -15,7 +15,6 @@ module EagerEye
|
|
|
15
15
|
def detect(ast, file_path)
|
|
16
16
|
@issues = []
|
|
17
17
|
@file_path = file_path
|
|
18
|
-
|
|
19
18
|
return @issues unless ast
|
|
20
19
|
|
|
21
20
|
find_iteration_blocks(ast) do |block_body, block_var|
|
|
@@ -60,64 +59,23 @@ module EagerEye
|
|
|
60
59
|
def array_returning_method?(node)
|
|
61
60
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
ARRAY_METHOD_SUFFIXES.any? { |suffix| method_name.end_with?(suffix) }
|
|
62
|
+
ARRAY_METHOD_SUFFIXES.any? { |suffix| node.children[1].to_s.end_with?(suffix) }
|
|
65
63
|
end
|
|
66
64
|
|
|
67
65
|
def association_call_on_block_var?(node, block_var)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
receiver = node.children[0]
|
|
71
|
-
return false unless receiver.is_a?(Parser::AST::Node)
|
|
72
|
-
|
|
73
|
-
return true if receiver.type == :lvar && receiver.children[0] == block_var
|
|
74
|
-
|
|
75
|
-
receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def chain_starts_with_block_var?(node, block_var)
|
|
79
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
80
|
-
|
|
81
|
-
case node.type
|
|
82
|
-
when :lvar then node.children[0] == block_var
|
|
83
|
-
when :send then chain_starts_with_block_var?(node.children[0], block_var)
|
|
84
|
-
else false
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def extract_block_variable(block_node)
|
|
89
|
-
args_node = block_node.children[1]
|
|
90
|
-
return nil unless args_node&.type == :args
|
|
91
|
-
|
|
92
|
-
first_arg = args_node.children[0]
|
|
93
|
-
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
66
|
+
node.is_a?(Parser::AST::Node) && node.type == :send &&
|
|
67
|
+
receiver_chain_starts_with?(node.children[0], block_var)
|
|
94
68
|
end
|
|
95
69
|
|
|
96
70
|
def add_issue(node)
|
|
97
|
-
|
|
98
|
-
|
|
71
|
+
chain = reconstruct_chain(node.children[0])
|
|
99
72
|
@issues << create_issue(
|
|
100
73
|
file_path: @file_path,
|
|
101
74
|
line_number: node.loc.line,
|
|
102
|
-
message: "`.count` called on `#{
|
|
75
|
+
message: "`.count` called on `#{chain}` inside iteration always executes a COUNT query",
|
|
103
76
|
suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
|
|
104
77
|
)
|
|
105
78
|
end
|
|
106
|
-
|
|
107
|
-
def reconstruct_chain(node)
|
|
108
|
-
return "" unless node.is_a?(Parser::AST::Node)
|
|
109
|
-
|
|
110
|
-
case node.type
|
|
111
|
-
when :lvar
|
|
112
|
-
node.children[0].to_s
|
|
113
|
-
when :send
|
|
114
|
-
receiver_str = reconstruct_chain(node.children[0])
|
|
115
|
-
method = node.children[1]
|
|
116
|
-
receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
|
|
117
|
-
else
|
|
118
|
-
""
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
79
|
end
|
|
122
80
|
end
|
|
123
81
|
end
|