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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79319b66a87234e31df5def9ba5c0a487e298de8620bd4022f9d7db63e455152
4
- data.tar.gz: f400ab8eb6ca1762a9773f90d8d1311546ed58035b3a062c6d408f7a97fd17e2
3
+ metadata.gz: 4cedfbe1ec9b556f757e9b161f896d96986bfc2dac8bf865c6e1bc289f6777b8
4
+ data.tar.gz: 8c0dc65a55e21c5d4e122f51293ac480f01435af2b719a262efe6ccdfa04603c
5
5
  SHA512:
6
- metadata.gz: d70aff4b63cc9da79ca7956487a94d0e950aa85416b64636c1a2d8f94f898c1aea04712d3e964aaf5aafd8eea4fca9a817f09021806462123cc1b4bd45bec9ff
7
- data.tar.gz: f77947b4812e87380618ac4544e717b5b17f08386540d6142a0e9f9fbbf9ed8fbdd6460beb8671e0a6e4fb8e8d2f21c1417eb9281082f5da67576bfd51990ab0
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.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.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>
@@ -18,12 +18,24 @@ 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],
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
- 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
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
- !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)
@@ -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
@@ -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.9"
5
5
  end
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.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
@@ -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