eager_eye 1.2.8 → 1.2.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79319b66a87234e31df5def9ba5c0a487e298de8620bd4022f9d7db63e455152
4
- data.tar.gz: f400ab8eb6ca1762a9773f90d8d1311546ed58035b3a062c6d408f7a97fd17e2
3
+ metadata.gz: 428a53d534da23f40ea61c600afe71dab1b3463eab4a0640a78502eb35dd2ebc
4
+ data.tar.gz: 004edfb6e51aa7e4708ff1731d1205fa336722995f440ac438e49535be7934b8
5
5
  SHA512:
6
- metadata.gz: d70aff4b63cc9da79ca7956487a94d0e950aa85416b64636c1a2d8f94f898c1aea04712d3e964aaf5aafd8eea4fca9a817f09021806462123cc1b4bd45bec9ff
7
- data.tar.gz: f77947b4812e87380618ac4544e717b5b17f08386540d6142a0e9f9fbbf9ed8fbdd6460beb8671e0a6e4fb8e8d2f21c1417eb9281082f5da67576bfd51990ab0
6
+ metadata.gz: 3e1e172cc60a07b7f031be8236b626c4b1fc5d081ef201d952d6e366ffddeddbcc102332cd281714529a0f51b3a35df5195a567b32d596ccf9af97aff93eb28a
7
+ data.tar.gz: c52ac7fe9047f9cdb3cf8ba040cea3faf0f910f50e6f36561d20fc2728da1a41c0e442efbc7af4511e68744071c005fd1dd328cf5cac68bb4807e5220bfcbc51
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.10] - 2026-03-16
11
+
12
+ ### Added
13
+
14
+ - **Cross-File Analysis** - Model methods containing queries are now detected when called from other files
15
+ - New `MethodQueryParser` scans model files for methods that execute queries (`.where`, `.find_by`, `.count`, `.pluck`, etc.)
16
+ - `LoopAssociation` detects cross-file query methods called inside loops (e.g. `user.active_orders` in a controller)
17
+ - `CustomMethodQuery` detects model query methods called inside iterations
18
+ - `SerializerNesting` detects model query methods called in serializer attribute blocks
19
+ - `DecoratorNPlusOne` detects model query methods called in decorator methods
20
+ - Two-pass architecture: first pass collects method signatures, second pass detects cross-file N+1 patterns
21
+
22
+ ## [1.2.9] - 2026-03-13
23
+
24
+ ### Changed
25
+
26
+ - **Dynamic Association Detection** - Association names are now automatically parsed from model files
27
+ - `has_many`, `has_one`, `belongs_to`, `has_and_belongs_to_many` declarations are collected
28
+ - Custom associations like `:enrollments`, `:subscriptions`, `:invoices` are now detected
29
+ - Dynamic names supplement (not replace) the hardcoded fallback lists
30
+ - Benefits `LoopAssociation`, `SerializerNesting`, `DecoratorNPlusOne`, and `MissingCounterCache`
31
+
10
32
  ## [1.2.8] - 2026-03-10
11
33
 
12
34
  ### 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.8-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.10-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>
@@ -739,6 +739,7 @@ EagerEye uses static analysis, which means:
739
739
  - **No runtime context** - Cannot know if associations are already eager loaded elsewhere
740
740
  - **Heuristic-based** - Uses naming conventions to identify associations (may have false positives)
741
741
  - **Ruby code only** - Does not analyze SQL queries or ActiveRecord internals
742
+ - **Cross-file scope** - Cross-file analysis is limited to model-defined methods; controller-to-view or service-to-service patterns are not yet tracked
742
743
 
743
744
  For best results, use EagerEye alongside runtime tools like Bullet for comprehensive N+1 detection.
744
745
 
@@ -18,12 +18,26 @@ module EagerEye
18
18
  validation_n_plus_one: Detectors::ValidationNPlusOne
19
19
  }.freeze
20
20
 
21
- attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps, :uniqueness_models
21
+ DETECTOR_EXTRA_ARGS = {
22
+ Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
23
+ Detectors::SerializerNesting => %i[association_names method_queries],
24
+ Detectors::MissingCounterCache => %i[association_names],
25
+ Detectors::DecoratorNPlusOne => %i[association_names method_queries],
26
+ Detectors::CustomMethodQuery => %i[method_queries],
27
+ Detectors::DelegationNPlusOne => %i[delegation_maps],
28
+ Detectors::ScopeChainNPlusOne => %i[scope_maps],
29
+ Detectors::ValidationNPlusOne => %i[uniqueness_models]
30
+ }.freeze
31
+
32
+ attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
33
+ :scope_maps, :uniqueness_models
22
34
 
