eager_eye 1.2.7 → 1.2.9
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 +21 -0
- data/README.md +41 -8
- data/lib/eager_eye/analyzer.rb +23 -7
- data/lib/eager_eye/association_parser.rb +4 -1
- data/lib/eager_eye/configuration.rb +3 -2
- data/lib/eager_eye/detectors/concerns/class_inspector.rb +1 -1
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +2 -1
- data/lib/eager_eye/detectors/loop_association.rb +7 -2
- data/lib/eager_eye/detectors/missing_counter_cache.rb +6 -2
- data/lib/eager_eye/detectors/scope_chain_n_plus_one.rb +78 -0
- data/lib/eager_eye/detectors/serializer_nesting.rb +2 -1
- data/lib/eager_eye/detectors/validation_n_plus_one.rb +118 -0
- data/lib/eager_eye/scope_parser.rb +45 -0
- data/lib/eager_eye/validation_parser.rb +52 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +2 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4cedfbe1ec9b556f757e9b161f896d96986bfc2dac8bf865c6e1bc289f6777b8
|
|
4
|
+
data.tar.gz: 8c0dc65a55e21c5d4e122f51293ac480f01435af2b719a262efe6ccdfa04603c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 64d29ba207011df8fee0e11e6557de652ad5e3ed87c140e68cbedebb44988a477da0e2d5842fe28460811ebcc53db974f5403f4d73dd9de2653980a329e9a10c
|
|
7
|
+
data.tar.gz: f1dc481bc8b85c6e03443edee9508216ffd111d49c09c0aabf021ef9250e42bc8a84ca95ab7fa6afb808ab9c041736fb75af40bbb8acdd2efd4f5ac060f879b7
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.9] - 2026-03-13
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Dynamic Association Detection** - Association names are now automatically parsed from model files
|
|
15
|
+
- `has_many`, `has_one`, `belongs_to`, `has_and_belongs_to_many` declarations are collected
|
|
16
|
+
- Custom associations like `:enrollments`, `:subscriptions`, `:invoices` are now detected
|
|
17
|
+
- Dynamic names supplement (not replace) the hardcoded fallback lists
|
|
18
|
+
- Benefits `LoopAssociation`, `SerializerNesting`, `DecoratorNPlusOne`, and `MissingCounterCache`
|
|
19
|
+
|
|
20
|
+
## [1.2.8] - 2026-03-10
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **New Detector: `ValidationNPlusOne`** - Detects batch create/save inside iterations for models with uniqueness validations
|
|
25
|
+
- Catches `User.create!(attrs)` and `User.new(attrs) + save!` patterns inside loops
|
|
26
|
+
- Parses model files for `validates :attr, uniqueness: true` and `validates_uniqueness_of` declarations
|
|
27
|
+
- Each uniqueness validation triggers a SELECT query per record — 2N queries total
|
|
28
|
+
- Suggests using `insert_all` with unique index constraints or batch-validating before saving
|
|
29
|
+
- **New Parser: `ValidationParser`** - Collects models with uniqueness validations for cross-file detection
|
|
30
|
+
|
|
10
31
|
## [1.2.7] - 2026-03-10
|
|
11
32
|
|
|
12
33
|
### 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.9-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 11 types of N+1 problems:**
|
|
46
46
|
- Loop associations (queries in iterations)
|
|
47
47
|
- Serializer nesting issues
|
|
48
48
|
- Missing counter caches
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
- Delegation N+1s (hidden via `delegate :method, to: :association`)
|
|
54
54
|
- Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
|
|
55
55
|
- Scope chain N+1s (named scopes on associations in loops)
|
|
56
|
+
- Validation N+1s (uniqueness validation in batch create/save)
|
|
56
57
|
|
|
57
58
|
🔧 **Developer-friendly:**
|
|
58
59
|
- Inline suppression (like RuboCop)
|
|
@@ -426,6 +427,35 @@ EagerEye detects these by:
|
|
|
426
427
|
1. Scanning model files for `scope :name, -> { ... }` declarations
|
|
427
428
|
2. Flagging known scope names called on association chains inside iteration blocks
|
|
428
429
|
|
|
430
|
+
### 11. Validation N+1
|
|
431
|
+
|
|
432
|
+
Detects batch create/save operations inside iterations for models with `validates uniqueness`. Each uniqueness validation triggers a SELECT query per record, resulting in 2N queries (SELECT + INSERT per record).
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
# Model
|
|
436
|
+
class User < ApplicationRecord
|
|
437
|
+
validates :email, uniqueness: true
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Bad - 2N queries (SELECT + INSERT per record)
|
|
441
|
+
params[:users].each do |user_params|
|
|
442
|
+
User.create!(user_params) # SELECT + INSERT for each!
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Bad - same problem with new + save
|
|
446
|
+
params[:users].each do |user_params|
|
|
447
|
+
user = User.new(user_params)
|
|
448
|
+
user.save! # SELECT + INSERT for each!
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Good - use insert_all with unique index
|
|
452
|
+
User.insert_all(params[:users]) # Single bulk INSERT, DB enforces uniqueness
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
EagerEye detects these by:
|
|
456
|
+
1. Scanning model files for `validates :attr, uniqueness: true` or `validates_uniqueness_of` declarations
|
|
457
|
+
2. Flagging `Model.create/create!` or `Model.new` + `.save/.save!` patterns inside iteration blocks
|
|
458
|
+
|
|
429
459
|
## Inline Suppression
|
|
430
460
|
|
|
431
461
|
Suppress false positives using inline comments (RuboCop-style):
|
|
@@ -471,6 +501,7 @@ Both CamelCase and snake_case formats are accepted:
|
|
|
471
501
|
| Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
|
|
472
502
|
| Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
|
|
473
503
|
| Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
|
|
504
|
+
| Validation N+1 | `ValidationNPlusOne` | `validation_n_plus_one` |
|
|
474
505
|
| All Detectors | `all` | `all` |
|
|
475
506
|
|
|
476
507
|
## Auto-fix (Experimental)
|
|
@@ -576,19 +607,21 @@ enabled_detectors:
|
|
|
576
607
|
- delegation_n_plus_one
|
|
577
608
|
- decorator_n_plus_one
|
|
578
609
|
- scope_chain_n_plus_one
|
|
610
|
+
- validation_n_plus_one
|
|
579
611
|
|
|
580
612
|
# Severity levels per detector (error, warning, info)
|
|
581
613
|
severity_levels:
|
|
582
|
-
loop_association: error
|
|
614
|
+
loop_association: error # Definite N+1
|
|
583
615
|
serializer_nesting: warning
|
|
584
616
|
custom_method_query: warning
|
|
585
617
|
count_in_iteration: warning
|
|
586
618
|
callback_query: warning
|
|
587
|
-
pluck_to_array: warning
|
|
588
|
-
delegation_n_plus_one: warning
|
|
589
|
-
decorator_n_plus_one: warning
|
|
590
|
-
scope_chain_n_plus_one: warning
|
|
591
|
-
|
|
619
|
+
pluck_to_array: warning # Optimization
|
|
620
|
+
delegation_n_plus_one: warning # Hidden delegation N+1
|
|
621
|
+
decorator_n_plus_one: warning # Decorator/Presenter N+1
|
|
622
|
+
scope_chain_n_plus_one: warning # Scope chain on association
|
|
623
|
+
validation_n_plus_one: warning # Uniqueness validation in batch
|
|
624
|
+
missing_counter_cache: info # Suggestion
|
|
592
625
|
|
|
593
626
|
# Minimum severity to report (default: info)
|
|
594
627
|
min_severity: warning
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -14,17 +14,31 @@ module EagerEye
|
|
|
14
14
|
pluck_to_array: Detectors::PluckToArray,
|
|
15
15
|
delegation_n_plus_one: Detectors::DelegationNPlusOne,
|
|
16
16
|
decorator_n_plus_one: Detectors::DecoratorNPlusOne,
|
|
17
|
-
scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne
|
|
17
|
+
scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne,
|
|
18
|
+
validation_n_plus_one: Detectors::ValidationNPlusOne
|
|
18
19
|
}.freeze
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
DETECTOR_EXTRA_ARGS = {
|
|
22
|
+
Detectors::LoopAssociation => %i[association_preloads association_names],
|
|
23
|
+
Detectors::SerializerNesting => %i[association_names],
|
|
24
|
+
Detectors::MissingCounterCache => %i[association_names],
|
|
25
|
+
Detectors::DecoratorNPlusOne => %i[association_names],
|
|
26
|
+
Detectors::DelegationNPlusOne => %i[delegation_maps],
|
|
27
|
+
Detectors::ScopeChainNPlusOne => %i[scope_maps],
|
|
28
|
+
Detectors::ValidationNPlusOne => %i[uniqueness_models]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :paths, :issues, :association_preloads, :association_names, :delegation_maps, :scope_maps,
|
|
32
|
+
:uniqueness_models
|
|
21
33
|
|
|
22
34
|
def initialize(paths: nil)
|
|
23
35
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
24
36
|
@issues = []
|
|
25
37
|
@association_preloads = {}
|
|
38
|
+
@association_names = Set.new
|
|
26
39
|
@delegation_maps = {}
|
|
27
40
|
@scope_maps = {}
|
|
41
|
+
@uniqueness_models = Set.new
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
def run
|
|
@@ -46,6 +60,7 @@ module EagerEye
|
|
|
46
60
|
assoc_parser = AssociationParser.new
|
|
47
61
|
assoc_parser.parse_model(ast, model_name)
|
|
48
62
|
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
63
|
+
@association_names.merge(assoc_parser.association_names)
|
|
49
64
|
|
|
50
65
|
deleg_parser = DelegationParser.new
|
|
51
66
|
deleg_parser.parse_model(ast, model_name)
|
|
@@ -54,6 +69,10 @@ module EagerEye
|
|
|
54
69
|
scope_parser = ScopeParser.new
|
|
55
70
|
scope_parser.parse_model(ast, model_name)
|
|
56
71
|
@scope_maps.merge!(scope_parser.scope_maps)
|
|
72
|
+
|
|
73
|
+
validation_parser = ValidationParser.new
|
|
74
|
+
validation_parser.parse_model(ast, model_name)
|
|
75
|
+
@uniqueness_models.merge(validation_parser.uniqueness_models)
|
|
57
76
|
rescue Errno::ENOENT, Errno::EACCES
|
|
58
77
|
next
|
|
59
78
|
end
|
|
@@ -111,11 +130,8 @@ module EagerEye
|
|
|
111
130
|
end
|
|
112
131
|
|
|
113
132
|
def detector_args(detector, ast, file_path)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
|
|
117
|
-
args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
|
|
118
|
-
args
|
|
133
|
+
extra = DETECTOR_EXTRA_ARGS.find { |klass, _| detector.is_a?(klass) }&.last || []
|
|
134
|
+
[ast, file_path, *extra.map { |name| instance_variable_get(:"@#{name}") }]
|
|
119
135
|
end
|
|
120
136
|
|
|
121
137
|
def enabled_detectors
|
|
@@ -4,10 +4,11 @@ module EagerEye
|
|
|
4
4
|
class AssociationParser
|
|
5
5
|
ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
|
|
6
6
|
|
|
7
|
-
attr_reader :preloaded_associations
|
|
7
|
+
attr_reader :preloaded_associations, :association_names
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@preloaded_associations = {}
|
|
11
|
+
@association_names = Set.new
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def parse_model(ast, model_name)
|
|
@@ -35,6 +36,8 @@ module EagerEye
|
|
|
35
36
|
association_name = extract_association_name(node)
|
|
36
37
|
return unless association_name
|
|
37
38
|
|
|
39
|
+
@association_names << association_name
|
|
40
|
+
|
|
38
41
|
preloaded = extract_preloaded_associations(node)
|
|
39
42
|
return if preloaded.empty?
|
|
40
43
|
|
|
@@ -9,7 +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
|
+
scope_chain_n_plus_one validation_n_plus_one
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
15
|
DEFAULT_SEVERITY_LEVELS = {
|
|
@@ -22,7 +22,8 @@ module EagerEye
|
|
|
22
22
|
pluck_to_array: :warning,
|
|
23
23
|
delegation_n_plus_one: :warning,
|
|
24
24
|
decorator_n_plus_one: :warning,
|
|
25
|
-
scope_chain_n_plus_one: :warning
|
|
25
|
+
scope_chain_n_plus_one: :warning,
|
|
26
|
+
validation_n_plus_one: :warning
|
|
26
27
|
}.freeze
|
|
27
28
|
|
|
28
29
|
VALID_SEVERITIES = %i[info warning error].freeze
|
|
@@ -35,7 +35,7 @@ module EagerEye
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def likely_association?(method_name)
|
|
38
|
-
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
|
|
38
|
+
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s) || @dynamic_associations&.include?(method_name)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def collect_active_storage_lines(body)
|
|
@@ -14,9 +14,10 @@ module EagerEye
|
|
|
14
14
|
:decorator_n_plus_one
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def detect(ast, file_path)
|
|
17
|
+
def detect(ast, file_path, association_names = Set.new)
|
|
18
18
|
return [] unless ast
|
|
19
19
|
|
|
20
|
+
@dynamic_associations = association_names
|
|
20
21
|
issues = []
|
|
21
22
|
traverse_ast(ast) do |node|
|
|
22
23
|
next unless node.type == :class && decorator_class?(node)
|
|
@@ -30,11 +30,12 @@ module EagerEye
|
|
|
30
30
|
:loop_association
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def detect(ast, file_path, association_preloads = {})
|
|
33
|
+
def detect(ast, file_path, association_preloads = {}, association_names = Set.new)
|
|
34
34
|
return [] unless ast
|
|
35
35
|
|
|
36
36
|
issues = []
|
|
37
37
|
@association_preloads = association_preloads
|
|
38
|
+
@dynamic_associations = association_names
|
|
38
39
|
build_variable_maps(ast)
|
|
39
40
|
|
|
40
41
|
traverse_ast(ast) do |node|
|
|
@@ -181,9 +182,13 @@ module EagerEye
|
|
|
181
182
|
|
|
182
183
|
def excluded_method?(method, included)
|
|
183
184
|
EXCLUDED_METHODS.include?(method) ||
|
|
184
|
-
!
|
|
185
|
+
!known_association?(method) ||
|
|
185
186
|
included.include?(method)
|
|
186
187
|
end
|
|
188
|
+
|
|
189
|
+
def known_association?(method)
|
|
190
|
+
ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
|
|
191
|
+
end
|
|
187
192
|
end
|
|
188
193
|
end
|
|
189
194
|
end
|
|
@@ -17,9 +17,10 @@ module EagerEye
|
|
|
17
17
|
:missing_counter_cache
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def detect(ast, file_path)
|
|
20
|
+
def detect(ast, file_path, association_names = Set.new)
|
|
21
21
|
return [] unless ast
|
|
22
22
|
|
|
23
|
+
@dynamic_associations = association_names
|
|
23
24
|
issues = []
|
|
24
25
|
|
|
25
26
|
traverse_ast(ast) do |node|
|
|
@@ -51,7 +52,10 @@ module EagerEye
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def likely_association_receiver?(node)
|
|
54
|
-
node.type == :send
|
|
55
|
+
return false unless node.type == :send
|
|
56
|
+
|
|
57
|
+
method = node.children[1]
|
|
58
|
+
PLURAL_ASSOCIATIONS.include?(method.to_s) || @dynamic_associations&.include?(method)
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
def extract_association_name(node)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class ScopeChainNPlusOne < Base
|
|
6
|
+
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
|
|
7
|
+
find_each find_in_batches in_batches].freeze
|
|
8
|
+
|
|
9
|
+
def self.detector_name
|
|
10
|
+
:scope_chain_n_plus_one
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def detect(ast, file_path, scope_maps = {})
|
|
14
|
+
return [] unless ast
|
|
15
|
+
|
|
16
|
+
@issues = []
|
|
17
|
+
@file_path = file_path
|
|
18
|
+
@all_scopes = scope_maps.each_value.reduce(Set.new, :merge)
|
|
19
|
+
return [] if @all_scopes.empty?
|
|
20
|
+
|
|
21
|
+
find_iteration_blocks(ast)
|
|
22
|
+
@issues
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def find_iteration_blocks(node)
|
|
28
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
29
|
+
|
|
30
|
+
if iteration_block?(node)
|
|
31
|
+
block_var = extract_block_variable(node)
|
|
32
|
+
check_block(node.children[2], block_var) if block_var
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
node.children.each { |child| find_iteration_blocks(child) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def iteration_block?(node)
|
|
39
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
40
|
+
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_block(node, block_var)
|
|
44
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
45
|
+
|
|
46
|
+
add_issue(node) if scope_chain_on_association?(node, block_var)
|
|
47
|
+
node.children.each { |child| check_block(child, block_var) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scope_chain_on_association?(node, block_var)
|
|
51
|
+
return false unless node.type == :send
|
|
52
|
+
|
|
53
|
+
method_name = node.children[1]
|
|
54
|
+
return false unless @all_scopes.include?(method_name)
|
|
55
|
+
|
|
56
|
+
receiver = node.children[0]
|
|
57
|
+
receiver_chain_starts_with?(receiver, block_var) && chain_depth(receiver, block_var) >= 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def chain_depth(node, block_var)
|
|
61
|
+
return 0 unless node.is_a?(Parser::AST::Node)
|
|
62
|
+
return 0 if node.type == :lvar && node.children[0] == block_var
|
|
63
|
+
|
|
64
|
+
node.type == :send ? 1 + chain_depth(node.children[0], block_var) : 0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def add_issue(node)
|
|
68
|
+
chain = reconstruct_chain(node.children[0])
|
|
69
|
+
@issues << create_issue(
|
|
70
|
+
file_path: @file_path,
|
|
71
|
+
line_number: node.loc.line,
|
|
72
|
+
message: "Scope `.#{node.children[1]}` called on `#{chain}` inside iteration",
|
|
73
|
+
suggestion: "Each scope call executes a new query. Consider preloading or using a joined query."
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -15,9 +15,10 @@ module EagerEye
|
|
|
15
15
|
:serializer_nesting
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def detect(ast, file_path)
|
|
18
|
+
def detect(ast, file_path, association_names = Set.new)
|
|
19
19
|
return [] unless ast
|
|
20
20
|
|
|
21
|
+
@dynamic_associations = association_names
|
|
21
22
|
issues = []
|
|
22
23
|
traverse_ast(ast) do |node|
|
|
23
24
|
next unless node.type == :class && serializer_class?(node)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class ValidationNPlusOne < Base
|
|
6
|
+
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
|
|
7
|
+
find_each find_in_batches in_batches].freeze
|
|
8
|
+
CREATE_METHODS = %i[create create!].freeze
|
|
9
|
+
SAVE_METHODS = %i[save save!].freeze
|
|
10
|
+
|
|
11
|
+
def self.detector_name
|
|
12
|
+
:validation_n_plus_one
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def detect(ast, file_path, uniqueness_models = Set.new)
|
|
16
|
+
return [] unless ast
|
|
17
|
+
|
|
18
|
+
@issues = []
|
|
19
|
+
@file_path = file_path
|
|
20
|
+
@uniqueness_models = uniqueness_models
|
|
21
|
+
return [] if @uniqueness_models.empty?
|
|
22
|
+
|
|
23
|
+
find_iteration_blocks(ast)
|
|
24
|
+
@issues
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def find_iteration_blocks(node)
|
|
30
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
31
|
+
|
|
32
|
+
if iteration_block?(node)
|
|
33
|
+
block_body = node.children[2]
|
|
34
|
+
check_block(block_body) if block_body
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
node.children.each { |child| find_iteration_blocks(child) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def iteration_block?(node)
|
|
41
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
42
|
+
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_block(block_body)
|
|
46
|
+
new_model_vars = {}
|
|
47
|
+
collect_model_new_assignments(block_body, new_model_vars)
|
|
48
|
+
scan_for_issues(block_body, new_model_vars)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def collect_model_new_assignments(node, vars)
|
|
52
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
53
|
+
|
|
54
|
+
if node.type == :lvasgn && model_new_call?(node.children[1])
|
|
55
|
+
model_name = const_name(node.children[1].children[0])
|
|
56
|
+
vars[node.children[0]] = model_name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
node.children.each { |child| collect_model_new_assignments(child, vars) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def scan_for_issues(node, new_model_vars)
|
|
63
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
64
|
+
|
|
65
|
+
if node.type == :send
|
|
66
|
+
check_create_call(node)
|
|
67
|
+
check_save_call(node, new_model_vars)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
node.children.each { |child| scan_for_issues(child, new_model_vars) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_create_call(node)
|
|
74
|
+
return unless CREATE_METHODS.include?(node.children[1])
|
|
75
|
+
|
|
76
|
+
receiver = node.children[0]
|
|
77
|
+
return unless receiver&.type == :const
|
|
78
|
+
|
|
79
|
+
model_name = const_name(receiver)
|
|
80
|
+
add_issue(node, model_name, node.children[1]) if @uniqueness_models.include?(model_name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_save_call(node, new_model_vars)
|
|
84
|
+
return unless SAVE_METHODS.include?(node.children[1])
|
|
85
|
+
|
|
86
|
+
receiver = node.children[0]
|
|
87
|
+
return unless receiver&.type == :lvar
|
|
88
|
+
|
|
89
|
+
model_name = new_model_vars[receiver.children[0]]
|
|
90
|
+
add_issue(node, model_name, node.children[1]) if model_name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def model_new_call?(node)
|
|
94
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
95
|
+
|
|
96
|
+
node.children[1] == :new && node.children[0]&.type == :const &&
|
|
97
|
+
@uniqueness_models.include?(const_name(node.children[0]))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def const_name(node)
|
|
101
|
+
return "" unless node.is_a?(Parser::AST::Node) && node.type == :const
|
|
102
|
+
|
|
103
|
+
parent = node.children[0]
|
|
104
|
+
name = node.children[1].to_s
|
|
105
|
+
parent ? "#{const_name(parent)}::#{name}" : name
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_issue(node, model_name, method_name)
|
|
109
|
+
@issues << create_issue(
|
|
110
|
+
file_path: @file_path,
|
|
111
|
+
line_number: node.loc.line,
|
|
112
|
+
message: "`#{model_name}.#{method_name}` inside iteration — uniqueness validation causes SELECT per record",
|
|
113
|
+
suggestion: "Use `insert_all` with unique index constraints, or batch-validate before saving."
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
class ScopeParser
|
|
5
|
+
attr_reader :scope_maps
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@scope_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_scope(node, model_name)
|
|
23
|
+
node.children.each { |child| traverse(child, model_name) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_scope(node, model_name)
|
|
27
|
+
return unless scope_call?(node)
|
|
28
|
+
|
|
29
|
+
scope_name = extract_scope_name(node)
|
|
30
|
+
return unless scope_name
|
|
31
|
+
|
|
32
|
+
@scope_maps[model_name] ||= Set.new
|
|
33
|
+
@scope_maps[model_name] << scope_name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scope_call?(node)
|
|
37
|
+
node.type == :send && node.children[0].nil? && node.children[1] == :scope
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_scope_name(node)
|
|
41
|
+
first_arg = node.children[2]
|
|
42
|
+
first_arg&.type == :sym ? first_arg.children[0] : nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
class ValidationParser
|
|
5
|
+
attr_reader :uniqueness_models
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@uniqueness_models = Set.new
|
|
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_uniqueness(node, model_name)
|
|
23
|
+
node.children.each { |child| traverse(child, model_name) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_uniqueness(node, model_name)
|
|
27
|
+
return unless node.type == :send && node.children[0].nil?
|
|
28
|
+
|
|
29
|
+
case node.children[1]
|
|
30
|
+
when :validates
|
|
31
|
+
@uniqueness_models << model_name if uniqueness_option?(node)
|
|
32
|
+
when :validates_uniqueness_of
|
|
33
|
+
@uniqueness_models << model_name
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def uniqueness_option?(node)
|
|
38
|
+
node.children[2..].any? do |arg|
|
|
39
|
+
next false unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
|
|
40
|
+
|
|
41
|
+
arg.children.any? { |pair| uniqueness_pair?(pair) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def uniqueness_pair?(pair)
|
|
46
|
+
return false unless pair.type == :pair
|
|
47
|
+
|
|
48
|
+
key = pair.children[0]
|
|
49
|
+
key&.type == :sym && key.children[0] == :uniqueness
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/eager_eye/version.rb
CHANGED
data/lib/eager_eye.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "eager_eye/issue"
|
|
|
7
7
|
require_relative "eager_eye/association_parser"
|
|
8
8
|
require_relative "eager_eye/delegation_parser"
|
|
9
9
|
require_relative "eager_eye/scope_parser"
|
|
10
|
+
require_relative "eager_eye/validation_parser"
|
|
10
11
|
require_relative "eager_eye/detectors/base"
|
|
11
12
|
require_relative "eager_eye/detectors/loop_association"
|
|
12
13
|
require_relative "eager_eye/detectors/serializer_nesting"
|
|
@@ -18,6 +19,7 @@ require_relative "eager_eye/detectors/pluck_to_array"
|
|
|
18
19
|
require_relative "eager_eye/detectors/delegation_n_plus_one"
|
|
19
20
|
require_relative "eager_eye/detectors/decorator_n_plus_one"
|
|
20
21
|
require_relative "eager_eye/detectors/scope_chain_n_plus_one"
|
|
22
|
+
require_relative "eager_eye/detectors/validation_n_plus_one"
|
|
21
23
|
require_relative "eager_eye/comment_parser"
|
|
22
24
|
require_relative "eager_eye/analyzer"
|
|
23
25
|
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.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hamzagedikkaya
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -76,7 +76,9 @@ files:
|
|
|
76
76
|
- lib/eager_eye/detectors/loop_association.rb
|
|
77
77
|
- lib/eager_eye/detectors/missing_counter_cache.rb
|
|
78
78
|
- lib/eager_eye/detectors/pluck_to_array.rb
|
|
79
|
+
- lib/eager_eye/detectors/scope_chain_n_plus_one.rb
|
|
79
80
|
- lib/eager_eye/detectors/serializer_nesting.rb
|
|
81
|
+
- lib/eager_eye/detectors/validation_n_plus_one.rb
|
|
80
82
|
- lib/eager_eye/fixer_registry.rb
|
|
81
83
|
- lib/eager_eye/fixers/base.rb
|
|
82
84
|
- lib/eager_eye/fixers/pluck_to_select.rb
|
|
@@ -88,6 +90,8 @@ files:
|
|
|
88
90
|
- lib/eager_eye/reporters/json.rb
|
|
89
91
|
- lib/eager_eye/rspec.rb
|
|
90
92
|
- lib/eager_eye/rspec/matchers.rb
|
|
93
|
+
- lib/eager_eye/scope_parser.rb
|
|
94
|
+
- lib/eager_eye/validation_parser.rb
|
|
91
95
|
- lib/eager_eye/version.rb
|
|
92
96
|
- sig/eager_eye.rbs
|
|
93
97
|
homepage: https://github.com/hamzagedikkaya/eager_eye
|