eager_eye 1.2.13 → 1.2.14

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: c9b4195fa833057ee04ef87d148e78de8463fdbca97e234ba9d5b08ecaab9afc
4
- data.tar.gz: f5c45a109aba0f563866669c4b6a32ce0823990fe5de467d65f6baea8d405197
3
+ metadata.gz: 102d5299d625a885f2916d113d68a966cfb6c261bcebb8e5caf53f7d497c2a97
4
+ data.tar.gz: 64e954f730dc260001a80540e6304aeed6c0838e6f003ea6c39dd166b93850ae
5
5
  SHA512:
6
- metadata.gz: 8312903e2476381ad7d9fe8a5a22feb74eb97937c5d488efc96448df9b0f5973f9d612c35cccd5235900e2eb25011694c862bb1a45a5bdeb70b2a8e17be155e2
7
- data.tar.gz: cc7b4336007a72b0d4131b311a2897d4dae9cf0b4ba18ceb4826f25931875f418067bd5a16e6d1fec472cbb29abd7d1f2d2f0b58cc943b871fdea4e9991fa623
6
+ metadata.gz: 39a182c18271764a2258a2d5f416cd1c44dba5c1d18c11b74fd2ab6025d36569cf28778320cf66cb2c935f501e3310f8fdae7b74e550b6bdf24f9e28e2680c83
7
+ data.tar.gz: 85297df8068df3aa88d2a69be379d8139850b82841cb98f90899e418fdff93cd360ea54f5c94a0d6722ab81b315cc3805c13bb66645d7f64a58e9f9dff6e826c
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.14] - 2026-05-01
11
+
12
+ ### Fixed (false-positive reduction pass)
13
+
14
+ - **Pagy / multi-assignment preload tracking** — `LoopAssociation` and `CustomMethodQuery` now follow preloads through `@pagy, items = pagy(query)` patterns and through wrapper calls like `pagy(...)`, `paginate(...)`, etc. Previously the `.includes(...)` on the wrapped query was lost across the multi-assign, generating false N+1 warnings on every preloaded association.
15
+ - **Preloads through conditional / variable chains** — `extract_included_associations_deep` now walks ternary branches and inlined begin-blocks, and resolves `:lvar`/`:ivar` receivers via the variable preload map. `base = cond ? Q.includes(...).where(...) : Q.none` no longer drops preloads.
16
+ - **Per-model association scoping** — `LoopAssociation` now infers the model class of the iteration variable and, when known, only flags methods that are actually associations on that model. This eliminates false positives where column accessors collide with hardcoded names (e.g. `log.tag`, `log.image`) or with associations defined on unrelated models.
17
+ - **Per-model `method_queries` lookup** — `LoopAssociation` and `CustomMethodQuery` no longer flag `obj.foo` just because *some other* model defines a `def foo` containing a query. The match is scoped to the receiver's model when known, and association names on that model are excluded from the query-method check.
18
+ - **`update_all` / `delete_all` / `destroy_all` chains** — `LoopAssociation` no longer flags association access whose only purpose is a non-loading terminal write (e.g. `avm.merchant_branches.update_all(...)`), since these don't trigger a SELECT for the association.
19
+ - **`PluckToArray` `.where(...).all.pluck(:id)` mis-classification** — only an *unscoped* `.all.pluck(:id)` is escalated to error severity ("loads entire table"); chains scoped by `where`/`joins`/`limit`/etc. are no longer misreported as table-scans.
20
+
21
+ ### Changed
22
+
23
+ - `AssociationParser` now exposes `associations_by_model` (a per-model `Set` of association names). Models with no declared associations are still registered, so detectors can distinguish "no associations" from "model unknown".
24
+ - Detector signatures: `LoopAssociation#detect` and `CustomMethodQuery#detect` accept an `associations_by_model` argument (defaults to `{}`).
25
+
26
+ ## [1.2.13] - 2026-03-23
27
+
28
+ ### Fixed
29
+
30
+ - Fixed gem packaging issue where `CountToSize` and `AddIncludes` auto-fixer files were missing from the published gem
31
+
10
32
  ## [1.2.12] - 2026-03-20
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.12-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.14-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>
@@ -19,24 +19,25 @@ module EagerEye
19
19
  }.freeze