23
35
  def initialize(paths: nil)
24
36
  @paths = Array(paths || EagerEye.configuration.app_path)
25
37
  @issues = []
26
38
  @association_preloads = {}
39
+ @association_names = Set.new
40
+ @method_queries = {}
27
41
  @delegation_maps = {}
28
42
  @scope_maps = {}
29
43
  @uniqueness_models = Set.new
@@ -48,6 +62,7 @@ module EagerEye
48
62
  assoc_parser = AssociationParser.new
49
63
  assoc_parser.parse_model(ast, model_name)
50
64
  @association_preloads.merge!(assoc_parser.preloaded_associations)
65
+ @association_names.merge(assoc_parser.association_names)
51
66
 
52
67
  deleg_parser = DelegationParser.new
53
68
  deleg_parser.parse_model(ast, model_name)
@@ -60,6 +75,10 @@ module EagerEye
60
75
  validation_parser = ValidationParser.new
61
76
  validation_parser.parse_model(ast, model_name)
62
77
  @uniqueness_models.merge(validation_parser.uniqueness_models)
78
+
79
+ method_query_parser = MethodQueryParser.new
80
+ method_query_parser.parse_model(ast, model_name)
81
+ @method_queries.merge!(method_query_parser.method_queries)
63
82
  rescue Errno::ENOENT, Errno::EACCES
64
83
  next
65
84
  end
@@ -117,12 +136,8 @@ module EagerEye
117
136
  end
118
137
 
119
138
  def detector_args(detector, ast, file_path)
120
- args = [ast, file_path]
121
- args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
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
139
+ extra = DETECTOR_EXTRA_ARGS.find { |klass, _| detector.is_a?(klass) }&.last || []
140
+ [ast, file_path, *extra.map { |name| instance_variable_get(:"@#{name}") }]
126
141
  end
127
142
 
128
143
  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)
@@ -15,14 +15,16 @@ module EagerEye
15
15
  :custom_method_query
16
16
  end
17
17
 
18
- def detect(ast, file_path)
18
+ def detect(ast, file_path, method_queries = {})
19
19
  return [] unless ast
20
20
 
21
21
  @issues = []
22
22
  @file_path = file_path
23
+ @method_queries = method_queries
23
24
 
24
25
  find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
25
26
  check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
27
+ check_block_for_model_query_methods(block_body, block_var)
26
28
  end
27
29
 
28
30
  @issues
@@ -112,6 +114,31 @@ module EagerEye
112
114
  node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
113
115
  end
114
116
 
117
+ def check_block_for_model_query_methods(node, block_var)
118
+ return unless node.is_a?(Parser::AST::Node)
119
+
120
+ if model_query_call?(node, block_var)
121
+ method = node.children[1]
122
+ @issues << create_issue(
123
+ file_path: @file_path,
124
+ line_number: node.loc.line,
125
+ message: "Model method `.#{method}` contains a query and is called inside iteration",
126
+ suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
127
+ )
128
+ end
129
+ node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
130
+ end
131
+
132
+ def model_query_call?(node, block_var)
133
+ return false unless node.type == :send
134
+
135
+ receiver = node.children[0]
136
+ method = node.children[1]
137
+ return false unless receiver&.type == :lvar && receiver.children[0] == block_var
138
+
139
+ @method_queries&.any? { |_model, methods| methods.include?(method) }
140
+ end
141
+
115
142
  def add_issue(node)
116
143
  chain = reconstruct_chain(node.children[0])
117
144
  @issues << create_issue(
@@ -14,9 +14,11 @@ 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, method_queries = {})
18
18
  return [] unless ast
19
19
 
20
+ @dynamic_associations = association_names
21
+ @method_queries = method_queries
20
22
  issues = []
21
23
  traverse_ast(ast) do |node|
22
24
  next unless node.type == :class && decorator_class?(node)
@@ -64,21 +66,34 @@ module EagerEye
64
66
 
65
67
  receiver = node.children[0]
66
68
  method_name = node.children[1]
67
- next unless object_reference?(receiver) && likely_association?(method_name)
69
+ next unless object_reference?(receiver)
68
70
 
69
71
  ref = receiver.children[1]
