eager_eye 1.2.13 → 1.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +1 -1
- data/lib/eager_eye/analyzer.rb +7 -3
- data/lib/eager_eye/association_parser.rb +6 -1
- data/lib/eager_eye/detectors/concerns/variable_model_inference.rb +64 -0
- data/lib/eager_eye/detectors/custom_method_query.rb +232 -19
- data/lib/eager_eye/detectors/loop_association.rb +397 -60
- data/lib/eager_eye/detectors/pluck_to_array.rb +29 -3
- data/lib/eager_eye/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84a1f7a4156669772686b2138d5015bd82e5859ec09d5b1cce55b795a896c8fe
|
|
4
|
+
data.tar.gz: bc45520cd998ab972ee86c6654cd6ec111846eb417512eae4d588ace8135cab0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3a621919af0c95f005ba215d6db440504fa235e33ede88326ad1813f826f851e77bf20a7066200df993a18bc535c024142cc79930aab714b7db80995f2578f4
|
|
7
|
+
data.tar.gz: 86702c9c9ad9b27dcd4fb0a581bdab8864f7eda953b983b4bb7c3a07e6d5f581f39544c2ddfdc0fdb3f4b7c71d84be73df608d02302df7e1db51e4c4eaa4294e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.15] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Per-method scope for variable preload tracking** — `LoopAssociation` and
|
|
15
|
+
`CustomMethodQuery` now process each `:def`/`:defs` body as an independent
|
|
16
|
+
scope. Previously a later assignment in another method (e.g.
|
|
17
|
+
`invoices = Invoice.includes(:invoice_items).where(...)` inside one action)
|
|
18
|
+
could overwrite the preload set for the same variable name in an earlier
|
|
19
|
+
action, producing false N+1 warnings on associations that were actually
|
|
20
|
+
preloaded. Each scope now inherits a snapshot from the enclosing scope but
|
|
21
|
+
its own writes do not leak back out.
|
|
22
|
+
- **Caller-method preload tracking** — `LoopAssociation` and
|
|
23
|
+
`CustomMethodQuery` now propagate preload/model context across method calls
|
|
24
|
+
within the same class. When `def index` does
|
|
25
|
+
`items = Foo.includes(:bar); prepare_data(items)` and `def prepare_data(items)`
|
|
26
|
+
iterates the parameter, the `:bar` association is now correctly recognized
|
|
27
|
+
as preloaded. Previously every helper method that received a relation by
|
|
28
|
+
parameter produced false positives because the parameter had no preload
|
|
29
|
+
context.
|
|
30
|
+
- Sibling defs in the same scope are processed in two passes: first to capture
|
|
31
|
+
each `self`-call's argument context, then to seed the callee's parameters
|
|
32
|
+
before analyzing its body. Multiple call sites are merged permissively (any
|
|
33
|
+
caller preloading suppresses the warning) to avoid false positives at the
|
|
34
|
+
expense of potentially missing helpers that have BOTH preloaded and
|
|
35
|
+
unpreloaded callers.
|
|
36
|
+
|
|
37
|
+
## [1.2.14] - 2026-05-01
|
|
38
|
+
|
|
39
|
+
### Fixed (false-positive reduction pass)
|
|
40
|
+
|
|
41
|
+
- **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.
|
|
42
|
+
- **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.
|
|
43
|
+
- **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.
|
|
44
|
+
- **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.
|
|
45
|
+
- **`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.
|
|
46
|
+
- **`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.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- `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".
|
|
51
|
+
- Detector signatures: `LoopAssociation#detect` and `CustomMethodQuery#detect` accept an `associations_by_model` argument (defaults to `{}`).
|
|
52
|
+
|
|
53
|
+
## [1.2.13] - 2026-03-23
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- Fixed gem packaging issue where `CountToSize` and `AddIncludes` auto-fixer files were missing from the published gem
|
|
58
|
+
|
|
10
59
|
## [1.2.12] - 2026-03-20
|
|
11
60
|
|
|
12
61
|
### Added
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml"><img src="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
13
|
-
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.
|
|
13
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.15-red.svg" alt="Gem Version"></a>
|
|
14
14
|
<a href="https://github.com/hamzagedikkaya/eager_eye"><img src="https://img.shields.io/badge/coverage-95%25-brightgreen.svg" alt="Coverage"></a>
|
|
15
15
|
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby"></a>
|
|
16
16
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -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
|
-
class CustomMethodQuery < Base
|
|
7
|
+
class CustomMethodQuery < Base # rubocop:disable Metrics/ClassLength
|
|
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,34 +20,222 @@ 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
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
# Process each method def as its own scope so variable models from one
|
|
32
|
+
# method don't bleed into another (e.g. `orders = Order.all` in #index
|
|
33
|
+
# vs `orders = Foo.where(...).all` in #report — global tracking would
|
|
34
|
+
# mis-attribute the iteration variable's class across scopes).
|
|
35
|
+
process_scope(ast, {})
|
|
30
36
|
|
|
31
37
|
@issues
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
private
|
|
35
41
|
|
|
36
|
-
def
|
|
42
|
+
def process_scope(scope_node, definitions)
|
|
43
|
+
@variable_models ||= {}
|
|
44
|
+
scope_body = scope_body_for(scope_node)
|
|
45
|
+
return unless scope_body
|
|
46
|
+
|
|
47
|
+
find_iteration_blocks_in_scope(scope_body, definitions)
|
|
48
|
+
|
|
49
|
+
process_nested_defs(scope_body, definitions)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def process_nested_defs(scope_body, definitions)
|
|
53
|
+
nested_defs = []
|
|
54
|
+
each_nested_def(scope_body) { |d| nested_defs << d }
|
|
55
|
+
return if nested_defs.empty?
|
|
56
|
+
|
|
57
|
+
call_sites_by_callee = collect_sibling_call_sites(nested_defs)
|
|
58
|
+
|
|
59
|
+
nested_defs.each do |nested_def|
|
|
60
|
+
with_scope_snapshot do
|
|
61
|
+
seed_params_from_callers(nested_def, call_sites_by_callee)
|
|
62
|
+
process_scope(nested_def, definitions.dup)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# See LoopAssociation#collect_sibling_call_sites for the rationale.
|
|
68
|
+
def collect_sibling_call_sites(nested_defs)
|
|
69
|
+
sibling_names = nested_defs.filter_map { |d| def_name(d) }.to_set
|
|
70
|
+
result = Hash.new { |h, k| h[k] = [] }
|
|
71
|
+
|
|
72
|
+
nested_defs.each do |def_node|
|
|
73
|
+
def_body = scope_body_for(def_node)
|
|
74
|
+
next unless def_body
|
|
75
|
+
|
|
76
|
+
with_scope_snapshot do
|
|
77
|
+
each_node_in_scope(def_body) do |node|
|
|
78
|
+
record_definition(node, {})
|
|
79
|
+
next unless self_send_to_sibling?(node, sibling_names)
|
|
80
|
+
|
|
81
|
+
result[node.children[1]] << {
|
|
82
|
+
args: node.children[2..],
|
|
83
|
+
models: @variable_models.dup
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self_send_to_sibling?(node, sibling_names)
|
|
93
|
+
return false unless node.type == :send
|
|
94
|
+
|
|
95
|
+
receiver = node.children[0]
|
|
96
|
+
return false unless receiver.nil? || (receiver.is_a?(Parser::AST::Node) && receiver.type == :self)
|
|
97
|
+
|
|
98
|
+
sibling_names.include?(node.children[1])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def def_name(def_node)
|
|
102
|
+
case def_node.type
|
|
103
|
+
when :def then def_node.children[0]
|
|
104
|
+
when :defs then def_node.children[1]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def seed_params_from_callers(def_node, call_sites_by_callee)
|
|
109
|
+
return unless def_node.type == :def
|
|
110
|
+
|
|
111
|
+
call_sites = call_sites_by_callee[def_name(def_node)]
|
|
112
|
+
return if call_sites.nil? || call_sites.empty?
|
|
113
|
+
|
|
114
|
+
extract_param_names(def_node).each_with_index do |param_name, idx|
|
|
115
|
+
model = first_arg_model(call_sites, idx)
|
|
116
|
+
@variable_models[[:lvar, param_name]] = model if model
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def first_arg_model(call_sites, idx)
|
|
121
|
+
call_sites.each do |site|
|
|
122
|
+
arg = site[:args][idx]
|
|
123
|
+
next unless arg
|
|
124
|
+
|
|
125
|
+
saved = @variable_models
|
|
126
|
+
@variable_models = site[:models]
|
|
127
|
+
model = infer_model_from_value(arg)
|
|
128
|
+
@variable_models = saved
|
|
129
|
+
return model if model
|
|
130
|
+
end
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_param_names(def_node)
|
|
135
|
+
args_node = def_node.children[1]
|
|
136
|
+
return [] unless args_node.is_a?(Parser::AST::Node) && args_node.type == :args
|
|
137
|
+
|
|
138
|
+
args_node.children.filter_map do |arg|
|
|
139
|
+
next unless arg.is_a?(Parser::AST::Node) && %i[arg optarg kwarg kwoptarg].include?(arg.type)
|
|
140
|
+
|
|
141
|
+
arg.children[0]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def scope_body_for(node)
|
|
146
|
+
return node unless node.is_a?(Parser::AST::Node)
|
|
147
|
+
|
|
148
|
+
case node.type
|
|
149
|
+
when :def then node.children[2]
|
|
150
|
+
when :defs then node.children[3]
|
|
151
|
+
else node
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def with_scope_snapshot
|
|
156
|
+
saved_models = @variable_models.dup
|
|
157
|
+
yield
|
|
158
|
+
ensure
|
|
159
|
+
@variable_models = saved_models
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def each_node_in_scope(node, &block)
|
|
37
163
|
return unless node.is_a?(Parser::AST::Node)
|
|
38
164
|
|
|
39
|
-
|
|
165
|
+
yield node
|
|
166
|
+
|
|
167
|
+
node.children.each do |child|
|
|
168
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
169
|
+
next if %i[def defs].include?(child.type)
|
|
170
|
+
|
|
171
|
+
each_node_in_scope(child, &block)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def each_nested_def(node, &block)
|
|
176
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
177
|
+
|
|
178
|
+
node.children.each do |child|
|
|
179
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
180
|
+
|
|
181
|
+
if %i[def defs].include?(child.type)
|
|
182
|
+
yield child
|
|
183
|
+
else
|
|
184
|
+
each_nested_def(child, &block)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def find_iteration_blocks_in_scope(scope_body, definitions)
|
|
190
|
+
each_node_in_scope(scope_body) do |node|
|
|
191
|
+
record_definition(node, definitions)
|
|
192
|
+
next unless iteration_block?(node)
|
|
40
193
|
|
|
41
|
-
if iteration_block?(node)
|
|
42
194
|
block_var = extract_iteration_variable(node)
|
|
43
195
|
block_body = node.children[2]
|
|
44
|
-
|
|
196
|
+
next unless block_var && block_body
|
|
197
|
+
|
|
198
|
+
model_name = infer_model_from_value(node.children[0])
|
|
199
|
+
check_block_for_query_methods(block_body, block_var, collection_is_array?(node.children[0], definitions))
|
|
200
|
+
check_block_for_model_query_methods(block_body, block_var, model_name)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def record_definition(node, definitions)
|
|
205
|
+
case node.type
|
|
206
|
+
when :lvasgn then record_simple_definition(node, :lvar, definitions)
|
|
207
|
+
when :ivasgn then record_simple_definition(node, :ivar, nil)
|
|
208
|
+
when :masgn then record_multi_definition(node, definitions)
|
|
45
209
|
end
|
|
46
|
-
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def record_simple_definition(node, var_type, definitions)
|
|
213
|
+
name = node.children[0]
|
|
214
|
+
value = node.children[1]
|
|
215
|
+
return unless name && value
|
|
216
|
+
|
|
217
|
+
definitions[name] = value if definitions
|
|
218
|
+
model = infer_model_from_value(value)
|
|
219
|
+
@variable_models[[var_type, name]] = model if model
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def record_multi_definition(node, definitions)
|
|
223
|
+
mlhs, rhs = node.children
|
|
224
|
+
return unless mlhs && rhs
|
|
225
|
+
|
|
226
|
+
model = infer_model_from_value(rhs)
|
|
227
|
+
mlhs.children.each { |target| record_multi_target(target, rhs, model, definitions) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def record_multi_target(target, rhs, model, definitions)
|
|
231
|
+
return unless %i[lvasgn ivasgn].include?(target&.type)
|
|
232
|
+
|
|
233
|
+
tname = target.children[0]
|
|
234
|
+
return if PAGINATION_META_NAMES.include?(tname)
|
|
235
|
+
|
|
236
|
+
var_type = target.type == :lvasgn ? :lvar : :ivar
|
|
237
|
+
@variable_models[[var_type, tname]] = model if model
|
|
238
|
+
definitions[tname] = rhs if target.type == :lvasgn
|
|
47
239
|
end
|
|
48
240
|
|
|
49
241
|
def iteration_block?(node)
|
|
@@ -115,10 +307,10 @@ module EagerEye
|
|
|
115
307
|
node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
|
|
116
308
|
end
|
|
117
309
|
|
|
118
|
-
def check_block_for_model_query_methods(node, block_var)
|
|
310
|
+
def check_block_for_model_query_methods(node, block_var, model_name)
|
|
119
311
|
return unless node.is_a?(Parser::AST::Node)
|
|
120
312
|
|
|
121
|
-
if model_query_call?(node, block_var)
|
|
313
|
+
if model_query_call?(node, block_var, model_name)
|
|
122
314
|
method = node.children[1]
|
|
123
315
|
@issues << create_issue(
|
|
124
316
|
file_path: @file_path,
|
|
@@ -127,17 +319,38 @@ module EagerEye
|
|
|
127
319
|
suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
|
|
128
320
|
)
|
|
129
321
|
end
|
|
130
|
-
node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
|
|
322
|
+
node.children.each { |child| check_block_for_model_query_methods(child, block_var, model_name) }
|
|
131
323
|
end
|
|
132
324
|
|
|
133
|
-
def model_query_call?(node, block_var)
|
|
134
|
-
return false unless node
|
|
325
|
+
def model_query_call?(node, block_var, model_name)
|
|
326
|
+
return false unless block_var_immediate_send?(node, block_var)
|
|
135
327
|
|
|
136
|
-
receiver = node.children[0]
|
|
137
328
|
method = node.children[1]
|
|
138
|
-
|
|
329
|
+
# If we know the receiver model AND the method is one of its
|
|
330
|
+
# associations, treat it as an association access — not a query method.
|
|
331
|
+
return false if association_on_model?(method, model_name)
|
|
332
|
+
|
|
333
|
+
method_defined_as_query?(method, model_name)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def block_var_immediate_send?(node, block_var)
|
|
337
|
+
node.type == :send &&
|
|
338
|
+
(receiver = node.children[0])&.type == :lvar &&
|
|
339
|
+
receiver.children[0] == block_var
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def association_on_model?(method, model_name)
|
|
343
|
+
model_name && @associations_by_model&.dig(model_name)&.include?(method)
|
|
344
|
+
end
|
|
139
345
|
|
|
140
|
-
|
|
346
|
+
def method_defined_as_query?(method, model_name)
|
|
347
|
+
return false unless @method_queries
|
|
348
|
+
|
|
349
|
+
if model_name
|
|
350
|
+
@method_queries[model_name]&.include?(method) || false
|
|
351
|
+
else
|
|
352
|
+
@method_queries.any? { |_model, methods| methods.include?(method) }
|
|
353
|
+
end
|
|
141
354
|
end
|
|
142
355
|
|
|
143
356
|
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
|
|
14
|
-
parent company organization project task item order product account profile
|
|
15
|
-
avatar photo
|
|
16
|
-
clients posts articles comments categories
|
|
17
|
-
tasks items orders products accounts profiles
|
|
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,96 +32,381 @@ 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,
|
|
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
|
-
issues = []
|
|
46
|
+
@issues = []
|
|
47
|
+
@file_path = file_path
|
|
38
48
|
@association_preloads = association_preloads
|
|
39
49
|
@dynamic_associations = association_names
|
|
40
50
|
@method_queries = method_queries
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
traverse_ast(ast) do |node|
|
|
44
|
-
next unless iteration_block?(node)
|
|
45
|
-
|
|
46
|
-
block_var = extract_iteration_variable(node)
|
|
47
|
-
next unless block_var
|
|
48
|
-
|
|
49
|
-
block_body = node.children[2]
|
|
50
|
-
next unless block_body
|
|
51
|
+
@associations_by_model = associations_by_model
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
# Variable preloads/models leak across methods if tracked globally
|
|
54
|
+
# (e.g. controller#index sets `invoices = Invoice.includes(...)`, then
|
|
55
|
+
# controller#auto_match overwrites with `invoices = Invoice.where(...)`
|
|
56
|
+
# — the second assignment would erase the first method's preload data).
|
|
57
|
+
# Process each method scope independently and inherit a snapshot from
|
|
58
|
+
# the enclosing scope (top-level / outer class body).
|
|
59
|
+
process_scope(ast)
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
included.merge(extract_variable_preloads(collection_node))
|
|
57
|
-
included.merge(get_association_preloads(infer_model_name_from_collection(collection_node)))
|
|
58
|
-
|
|
59
|
-
find_association_calls(block_body, block_var, file_path, issues, included)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
issues
|
|
61
|
+
@issues
|
|
63
62
|
end
|
|
64
63
|
|
|
65
64
|
private
|
|
66
65
|
|
|
66
|
+
def collect_included_associations(collection_node)
|
|
67
|
+
included = extract_included_associations_deep(collection_node)
|
|
68
|
+
included.merge(extract_variable_preloads(collection_node))
|
|
69
|
+
included.merge(get_association_preloads(infer_model_from_value(collection_node)))
|
|
70
|
+
included
|
|
71
|
+
end
|
|
72
|
+
|
|
67
73
|
def get_association_preloads(model_name)
|
|
68
74
|
preloaded = Set.new
|
|
75
|
+
return preloaded unless model_name
|
|
76
|
+
|
|
69
77
|
@association_preloads&.each do |key, assocs|
|
|
70
78
|
preloaded.merge(assocs) if key.start_with?("#{model_name}#")
|
|
71
79
|
end
|
|
72
80
|
preloaded
|
|
73
81
|
end
|
|
74
82
|
|
|
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
83
|
def iteration_block?(node)
|
|
83
84
|
node.type == :block && node.children[0]&.type == :send &&
|
|
84
85
|
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
# Extract :includes/:preload/:eager_load arguments from a value node,
|
|
89
|
+
# walking BOTH the receiver chain and any method arguments. The arg
|
|
90
|
+
# recursion is what lets us see through wrappers like pagy(query, ...).
|
|
91
|
+
def extract_included_associations_deep(value_node, depth = 0)
|
|
88
92
|
included = Set.new
|
|
89
|
-
return included
|
|
93
|
+
return included if depth > 10 || !value_node.is_a?(Parser::AST::Node)
|
|
90
94
|
|
|
91
|
-
current =
|
|
92
|
-
|
|
95
|
+
current = walk_send_chain_for_preloads(value_node, included, depth)
|
|
96
|
+
merge_preloads_from_non_send(current, included, depth)
|
|
97
|
+
included
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def walk_send_chain_for_preloads(node, included, depth)
|
|
101
|
+
current = node
|
|
102
|
+
while current.is_a?(Parser::AST::Node) && current.type == :send
|
|
93
103
|
extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(current.children[1])
|
|
104
|
+
current.children[2..].each do |arg|
|
|
105
|
+
included.merge(extract_included_associations_deep(arg, depth + 1))
|
|
106
|
+
end
|
|
94
107
|
current = current.children[0]
|
|
95
108
|
end
|
|
96
|
-
|
|
109
|
+
current
|
|
97
110
|
end
|
|
98
111
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
def merge_preloads_from_non_send(node, included, depth)
|
|
113
|
+
case node&.type
|
|
114
|
+
when :if then merge_branch_preloads(node, included, depth)
|
|
115
|
+
when :begin then node.children.each do |c|
|
|
116
|
+
included.merge(extract_included_associations_deep(c, depth + 1))
|
|
117
|
+
end
|
|
118
|
+
when :lvar, :ivar then merge_variable_preloads(node, included)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def merge_branch_preloads(node, included, depth)
|
|
123
|
+
included.merge(extract_included_associations_deep(node.children[1], depth + 1))
|
|
124
|
+
included.merge(extract_included_associations_deep(node.children[2], depth + 1))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def merge_variable_preloads(node, included)
|
|
128
|
+
key = [node.type == :ivar ? :ivar : :lvar, node.children[0]]
|
|
129
|
+
included.merge(@variable_preloads[key]) if @variable_preloads&.key?(key)
|
|
130
|
+
end
|
|
102
131
|
|
|
103
|
-
|
|
132
|
+
# Walk this scope's body, then recurse into nested method defs as fresh
|
|
133
|
+
# scopes. A method def inherits a snapshot of the enclosing scope's
|
|
134
|
+
# variable state (so top-level lets/instance vars stay visible), but its
|
|
135
|
+
# own changes do not leak back out. Nested defs are processed in two
|
|
136
|
+
# passes: first to collect call sites between siblings (so we know which
|
|
137
|
+
# arguments each method receives at call time), then to actually analyze
|
|
138
|
+
# each def with its parameters seeded from caller context.
|
|
139
|
+
def process_scope(scope_node)
|
|
140
|
+
@variable_preloads ||= {}
|
|
141
|
+
@variable_models ||= {}
|
|
142
|
+
@single_record_variables ||= Set.new
|
|
143
|
+
|
|
144
|
+
scope_body = scope_body_for(scope_node)
|
|
145
|
+
return unless scope_body
|
|
146
|
+
|
|
147
|
+
build_variable_maps_in_scope(scope_body)
|
|
148
|
+
find_iterations_in_scope(scope_body)
|
|
149
|
+
|
|
150
|
+
process_nested_defs(scope_body)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def process_nested_defs(scope_body)
|
|
154
|
+
nested_defs = collect_nested_defs(scope_body)
|
|
155
|
+
return if nested_defs.empty?
|
|
156
|
+
|
|
157
|
+
call_sites_by_callee = collect_sibling_call_sites(nested_defs)
|
|
158
|
+
|
|
159
|
+
nested_defs.each do |nested_def|
|
|
160
|
+
with_scope_snapshot do
|
|
161
|
+
seed_params_from_callers(nested_def, call_sites_by_callee)
|
|
162
|
+
process_scope(nested_def)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def collect_nested_defs(scope_body)
|
|
168
|
+
defs = []
|
|
169
|
+
each_nested_def(scope_body) { |d| defs << d }
|
|
170
|
+
defs
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# For each sibling def in this class/module body, build its variable map
|
|
174
|
+
# in isolation and capture every self-send to ANOTHER sibling. The
|
|
175
|
+
# captured snapshot is the caller's variable state at the call site —
|
|
176
|
+
# later we re-evaluate the call's arg expressions against this snapshot
|
|
177
|
+
# to derive the callee's parameter contexts.
|
|
178
|
+
def collect_sibling_call_sites(nested_defs)
|
|
179
|
+
sibling_names = nested_defs.filter_map { |d| def_name(d) }.to_set
|
|
180
|
+
result = Hash.new { |h, k| h[k] = [] }
|
|
181
|
+
|
|
182
|
+
nested_defs.each do |def_node|
|
|
183
|
+
def_body = scope_body_for(def_node)
|
|
184
|
+
next unless def_body
|
|
185
|
+
|
|
186
|
+
with_scope_snapshot do
|
|
187
|
+
build_variable_maps_in_scope(def_body)
|
|
188
|
+
capture_calls_to_siblings(def_body, sibling_names, result)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
result
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def capture_calls_to_siblings(def_body, sibling_names, result)
|
|
196
|
+
each_node_in_scope(def_body) do |node|
|
|
197
|
+
next unless self_send_to_sibling?(node, sibling_names)
|
|
198
|
+
|
|
199
|
+
callee = node.children[1]
|
|
200
|
+
result[callee] << {
|
|
201
|
+
args: node.children[2..],
|
|
202
|
+
preloads: @variable_preloads.dup,
|
|
203
|
+
models: @variable_models.dup
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self_send_to_sibling?(node, sibling_names)
|
|
209
|
+
return false unless node.type == :send
|
|
210
|
+
|
|
211
|
+
receiver = node.children[0]
|
|
212
|
+
return false unless receiver.nil? || (receiver.is_a?(Parser::AST::Node) && receiver.type == :self)
|
|
213
|
+
|
|
214
|
+
sibling_names.include?(node.children[1])
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def def_name(def_node)
|
|
218
|
+
case def_node.type
|
|
219
|
+
when :def then def_node.children[0]
|
|
220
|
+
when :defs then def_node.children[1]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# If `def_node` is called by sibling defs in this class, evaluate each
|
|
225
|
+
# call site's arguments in that caller's context and bind the resulting
|
|
226
|
+
# preloads/model to the callee's parameter names. This is what eliminates
|
|
227
|
+
# the "helper method receives a preloaded relation" false positive.
|
|
228
|
+
def seed_params_from_callers(def_node, call_sites_by_callee)
|
|
229
|
+
return unless def_node.type == :def
|
|
230
|
+
|
|
231
|
+
name = def_name(def_node)
|
|
232
|
+
call_sites = call_sites_by_callee[name]
|
|
233
|
+
return if call_sites.nil? || call_sites.empty?
|
|
234
|
+
|
|
235
|
+
param_names = extract_param_names(def_node)
|
|
236
|
+
param_names.each_with_index do |param_name, idx|
|
|
237
|
+
seed_single_param(param_name, idx, call_sites)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def seed_single_param(param_name, idx, call_sites)
|
|
242
|
+
merged_preloads = Set.new
|
|
243
|
+
chosen_model = nil
|
|
244
|
+
|
|
245
|
+
call_sites.each do |site|
|
|
246
|
+
arg = site[:args][idx]
|
|
247
|
+
next unless arg
|
|
248
|
+
|
|
249
|
+
with_call_site_context(site) do
|
|
250
|
+
merged_preloads.merge(extract_included_associations_deep(arg))
|
|
251
|
+
chosen_model ||= infer_model_from_value(arg)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
key = [:lvar, param_name]
|
|
256
|
+
@variable_preloads[key] = merged_preloads unless merged_preloads.empty?
|
|
257
|
+
@variable_models[key] = chosen_model if chosen_model
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def with_call_site_context(site)
|
|
261
|
+
saved_preloads = @variable_preloads
|
|
262
|
+
saved_models = @variable_models
|
|
263
|
+
@variable_preloads = site[:preloads]
|
|
264
|
+
@variable_models = site[:models]
|
|
265
|
+
yield
|
|
266
|
+
ensure
|
|
267
|
+
@variable_preloads = saved_preloads
|
|
268
|
+
@variable_models = saved_models
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def extract_param_names(def_node)
|
|
272
|
+
args_node = def_node.children[1]
|
|
273
|
+
return [] unless args_node.is_a?(Parser::AST::Node) && args_node.type == :args
|
|
274
|
+
|
|
275
|
+
args_node.children.filter_map do |arg|
|
|
276
|
+
next unless arg.is_a?(Parser::AST::Node)
|
|
277
|
+
# Skip blockarg/restarg/kwrestarg etc. — only positional/optional/kwarg names.
|
|
278
|
+
next unless %i[arg optarg kwarg kwoptarg].include?(arg.type)
|
|
279
|
+
|
|
280
|
+
arg.children[0]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def scope_body_for(node)
|
|
285
|
+
return node unless node.is_a?(Parser::AST::Node)
|
|
286
|
+
|
|
287
|
+
case node.type
|
|
288
|
+
when :def then node.children[2]
|
|
289
|
+
when :defs then node.children[3]
|
|
290
|
+
else node
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def with_scope_snapshot
|
|
295
|
+
saved_preloads = @variable_preloads.dup
|
|
296
|
+
saved_models = @variable_models.dup
|
|
297
|
+
saved_single = @single_record_variables.dup
|
|
298
|
+
yield
|
|
299
|
+
ensure
|
|
300
|
+
@variable_preloads = saved_preloads
|
|
301
|
+
@variable_models = saved_models
|
|
302
|
+
@single_record_variables = saved_single
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Yields every node inside `scope_body` but stops at any :def/:defs —
|
|
306
|
+
# those subtrees represent fresh scopes and are visited separately.
|
|
307
|
+
def each_node_in_scope(node, &block)
|
|
308
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
309
|
+
|
|
310
|
+
yield node
|
|
311
|
+
|
|
312
|
+
node.children.each do |child|
|
|
313
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
314
|
+
next if %i[def defs].include?(child.type)
|
|
315
|
+
|
|
316
|
+
each_node_in_scope(child, &block)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Yields each immediately-nested :def/:defs (not deeper-nested ones —
|
|
321
|
+
# those are visited via that def's own process_scope call).
|
|
322
|
+
def each_nested_def(node, &block)
|
|
323
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
324
|
+
|
|
325
|
+
node.children.each do |child|
|
|
326
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
327
|
+
|
|
328
|
+
if %i[def defs].include?(child.type)
|
|
329
|
+
yield child
|
|
330
|
+
else
|
|
331
|
+
each_nested_def(child, &block)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def build_variable_maps_in_scope(scope_body)
|
|
337
|
+
each_node_in_scope(scope_body) { |node| process_variable_assignment(node) }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def find_iterations_in_scope(scope_body)
|
|
341
|
+
each_node_in_scope(scope_body) do |node|
|
|
342
|
+
process_iteration_block(node) if iteration_block?(node)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def process_iteration_block(node)
|
|
347
|
+
block_var = extract_iteration_variable(node)
|
|
348
|
+
return unless block_var
|
|
349
|
+
|
|
350
|
+
block_body = node.children[2]
|
|
351
|
+
return unless block_body
|
|
352
|
+
|
|
353
|
+
collection_node = node.children[0]
|
|
354
|
+
return if single_record_iteration?(collection_node)
|
|
355
|
+
|
|
356
|
+
included = collect_included_associations(collection_node)
|
|
357
|
+
model_name = infer_model_from_value(collection_node)
|
|
358
|
+
skip_nodes = collect_non_loading_skip_set(block_body)
|
|
359
|
+
|
|
360
|
+
find_association_calls(block_body, block_var, @file_path, @issues, included, model_name, skip_nodes)
|
|
104
361
|
end
|
|
105
362
|
|
|
106
363
|
def process_variable_assignment(node)
|
|
107
|
-
|
|
364
|
+
case node.type
|
|
365
|
+
when :lvasgn, :ivasgn then process_simple_assignment(node)
|
|
366
|
+
when :masgn then process_multi_assignment(node)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
108
369
|
|
|
370
|
+
def process_simple_assignment(node)
|
|
109
371
|
var_type = node.type == :lvasgn ? :lvar : :ivar
|
|
110
372
|
var_name = node.children[0]
|
|
111
373
|
value_node = node.children[1]
|
|
112
|
-
return unless value_node
|
|
374
|
+
return unless value_node && var_name
|
|
375
|
+
|
|
376
|
+
record_variable(var_type, var_name, value_node)
|
|
377
|
+
end
|
|
113
378
|
|
|
379
|
+
def process_multi_assignment(node)
|
|
380
|
+
mlhs, rhs = node.children
|
|
381
|
+
return unless mlhs && rhs
|
|
382
|
+
|
|
383
|
+
# mlhs.children is a list of lvasgn/ivasgn (LHS targets). RHS is
|
|
384
|
+
# typically a method call like `pagy(query)` returning [meta, records],
|
|
385
|
+
# or an array literal. We can't statically know which target gets which
|
|
386
|
+
# slot, so apply preloads/model to every LHS — except names that look
|
|
387
|
+
# like pagination metadata.
|
|
388
|
+
mlhs.children.each { |target| record_multi_target(target, rhs) }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def record_multi_target(target, rhs)
|
|
392
|
+
return unless %i[lvasgn ivasgn].include?(target&.type)
|
|
393
|
+
|
|
394
|
+
name = target.children[0]
|
|
395
|
+
return if PAGINATION_META_NAMES.include?(name)
|
|
396
|
+
|
|
397
|
+
var_type = target.type == :lvasgn ? :lvar : :ivar
|
|
398
|
+
record_variable(var_type, name, rhs)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def record_variable(var_type, var_name, value_node)
|
|
114
402
|
key = [var_type, var_name]
|
|
115
|
-
|
|
403
|
+
|
|
404
|
+
preloaded = extract_included_associations_deep(value_node)
|
|
116
405
|
@variable_preloads[key] = preloaded unless preloaded.empty?
|
|
406
|
+
|
|
407
|
+
model = infer_model_from_value(value_node)
|
|
408
|
+
@variable_models[key] = model if model
|
|
409
|
+
|
|
117
410
|
@single_record_variables.add(key) if single_record_query?(value_node)
|
|
118
411
|
end
|
|
119
412
|
|
|
@@ -156,10 +449,33 @@ module EagerEye
|
|
|
156
449
|
included_set.merge(extract_symbols_from_args(extract_method_args(method_node)))
|
|
157
450
|
end
|
|
158
451
|
|
|
159
|
-
|
|
452
|
+
# Pre-scan: when an inner send like `block_var.assoc` is the receiver of a
|
|
453
|
+
# NON_LOADING_TERMINAL_METHODS call (e.g. `.update_all`), the assoc access
|
|
454
|
+
# does not trigger a SELECT. Track those receiver nodes so we don't flag them.
|
|
455
|
+
def collect_non_loading_skip_set(block_body)
|
|
456
|
+
skip = Set.new
|
|
457
|
+
traverse_ast(block_body) do |node|
|
|
458
|
+
next unless node.type == :send && NON_LOADING_TERMINAL_METHODS.include?(node.children[1])
|
|
459
|
+
|
|
460
|
+
receiver = node.children[0]
|
|
461
|
+
collect_chain_node_ids(receiver, skip)
|
|
462
|
+
end
|
|
463
|
+
skip
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def collect_chain_node_ids(node, set)
|
|
467
|
+
return unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
468
|
+
|
|
469
|
+
set.add(node.object_id)
|
|
470
|
+
collect_chain_node_ids(node.children[0], set)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def find_association_calls(node, block_var, file_path, issues, included_associations, model_name, skip_nodes) # rubocop:disable Metrics/ParameterLists
|
|
160
474
|
reported = Set.new
|
|
161
475
|
traverse_ast(node) do |child|
|
|
162
|
-
if
|
|
476
|
+
next if skip_nodes.include?(child.object_id)
|
|
477
|
+
|
|
478
|
+
if reportable_association_call?(child, block_var, reported, included_associations, model_name)
|
|
163
479
|
method = child.children[1]
|
|
164
480
|
issues << create_issue(
|
|
165
481
|
file_path: file_path,
|
|
@@ -167,7 +483,7 @@ module EagerEye
|
|
|
167
483
|
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
168
484
|
suggestion: "Use `includes(:#{method})` before iterating"
|
|
169
485
|
)
|
|
170
|
-
elsif reportable_method_query_call?(child, block_var, reported)
|
|
486
|
+
elsif reportable_method_query_call?(child, block_var, reported, model_name)
|
|
171
487
|
method = child.children[1]
|
|
172
488
|
issues << create_issue(
|
|
173
489
|
file_path: file_path,
|
|
@@ -179,35 +495,56 @@ module EagerEye
|
|
|
179
495
|
end
|
|
180
496
|
end
|
|
181
497
|
|
|
182
|
-
def reportable_association_call?(node, block_var, reported, included)
|
|
498
|
+
def reportable_association_call?(node, block_var, reported, included, model_name)
|
|
183
499
|
return false unless node.type == :send
|
|
184
500
|
|
|
185
501
|
receiver = node.children[0]
|
|
186
502
|
method = node.children[1]
|
|
187
503
|
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
188
|
-
return false if excluded_method?(method, included)
|
|
504
|
+
return false if excluded_method?(method, included, model_name)
|
|
189
505
|
|
|
190
506
|
reported.add?("#{node.loc.line}:#{method}")
|
|
191
507
|
end
|
|
192
508
|
|
|
193
|
-
def excluded_method?(method, included)
|
|
509
|
+
def excluded_method?(method, included, model_name)
|
|
194
510
|
EXCLUDED_METHODS.include?(method) ||
|
|
195
|
-
|
|
196
|
-
|
|
511
|
+
included.include?(method) ||
|
|
512
|
+
!known_association?(method, model_name)
|
|
197
513
|
end
|
|
198
514
|
|
|
199
|
-
|
|
515
|
+
# When we know the iteration variable's model AND have parsed that model's
|
|
516
|
+
# associations, trust that map exclusively — methods not in it are columns
|
|
517
|
+
# or scalar accessors, not associations. Otherwise fall back to heuristics
|
|
518
|
+
# (hardcoded common names + globally collected association names).
|
|
519
|
+
def known_association?(method, model_name)
|
|
520
|
+
if model_name && @associations_by_model&.key?(model_name)
|
|
521
|
+
return @associations_by_model[model_name].include?(method)
|
|
522
|
+
end
|
|
523
|
+
|
|
200
524
|
ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
|
|
201
525
|
end
|
|
202
526
|
|
|
203
|
-
def reportable_method_query_call?(node, block_var, reported)
|
|
527
|
+
def reportable_method_query_call?(node, block_var, reported, model_name)
|
|
204
528
|
return false unless block_var_send?(node, block_var)
|
|
205
529
|
return false if EXCLUDED_METHODS.include?(node.children[1])
|
|
206
|
-
return false unless
|
|
530
|
+
return false unless method_known_to_query?(node.children[1], model_name)
|
|
207
531
|
|
|
208
532
|
reported.add?("#{node.loc.line}:#{node.children[1]}")
|
|
209
533
|
end
|
|
210
534
|
|
|
535
|
+
# When the receiver model is known, only consider methods defined as a
|
|
536
|
+
# query on THAT model — not any model in the project. Without a known
|
|
537
|
+
# model, fall back to the global "any model has this method" heuristic.
|
|
538
|
+
def method_known_to_query?(method, model_name)
|
|
539
|
+
return false unless @method_queries
|
|
540
|
+
|
|
541
|
+
if model_name
|
|
542
|
+
@method_queries[model_name]&.include?(method) || false
|
|
543
|
+
else
|
|
544
|
+
@method_queries.any? { |_, ms| ms.include?(method) }
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
211
548
|
def block_var_send?(node, block_var)
|
|
212
549
|
node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
|
|
213
550
|
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)
|
|
122
|
-
|
|
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)
|
data/lib/eager_eye/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eager_eye
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hamzagedikkaya
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
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
|