20
20
 
21
21
  DETECTOR_EXTRA_ARGS = {
22
- Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
22
+ Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model],
23
23
  Detectors::SerializerNesting => %i[association_names method_queries],
24
24
  Detectors::MissingCounterCache => %i[association_names],
25
25
  Detectors::DecoratorNPlusOne => %i[association_names method_queries],
26
- Detectors::CustomMethodQuery => %i[method_queries],
26
+ Detectors::CustomMethodQuery => %i[method_queries associations_by_model],
27
27
  Detectors::DelegationNPlusOne => %i[delegation_maps],
28
28
  Detectors::ScopeChainNPlusOne => %i[scope_maps],
29
29
  Detectors::ValidationNPlusOne => %i[uniqueness_models]
30
30
  }.freeze
31
31
 
32
32
  attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
33
- :scope_maps, :uniqueness_models
33
+ :scope_maps, :uniqueness_models, :associations_by_model
34
34
 
35
35
  def initialize(paths: nil)
36
36
  @paths = Array(paths || EagerEye.configuration.app_path)
37
37
  @issues = []
38
38
  @association_preloads = {}
39
39
  @association_names = Set.new
40
+ @associations_by_model = {}
40
41
  @method_queries = {}
41
42
  @delegation_maps = {}
42
43
  @scope_maps = {}
@@ -63,6 +64,9 @@ module EagerEye
63
64
  assoc_parser.parse_model(ast, model_name)
64
65
  @association_preloads.merge!(assoc_parser.preloaded_associations)
65
66
  @association_names.merge(assoc_parser.association_names)
67
+ assoc_parser.associations_by_model.each do |m, set|
68
+ (@associations_by_model[m] ||= Set.new).merge(set)
69
+ end
66
70
 
67
71
  deleg_parser = DelegationParser.new
68
72
  deleg_parser.parse_model(ast, model_name)
@@ -4,16 +4,20 @@ 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, :association_names
7
+ attr_reader :preloaded_associations, :association_names, :associations_by_model
8
8
 
9
9
  def initialize
10
10
  @preloaded_associations = {}
11
11
  @association_names = Set.new
12
+ @associations_by_model = {}
12
13
  end
13
14
 
14
15
  def parse_model(ast, model_name)
15
16
  return unless ast
16
17
 
18
+ # Register the model even if it declares no associations, so callers can
19
+ # distinguish "model has no associations" from "model unknown to parser".
20
+ @associations_by_model[model_name] ||= Set.new
17
21
  traverse(ast, model_name)
18
22
  end
19
23
 
@@ -37,6 +41,7 @@ module EagerEye
37
41
  return unless association_name
38
42
 
39
43
  @association_names << association_name
44
+ (@associations_by_model[model_name] ||= Set.new) << association_name
40
45
 
41
46
  preloaded = extract_preloaded_associations(node)
