eager_eye 1.2.8 → 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 +10 -0
- data/README.md +1 -1
- data/lib/eager_eye/analyzer.rb +16 -7
- data/lib/eager_eye/association_parser.rb +4 -1
- 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/serializer_nesting.rb +2 -1
- data/lib/eager_eye/detectors/validation_n_plus_one.rb +118 -0
- data/lib/eager_eye/validation_parser.rb +52 -0
- data/lib/eager_eye/version.rb +1 -1
- 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: 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,16 @@ 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
|
+
|
|
10
20
|
## [1.2.8] - 2026-03-10
|
|
11
21
|
|
|
12
22
|
### 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>
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -18,12 +18,24 @@ module EagerEye
|
|
|
18
18
|
validation_n_plus_one: Detectors::ValidationNPlusOne
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
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
|
|
22
33
|
|
|
23
34
|
def initialize(paths: nil)
|
|
24
35
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
25
36
|
@issues = []
|
|
26
37
|
@association_preloads = {}
|
|
38
|
+
@association_names = Set.new
|
|
27
39
|
@delegation_maps = {}
|
|
28
40
|
@scope_maps = {}
|
|
29
41
|
@uniqueness_models = Set.new
|
|
@@ -48,6 +60,7 @@ module EagerEye
|
|
|
48
60
|
assoc_parser = AssociationParser.new
|
|
49
61
|
assoc_parser.parse_model(ast, model_name)
|
|
50
62
|
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
63
|
+
@association_names.merge(assoc_parser.association_names)
|
|
51
64
|
|
|
52
65
|
deleg_parser = DelegationParser.new
|
|
53
66
|
deleg_parser.parse_model(ast, model_name)
|
|
@@ -117,12 +130,8 @@ module EagerEye
|
|
|
117
130
|
end
|
|
118
131
|
|
|
119
132
|
def detector_args(detector, ast, file_path)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
|
|
123
|
-
args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
|
|
124
|
-
args << @uniqueness_models if detector.is_a?(Detectors::ValidationNPlusOne)
|
|
125
|
-
args
|
|
133
|
+
extra = DETECTOR_EXTRA_ARGS.find { |klass, _| detector.is_a?(klass) }&.last || []
|
|
134
|
+
[ast, file_path, *extra.map { |name| instance_variable_get(:"@#{name}") }]
|
|
126
135
|
end
|
|
127
136
|
|
|
128
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
|
|
|
@@ -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)
|
|
@@ -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,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
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
|
|
@@ -78,6 +78,7 @@ files:
|
|
|
78
78
|
- lib/eager_eye/detectors/pluck_to_array.rb
|
|
79
79
|
- lib/eager_eye/detectors/scope_chain_n_plus_one.rb
|
|
80
80
|
- lib/eager_eye/detectors/serializer_nesting.rb
|
|
81
|
+
- lib/eager_eye/detectors/validation_n_plus_one.rb
|
|
81
82
|
- lib/eager_eye/fixer_registry.rb
|
|
82
83
|
- lib/eager_eye/fixers/base.rb
|
|
83
84
|
- lib/eager_eye/fixers/pluck_to_select.rb
|
|
@@ -90,6 +91,7 @@ files:
|
|
|
90
91
|
- lib/eager_eye/rspec.rb
|
|
91
92
|
- lib/eager_eye/rspec/matchers.rb
|
|
92
93
|
- lib/eager_eye/scope_parser.rb
|
|
94
|
+
- lib/eager_eye/validation_parser.rb
|
|
93
95
|
- lib/eager_eye/version.rb
|
|
94
96
|
- sig/eager_eye.rbs
|
|
95
97
|
homepage: https://github.com/hamzagedikkaya/eager_eye
|