70
- issues << create_issue(
71
- file_path: file_path,
72
- line_number: node.loc.line,
73
- message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
74
- suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
75
- )
72
+ if likely_association?(method_name)
73
+ issues << create_issue(
74
+ file_path: file_path,
75
+ line_number: node.loc.line,
76
+ message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
77
+ suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
78
+ )
79
+ elsif model_query_method?(method_name)
80
+ issues << create_issue(
81
+ file_path: file_path,
82
+ line_number: node.loc.line,
83
+ message: "N+1 in decorator: `#{ref}.#{method_name}` calls a query method defined in the model",
84
+ suggestion: "Preload the data in the controller before decorating the collection"
85
+ )
86
+ end
76
87
  end
77
88
  end
78
89
 
79
90
  def object_reference?(node)
80
91
  node&.type == :send && node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
81
92
  end
93
+
94
+ def model_query_method?(method_name)
95
+ @method_queries&.any? { |_model, methods| methods.include?(method_name) }
96
+ end
82
97
  end
83
98
  end
84
99
  end
@@ -30,11 +30,13 @@ 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, method_queries = {})
34
34
  return [] unless ast
35
35
 
36
36
  issues = []
37
37
  @association_preloads = association_preloads
38
+ @dynamic_associations = association_names
39
+ @method_queries = method_queries
38
40
  build_variable_maps(ast)
39
41
 
40
42
  traverse_ast(ast) do |node|
@@ -156,15 +158,23 @@ module EagerEye
156
158
  def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
157
159
  reported = Set.new
158
160
  traverse_ast(node) do |child|
159
- next unless reportable_association_call?(child, block_var, reported, included_associations)
160
-
161
- method = child.children[1]
162
- issues << create_issue(
163
- file_path: file_path,
164
- line_number: child.loc.line,
165
- message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
166
- suggestion: "Use `includes(:#{method})` before iterating"
167
- )
161
+ if reportable_association_call?(child, block_var, reported, included_associations)
162
+ method = child.children[1]
163
+ issues << create_issue(
164
+ file_path: file_path,
165
+ line_number: child.loc.line,
166
+ message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
167
+ suggestion: "Use `includes(:#{method})` before iterating"
168
+ )
169
+ elsif reportable_method_query_call?(child, block_var, reported)
170
+ method = child.children[1]
171
+ issues << create_issue(
172
+ file_path: file_path,
173
+ line_number: child.loc.line,
174
+ message: "Potential N+1 query: `#{block_var}.#{method}` calls a query method defined in the model",
175
+ suggestion: "Preload the data or restructure to avoid per-record queries"
176
+ )
177
+ end
168
178
  end
169
179
  end
170
180
 
@@ -181,9 +191,25 @@ module EagerEye
181
191
 
182
192
  def excluded_method?(method, included)
183
193
  EXCLUDED_METHODS.include?(method) ||
184
- !ASSOCIATION_NAMES.include?(method.to_s) ||
194
+ !known_association?(method) ||
185
195
  included.include?(method)
186
196
  end
197
+
198
+ def known_association?(method)
199
+ ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
200
+ end
201
+
202
+ def reportable_method_query_call?(node, block_var, reported)
203
+ return false unless block_var_send?(node, block_var)
204
+ return false if EXCLUDED_METHODS.include?(node.children[1])
205
+ return false unless @method_queries&.any? { |_, ms| ms.include?(node.children[1]) }
206
+
207
+ reported.add?("#{node.loc.line}:#{node.children[1]}")
208
+ end
209
+
210
+ def block_var_send?(node, block_var)
211
+ node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
212
+ end
187
213
  end
188
214
  end
189
215
  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 && PLURAL_ASSOCIATIONS.include?(node.children[1].to_s)
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,11 @@ 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, method_queries = {})
19
19
  return [] unless ast
20
20
 
21
+ @dynamic_associations = association_names
22
+ @method_queries = method_queries
21
23
  issues = []
22
24
  traverse_ast(ast) do |node|
23
25
  next unless node.type == :class && serializer_class?(node)
@@ -85,14 +87,23 @@ module EagerEye
85
87
 
86
88
  receiver = node.children[0]
87
89
  method_name = node.children[1]