42
47
  return if preloaded.empty?
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ module Concerns
6
+ # Tracks the inferred model class behind a local/instance variable, by
7
+ # walking AST value-nodes down to a `:const` (e.g. `User.where(...)` →
8
+ # `"User"`). Recurses into argument lists of relation-wrapper methods
9
+ # (pagy, paginate, ...) so that `pagy(User.includes(...))` is still
10
+ # recognized as a User-relation.
11
+ module VariableModelInference
12
+ # Methods whose first positional argument is the underlying relation
13
+ # (Pagy/Kaminari/etc.). Walking into those args lets us see through
14
+ # the wrapper to the source query.
15
+ RELATION_WRAPPERS = %i[pagy paginate page kaminari with_pagy].freeze
16
+
17
+ # LHS names in `@pagy, items = pagy(query)`-style assignments that
18
+ # should not inherit the relation's model — they hold pagination
19
+ # metadata, not records.
20
+ PAGINATION_META_NAMES = %i[pagy paginator meta page_info pagination].freeze
21
+
22
+ private
23
+
24
+ def infer_model_from_value(node, depth = 0) # rubocop:disable Metrics/CyclomaticComplexity
25
+ return nil if depth > 10 || !node.is_a?(Parser::AST::Node)
26
+
27
+ case node.type
28
+ when :const then node.children[1].to_s
29
+ when :send then infer_model_from_send(node, depth)
30
+ when :lvar, :ivar then variable_model_for(node)
31
+ when :if then infer_model_from_branches(node, depth)
32
+ when :begin then infer_model_from_first(node.children, depth)
33
+ end
34
+ end
35
+
36
+ def infer_model_from_send(node, depth)
37
+ method = node.children[1]
38
+ if RELATION_WRAPPERS.include?(method) && node.children[2]
39
+ from_arg = infer_model_from_value(node.children[2], depth + 1)
40
+ return from_arg if from_arg
41
+ end
42
+ infer_model_from_value(node.children[0], depth + 1)
43
+ end
44
+
45
+ def infer_model_from_branches(node, depth)
46
+ infer_model_from_value(node.children[1], depth + 1) ||
47
+ infer_model_from_value(node.children[2], depth + 1)
48
+ end
49
+
50
+ def infer_model_from_first(children, depth)
51
+ children.each do |child|
52
+ result = infer_model_from_value(child, depth + 1)
53
+ return result if result
54
+ end
55
+ nil
56
+ end
57
+
58
+ def variable_model_for(node)
59
+ @variable_models&.[]([node.type == :ivar ? :ivar : :lvar, node.children[0]])
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/variable_model_inference"
4
+
3
5
  module EagerEye
4
6
  module Detectors
5
7
  class CustomMethodQuery < Base
8
+ include Concerns::VariableModelInference
9
+
6
10
  QUERY_METHODS = %i[where find_by find_by! exists? find first last take pluck ids count sum average minimum
7
11
  maximum].freeze
8
12
  SAFE_QUERY_METHODS = %i[first last take count sum find size length ids].freeze
@@ -16,16 +20,19 @@ module EagerEye
16
20
  :custom_method_query
17
21
  end
18
22
 
19
- def detect(ast, file_path, method_queries = {})
23
+ def detect(ast, file_path, method_queries = {}, associations_by_model = {})
20
24
  return [] unless ast
21
25
 
22
26
  @issues = []
23
27
  @file_path = file_path
24
28
  @method_queries = method_queries
29
+ @associations_by_model = associations_by_model
30
+ @variable_models = {}
25
31
 
26
32
  find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
33
+ model_name = infer_model_from_value(collection)
27
34
  check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
28
- check_block_for_model_query_methods(block_body, block_var)
35
+ check_block_for_model_query_methods(block_body, block_var, model_name)
29
36
  end
30
37
 
31
38
  @issues
@@ -36,7 +43,7 @@ module EagerEye
36
43
  def find_iteration_blocks(node, definitions = {}, &block)
37
44
  return unless node.is_a?(Parser::AST::Node)
38
45
 
39
- definitions[node.children[0]] = node.children[1] if node.type == :lvasgn
46
+ record_definition(node, definitions)
40
47
 
41
48
  if iteration_block?(node)
42
49
  block_var = extract_iteration_variable(node)
@@ -46,6 +53,43 @@ module EagerEye
46
53
  node.children.each { |child| find_iteration_blocks(child, definitions, &block) }
47
54
  end
48
55
 
56
+ def record_definition(node, definitions)
57
+ case node.type
58
+ when :lvasgn then record_simple_definition(node, :lvar, definitions)
59
+ when :ivasgn then record_simple_definition(node, :ivar, nil)
60
+ when :masgn then record_multi_definition(node, definitions)
61
+ end
62
+ end
63
+
64
+ def record_simple_definition(node, var_type, definitions)
65
+ name = node.children[0]
66
+ value = node.children[1]
67
+ return unless name && value
68
+
69
+ definitions[name] = value if definitions
70
+ model = infer_model_from_value(value)
71
+ @variable_models[[var_type, name]] = model if model
72
+ end
73
+
74
+ def record_multi_definition(node, definitions)
75
+ mlhs, rhs = node.children
76
+ return unless mlhs && rhs
77
+
78
+ model = infer_model_from_value(rhs)
79
+ mlhs.children.each { |target| record_multi_target(target, rhs, model, definitions) }
80
+ end
81
+
82
+ def record_multi_target(target, rhs, model, definitions)
83
+ return unless %i[lvasgn ivasgn].include?(target&.type)
84
+
85
+ tname = target.children[0]
86
+ return if PAGINATION_META_NAMES.include?(tname)
87
+
88
+ var_type = target.type == :lvasgn ? :lvar : :ivar
89
+ @variable_models[[var_type, tname]] = model if model
90
+ definitions[tname] = rhs if target.type == :lvasgn
91
+ end
92
+
49
93
  def iteration_block?(node)
