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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd5177a12d64b54cc6cd426440f79018da9ccc7dd931db70c491c9154bb385a1
4
- data.tar.gz: dec16deb3b55ab821225d5d0e06a6fa308c41fd472dad405bf125c7e5839f2e0
3
+ metadata.gz: 4cedfbe1ec9b556f757e9b161f896d96986bfc2dac8bf865c6e1bc289f6777b8
4
+ data.tar.gz: 8c0dc65a55e21c5d4e122f51293ac480f01435af2b719a262efe6ccdfa04603c
5
5
  SHA512:
6
- metadata.gz: 4336421ff71269732f594163dc3dbe29ca70d1b10c19dd333d972ea63bff9da36a4e83a041263fa6f61d758c8b0551912e075e00ce8f05978ab59c7db698af41
7
- data.tar.gz: 51de528f0dca6b3279c0cb1fe6f21e5c35592dda81220419c209d8d43faf35b41375cbfffd33a463546f5e81e4807f8c48268f618d8383c82a570f81da823efa
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.7-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.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 10 types of N+1 problems:**
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 # Definite N+1
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 # 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
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
@@ -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
- attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps
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
- args = [ast, file_path]
115
- args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
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
- !ASSOCIATION_NAMES.include?(method.to_s) ||
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 && 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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.7"
4
+ VERSION = "1.2.9"
5
5
  end
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.7
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-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
@@ -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