88
- next unless object_reference?(receiver) && likely_association?(method_name)
89
-
90
- issues << create_issue(
91
- file_path: file_path,
92
- line_number: node.loc.line,
93
- message: "Nested association `#{receiver_name(receiver)}.#{method_name}` in serializer attribute",
94
- suggestion: "Eager load :#{method_name} in controller or use association serializer"
95
- )
90
+ next unless object_reference?(receiver)
91
+
92
+ if likely_association?(method_name)
93
+ issues << create_issue(
94
+ file_path: file_path,
95
+ line_number: node.loc.line,
96
+ message: "Nested association `#{receiver_name(receiver)}.#{method_name}` in serializer attribute",
97
+ suggestion: "Eager load :#{method_name} in controller or use association serializer"
98
+ )
99
+ elsif model_query_method?(method_name)
100
+ issues << create_issue(
101
+ file_path: file_path,
102
+ line_number: node.loc.line,
103
+ message: "Model method `#{receiver_name(receiver)}.#{method_name}` contains a query in serializer",
104
+ suggestion: "This method executes a query per serialized object. Preload or cache the data."
105
+ )
106
+ end
96
107
  end
97
108
  end
98
109
 
@@ -113,6 +124,10 @@ module EagerEye
113
124
  else "object"
114
125
  end
115
126
  end
127
+
128
+ def model_query_method?(method_name)
129
+ @method_queries&.any? { |_model, methods| methods.include?(method_name) }
130
+ end
116
131
  end
117
132
  end
118
133
  end
@@ -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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ class MethodQueryParser
5
+ QUERY_METHODS = %i[where find find_by find_by! exists? any? none? many? one?
6
+ count sum average minimum maximum
7
+ pluck ids select
8
+ update_all delete_all destroy_all
9
+ create create! save save!
10
+ first last take].freeze
11
+
12
+ ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
13
+
14
+ attr_reader :method_queries
15
+
16
+ def initialize
17
+ @method_queries = {}
18
+ end
19
+
20
+ def parse_model(ast, model_name)
21
+ return unless ast
22
+
23
+ traverse(ast, model_name)
24
+ end
25
+
26
+ private
27
+
28
+ def traverse(node, model_name)
29
+ return unless node.is_a?(Parser::AST::Node)
30
+
31
+ check_method(node, model_name)
32
+ node.children.each { |child| traverse(child, model_name) }
33
+ end
34
+
35
+ def check_method(node, model_name)
36
+ return unless instance_method_def?(node)
37
+
38
+ method_name = node.children[0]
39
+ body = node.children[2]
40
+ return unless body && contains_query?(body)
41
+
42
+ @method_queries[model_name] ||= Set.new
43
+ @method_queries[model_name] << method_name
44
+ end
45
+
46
+ def instance_method_def?(node)
47
+ node.type == :def
48
+ end
49
+
50
+ def contains_query?(node)
51
+ return false unless node.is_a?(Parser::AST::Node)
52
+ return true if query_call?(node)
53
+
54
+ node.children.any? { |child| contains_query?(child) }
55
+ end
56
+
57
+ def query_call?(node)
58
+ node.type == :send && QUERY_METHODS.include?(node.children[1])
59
+ end
60
+ end
61
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.8"
4
+ VERSION = "1.2.10"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "eager_eye/association_parser"
8
8
  require_relative "eager_eye/delegation_parser"
9
9
  require_relative "eager_eye/scope_parser"
10
10
  require_relative "eager_eye/validation_parser"
11
+ require_relative "eager_eye/method_query_parser"
11
12
  require_relative "eager_eye/detectors/base"
12
13
  require_relative "eager_eye/detectors/loop_association"
13
14
  require_relative "eager_eye/detectors/serializer_nesting"
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.8
4
+ version: 1.2.10
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-10 00:00:00.000000000 Z
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -78,11 +78,13 @@ 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
84
85
  - lib/eager_eye/generators/install_generator.rb
85
86
  - lib/eager_eye/issue.rb
87
+ - lib/eager_eye/method_query_parser.rb
86
88
  - lib/eager_eye/railtie.rb
87
89
  - lib/eager_eye/reporters/base.rb
88
90
  - lib/eager_eye/reporters/console.rb
@@ -90,6 +92,7 @@ files:
90
92
  - lib/eager_eye/rspec.rb
91
93
  - lib/eager_eye/rspec/matchers.rb
92
94
  - lib/eager_eye/scope_parser.rb
95
+ - lib/eager_eye/validation_parser.rb
93
96
  - lib/eager_eye/version.rb
94
97
  - sig/eager_eye.rbs
95
98
  homepage: https://github.com/hamzagedikkaya/eager_eye