50
94
  node.type == :block && node.children[0]&.type == :send &&
51
95
  ITERATION_METHODS.include?(node.children[0].children[1])
@@ -115,10 +159,10 @@ module EagerEye
115
159
  node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
116
160
  end
117
161
 
118
- def check_block_for_model_query_methods(node, block_var)
162
+ def check_block_for_model_query_methods(node, block_var, model_name)
119
163
  return unless node.is_a?(Parser::AST::Node)
120
164
 
121
- if model_query_call?(node, block_var)
165
+ if model_query_call?(node, block_var, model_name)
122
166
  method = node.children[1]
123
167
  @issues << create_issue(
124
168
  file_path: @file_path,
@@ -127,17 +171,38 @@ module EagerEye
127
171
  suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
128
172
  )
129
173
  end
130
- node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
174
+ node.children.each { |child| check_block_for_model_query_methods(child, block_var, model_name) }
131
175
  end
132
176
 
133
- def model_query_call?(node, block_var)
134
- return false unless node.type == :send
177
+ def model_query_call?(node, block_var, model_name)
178
+ return false unless block_var_immediate_send?(node, block_var)
135
179
 
136
- receiver = node.children[0]
137
180
  method = node.children[1]
138
- return false unless receiver&.type == :lvar && receiver.children[0] == block_var
181
+ # If we know the receiver model AND the method is one of its
182
+ # associations, treat it as an association access — not a query method.
183
+ return false if association_on_model?(method, model_name)
184
+
185
+ method_defined_as_query?(method, model_name)
186
+ end
139
187
 
140
- @method_queries&.any? { |_model, methods| methods.include?(method) }
188
+ def block_var_immediate_send?(node, block_var)
189
+ node.type == :send &&
190
+ (receiver = node.children[0])&.type == :lvar &&
191
+ receiver.children[0] == block_var
192
+ end
193
+
194
+ def association_on_model?(method, model_name)
195
+ model_name && @associations_by_model&.dig(model_name)&.include?(method)
196
+ end
197
+
198
+ def method_defined_as_query?(method, model_name)
199
+ return false unless @method_queries
200
+
201
+ if model_name
202
+ @method_queries[model_name]&.include?(method) || false
203
+ else
204
+ @method_queries.any? { |_model, methods| methods.include?(method) }
205
+ end
141
206
  end
142
207
 
143
208
  def add_issue(node)
@@ -1,21 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/variable_model_inference"
4
+
3
5
  module EagerEye
4
6
  module Detectors
5
- class LoopAssociation < Base
7
+ class LoopAssociation < Base # rubocop:disable Metrics/ClassLength
8
+ include Concerns::VariableModelInference
9
+
6
10
  ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map
7
11
  each_with_index each_with_object reduce inject
8
12
  find_each find_in_batches in_batches array!].freeze
9
13
  PRELOAD_METHODS = %i[includes preload eager_load].freeze
10
14
  SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
11
15
  forty_two sole find_sole_by].freeze
16
+ # Terminal methods on an association that do NOT trigger a SELECT for
17
+ # loading the association — they translate directly to UPDATE/DELETE SQL
18
+ # against the association's foreign key.
19
+ NON_LOADING_TERMINAL_METHODS = %i[update_all delete_all destroy_all touch_all
20
+ increment_counter decrement_counter].freeze
12
21
  ASSOCIATION_NAMES = Set.new(%w[
13
- author user owner creator admin member customer client post article comment category tag
14
- parent company organization project task item order product account profile setting image
15
- avatar photo attachment document authors users owners creators admins members customers
16
- clients posts articles comments categories tags children companies organizations projects
17
- tasks items orders products accounts profiles settings images avatars photos attachments
18
- documents
22
+ author user owner creator admin member customer client post article comment category
23
+ parent company organization project task item order product account profile
24
+ avatar photo authors users owners creators admins members customers
25
+ clients posts articles comments categories children companies organizations projects
26
+ tasks items orders products accounts profiles avatars photos
19
27
  ]).freeze
20
28
  EXCLUDED_METHODS = %i[
21
29
  id to_s to_h to_a to_json to_xml inspect class object_id nil? blank? present? empty?
@@ -24,20 +32,22 @@ module EagerEye
24
32
  description value key type status state created_at updated_at deleted_at origin
25
33
  priority level kind label code reason amount price quantity url path email phone
26
34
  address notes memo data metadata position rank score rating enabled disabled active
27
- published draft archived locked visible hidden
35
+ published draft archived locked visible hidden tag image attachment document setting
28
36
  ].freeze
29
37
 
30
38
  def self.detector_name
31
39
  :loop_association
32
40
  end
33
41
 
34
- def detect(ast, file_path, association_preloads = {}, association_names = Set.new, method_queries = {})
42
+ def detect(ast, file_path, association_preloads = {}, association_names = Set.new, # rubocop:disable Metrics/ParameterLists
43
+ method_queries = {}, associations_by_model = {})
35
44
  return [] unless ast
36
45
 
37
46
  issues = []
38
47
  @association_preloads = association_preloads
39
48
  @dynamic_associations = association_names
40
49
  @method_queries = method_queries
50
+ @associations_by_model = associations_by_model
41
51
  build_variable_maps(ast)
42
52
 
43
53
  traverse_ast(ast) do |node|
@@ -52,11 +62,11 @@ module EagerEye
52
62
  collection_node = node.children[0]
53
63
  next if single_record_iteration?(collection_node)
54
64
 
55
- included = extract_included_associations(collection_node)
56
- included.merge(extract_variable_preloads(collection_node))
57
- included.merge(get_association_preloads(infer_model_name_from_collection(collection_node)))
65
+ included = collect_included_associations(collection_node)
66
+ model_name = infer_model_from_value(collection_node)
67
+ skip_nodes = collect_non_loading_skip_set(block_body)
58
68
 
59
- find_association_calls(block_body, block_var, file_path, issues, included)
69
+ find_association_calls(block_body, block_var, file_path, issues, included, model_name, skip_nodes)
60
70
  end
61
71
 
62
72
  issues
@@ -64,56 +74,127 @@ module EagerEye
64
74
 
65
75
  private
66
76
 
77
+ def collect_included_associations(collection_node)
78
+ included = extract_included_associations_deep(collection_node)
79
+ included.merge(extract_variable_preloads(collection_node))
80
+ included.merge(get_association_preloads(infer_model_from_value(collection_node)))
81
+ included
82
+ end
83
+
67
84
  def get_association_preloads(model_name)
68
85
  preloaded = Set.new
86
+ return preloaded unless model_name
87
+
69
88
  @association_preloads&.each do |key, assocs|
70
89
  preloaded.merge(assocs) if key.start_with?("#{model_name}#")
71
90
  end
72
91
  preloaded
73
92
  end
74
93
 
75
- def infer_model_name_from_collection(node)
76
- return nil unless node&.type == :send
77
-
78
- receiver = node.children[0]
79
- receiver.children[1].to_s if receiver&.type == :const
80
- end
81
-
82
94
  def iteration_block?(node)
83
95
  node.type == :block && node.children[0]&.type == :send &&
84
96
  ITERATION_METHODS.include?(node.children[0].children[1])
85
97
  end
86
98
 
87
- def extract_included_associations(collection_node)
99
+ # Extract :includes/:preload/:eager_load arguments from a value node,
100
+ # walking BOTH the receiver chain and any method arguments. The arg
101
+ # recursion is what lets us see through wrappers like pagy(query, ...).
102
+ def extract_included_associations_deep(value_node, depth = 0)
88
103
  included = Set.new
89
- return included unless collection_node&.type == :send
104
+ return included if depth > 10 || !value_node.is_a?(Parser::AST::Node)
90
105
 
91
- current = collection_node
92
- while current&.type == :send
106
+ current = walk_send_chain_for_preloads(value_node, included, depth)
107
+ merge_preloads_from_non_send(current, included, depth)
108
+ included
109
+ end
110
+
111
+ def walk_send_chain_for_preloads(node, included, depth)
112
+ current = node
113
+ while current.is_a?(Parser::AST::Node) && current.type == :send
93
114
  extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(current.children[1])
115
+ current.children[2..].each do |arg|
116
+ included.merge(extract_included_associations_deep(arg, depth + 1))
117
+ end
94
118
  current = current.children[0]
95
119
  end
96
- included
120
+ current
121
+ end
122
+
123
+ def merge_preloads_from_non_send(node, included, depth)
124
+ case node&.type
125
+ when :if then merge_branch_preloads(node, included, depth)
126
+ when :begin then node.children.each do |c|
127
+ included.merge(extract_included_associations_deep(c, depth + 1))
128
+ end
129
+ when :lvar, :ivar then merge_variable_preloads(node, included)
130
+ end
131
+ end
132
+
133
+ def merge_branch_preloads(node, included, depth)
134
+ included.merge(extract_included_associations_deep(node.children[1], depth + 1))
135
+ included.merge(extract_included_associations_deep(node.children[2], depth + 1))
136
+ end
137
+
138
+ def merge_variable_preloads(node, included)
139
+ key = [node.type == :ivar ? :ivar : :lvar, node.children[0]]
140
+ included.merge(@variable_preloads[key]) if @variable_preloads&.key?(key)
97
141
  end
98
142
 
99
143
  def build_variable_maps(ast)
100
144
  @variable_preloads = {}
145
+ @variable_models = {}
101
146
  @single_record_variables = Set.new
102
147
 
103
148
  traverse_ast(ast) { |node| process_variable_assignment(node) }
104
149
  end
105
150
 
106
151
  def process_variable_assignment(node)
107
- return unless %i[lvasgn ivasgn].include?(node.type)
152
+ case node.type
153
+ when :lvasgn, :ivasgn then process_simple_assignment(node)
154
+ when :masgn then process_multi_assignment(node)
155
+ end
156
+ end
108
157
 
158
+ def process_simple_assignment(node)
109
159
  var_type = node.type == :lvasgn ? :lvar : :ivar
110
160
  var_name = node.children[0]
111
161
  value_node = node.children[1]
112
- return unless value_node
162
+ return unless value_node && var_name
163
+
164
+ record_variable(var_type, var_name, value_node)
165
+ end
166
+
167
+ def process_multi_assignment(node)
168
+ mlhs, rhs = node.children
169
+ return unless mlhs && rhs
170
+
171
+ # mlhs.children is a list of lvasgn/ivasgn (LHS targets). RHS is
172
+ # typically a method call like `pagy(query)` returning [meta, records],
173
+ # or an array literal. We can't statically know which target gets which
174
+ # slot, so apply preloads/model to every LHS — except names that look
175
+ # like pagination metadata.
176
+ mlhs.children.each { |target| record_multi_target(target, rhs) }
177
+ end
178
+
179
+ def record_multi_target(target, rhs)
180
+ return unless %i[lvasgn ivasgn].include?(target&.type)
113
181
 
182
+ name = target.children[0]
183
+ return if PAGINATION_META_NAMES.include?(name)
184
+
185
+ var_type = target.type == :lvasgn ? :lvar : :ivar
186
+ record_variable(var_type, name, rhs)
187
+ end
188
+
189
+ def record_variable(var_type, var_name, value_node)
114
190
  key = [var_type, var_name]
115
- preloaded = extract_included_associations(value_node)
191
+
192
+ preloaded = extract_included_associations_deep(value_node)
116
193
  @variable_preloads[key] = preloaded unless preloaded.empty?
194
+
195
+ model = infer_model_from_value(value_node)
196
+ @variable_models[key] = model if model
197
+
117
198
  @single_record_variables.add(key) if single_record_query?(value_node)
118
199
  end
119
200
 
@@ -156,10 +237,33 @@ module EagerEye
156
237
  included_set.merge(extract_symbols_from_args(extract_method_args(method_node)))
157
238
  end
158
239
 
159
- def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
240
+ # Pre-scan: when an inner send like `block_var.assoc` is the receiver of a
241
+ # NON_LOADING_TERMINAL_METHODS call (e.g. `.update_all`), the assoc access
242
+ # does not trigger a SELECT. Track those receiver nodes so we don't flag them.
243
+ def collect_non_loading_skip_set(block_body)
244
+ skip = Set.new
245
+ traverse_ast(block_body) do |node|
246
+ next unless node.type == :send && NON_LOADING_TERMINAL_METHODS.include?(node.children[1])
247
+
248
+ receiver = node.children[0]
249
+ collect_chain_node_ids(receiver, skip)
250
+ end
251
+ skip
252
+ end
253
+
254
+ def collect_chain_node_ids(node, set)
255
+ return unless node.is_a?(Parser::AST::Node) && node.type == :send
256
+
257
+ set.add(node.object_id)
258
+ collect_chain_node_ids(node.children[0], set)
259
+ end
260
+
261
+ def find_association_calls(node, block_var, file_path, issues, included_associations, model_name, skip_nodes) # rubocop:disable Metrics/ParameterLists
160
262
  reported = Set.new
161
263
  traverse_ast(node) do |child|
162
- if reportable_association_call?(child, block_var, reported, included_associations)
264
+ next if skip_nodes.include?(child.object_id)
265
+
266
+ if reportable_association_call?(child, block_var, reported, included_associations, model_name)
163
267
  method = child.children[1]
164
268
  issues << create_issue(
165
269
  file_path: file_path,
@@ -167,7 +271,7 @@ module EagerEye
167
271
  message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
168
272
  suggestion: "Use `includes(:#{method})` before iterating"
169
273
  )
170
- elsif reportable_method_query_call?(child, block_var, reported)
274
+ elsif reportable_method_query_call?(child, block_var, reported, model_name)
171
275
  method = child.children[1]
172
276
  issues << create_issue(
173
277
  file_path: file_path,
@@ -179,35 +283,56 @@ module EagerEye
179
283
  end
180
284
  end
181
285
 
182
- def reportable_association_call?(node, block_var, reported, included)
286
+ def reportable_association_call?(node, block_var, reported, included, model_name)
183
287
  return false unless node.type == :send
184
288
 
185
289
  receiver = node.children[0]
186
290
  method = node.children[1]
187
291
  return false unless receiver&.type == :lvar && receiver.children[0] == block_var
188
- return false if excluded_method?(method, included)
292
+ return false if excluded_method?(method, included, model_name)
189
293
 
190
294
  reported.add?("#{node.loc.line}:#{method}")
191
295
  end
192
296
 
193
- def excluded_method?(method, included)
297
+ def excluded_method?(method, included, model_name)
194
298
  EXCLUDED_METHODS.include?(method) ||
195
- !known_association?(method) ||
196
- included.include?(method)
299
+ included.include?(method) ||
300
+ !known_association?(method, model_name)
197
301
  end
198
302
 
199
- def known_association?(method)
303
+ # When we know the iteration variable's model AND have parsed that model's
304
+ # associations, trust that map exclusively — methods not in it are columns
305
+ # or scalar accessors, not associations. Otherwise fall back to heuristics
306
+ # (hardcoded common names + globally collected association names).
307
+ def known_association?(method, model_name)
308
+ if model_name && @associations_by_model&.key?(model_name)
309
+ return @associations_by_model[model_name].include?(method)
310
+ end
311
+
200
312
  ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
201
313
  end
202
314
 
203
- def reportable_method_query_call?(node, block_var, reported)
315
+ def reportable_method_query_call?(node, block_var, reported, model_name)
204
316
  return false unless block_var_send?(node, block_var)
205
317
  return false if EXCLUDED_METHODS.include?(node.children[1])
206
- return false unless @method_queries&.any? { |_, ms| ms.include?(node.children[1]) }
318
+ return false unless method_known_to_query?(node.children[1], model_name)
207
319
 
208
320
  reported.add?("#{node.loc.line}:#{node.children[1]}")
209
321
  end
210
322
 
323
+ # When the receiver model is known, only consider methods defined as a
324
+ # query on THAT model — not any model in the project. Without a known
325
+ # model, fall back to the global "any model has this method" heuristic.
326
+ def method_known_to_query?(method, model_name)
327
+ return false unless @method_queries
328
+
329
+ if model_name
330
+ @method_queries[model_name]&.include?(method) || false
331
+ else
332
+ @method_queries.any? { |_, ms| ms.include?(method) }
333
+ end
334
+ end
335
+
211
336
  def block_var_send?(node, block_var)
212
337
  node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
213
338
  end
@@ -4,10 +4,14 @@ require_relative "concerns/non_ar_source_detector"
4
4
 
5
5
  module EagerEye
6
6
  module Detectors
7
- class PluckToArray < Base
7
+ class PluckToArray < Base # rubocop:disable Metrics/ClassLength
8
8
  include Concerns::NonArSourceDetector
9
9
 
10
10
  SMALL_COLLECTIONS = %w[tags settings options categories roles permissions statuses types priorities].freeze
11
+ SCOPING_METHODS = %i[where not limit offset order group having distinct
12
+ joins left_joins left_outer_joins includes preload eager_load
13
+ lock select from references unscope merge or rewhere reorder regroup
14
+ in_order_of].freeze
11
15
 
12
16
  def self.detector_name
13
17
  :pluck_to_array
@@ -117,9 +121,31 @@ module EagerEye
117
121
  node.is_a?(Parser::AST::Node) && node.type == :send && %i[pluck ids].include?(node.children[1])
118
122
  end
119
123
 
124
+ # `.all.pluck(:id)` is "critical" only when the `.all` is unscoped — i.e.
125
+ # there's no `.where`/`.limit`/etc. earlier in the chain. Otherwise the
126
+ # query is already filtered and isn't loading the entire table.
120
127
  def all_pluck_call?(node)
121
- pluck_call?(node) && node.children[0].is_a?(Parser::AST::Node) &&
122
- node.children[0].type == :send && node.children[0].children[1] == :all
128
+ return false unless pluck_call?(node)
129
+
130
+ receiver = node.children[0]
131
+ return false unless receiver.is_a?(Parser::AST::Node) && receiver.type == :send
132
+ return false unless receiver.children[1] == :all
133
+
134
+ unscoped_all?(receiver)
135
+ end
136
+
137
+ def unscoped_all?(all_node)
138
+ receiver = all_node.children[0]
139
+ # `.all` with no receiver (just `all` in a model context) — treat as unscoped.
140
+ return true if receiver.nil?
141
+
142
+ while receiver.is_a?(Parser::AST::Node) && receiver.type == :send
143
+ return false if SCOPING_METHODS.include?(receiver.children[1])
144
+
145
+ receiver = receiver.children[0]
146
+ end
147
+
148
+ true
123
149
  end
124
150
 
125
151
  def small_collection_pluck?(node)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.13"
4
+ VERSION = "1.2.14"
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.13
4
+ version: 1.2.14
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-23 00:00:00.000000000 Z
11
+ date: 2026-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -69,6 +69,7 @@ files:
69
69
  - lib/eager_eye/detectors/callback_query.rb
70
70
  - lib/eager_eye/detectors/concerns/class_inspector.rb
71
71
  - lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
72
+ - lib/eager_eye/detectors/concerns/variable_model_inference.rb
72
73
  - lib/eager_eye/detectors/count_in_iteration.rb
73
74
  - lib/eager_eye/detectors/custom_method_query.rb
74
75
  - lib/eager_eye/detectors/decorator_n_plus_one.rb