eager_eye 1.2.15 → 1.3.1
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 +51 -0
- data/README.md +227 -541
- data/README.tr.md +489 -0
- data/lib/eager_eye/analyzer.rb +31 -4
- data/lib/eager_eye/baseline.rb +46 -0
- data/lib/eager_eye/cli.rb +15 -1
- data/lib/eager_eye/detectors/custom_method_query.rb +52 -1
- data/lib/eager_eye/detectors/loop_association.rb +65 -9
- data/lib/eager_eye/detectors/serializer_nesting.rb +47 -5
- data/lib/eager_eye/detectors/validation_n_plus_one.rb +25 -0
- data/lib/eager_eye/issue.rb +18 -6
- data/lib/eager_eye/schema_parser.rb +128 -0
- data/lib/eager_eye/serializer_usage_parser.rb +230 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +3 -0
- metadata +6 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/current"
|
|
4
|
+
|
|
5
|
+
module EagerEye
|
|
6
|
+
# Serializers are the worst false-positive source because the detector sees the
|
|
7
|
+
# serializer class in isolation: it cannot tell whether an association it flags
|
|
8
|
+
# is actually eager-loaded by the controller that renders it, nor whether the
|
|
9
|
+
# serializer is only ever handed a single record (no collection => no N+1).
|
|
10
|
+
#
|
|
11
|
+
# This parser scans the whole app for render sites — `XxxBlueprint.render*(arg,
|
|
12
|
+
# view: :v)`, AMS `render json: arg, (each_)serializer: XxxSerializer` — and,
|
|
13
|
+
# per (serializer, view), records:
|
|
14
|
+
# * preloaded_per_site : the associations eager-loaded on `arg` at each site
|
|
15
|
+
# * any_collection : was `arg` ever a collection (vs a single record)?
|
|
16
|
+
# An association preloaded at EVERY site, or a serializer only ever fed single
|
|
17
|
+
# records, cannot cause an N+1 — letting the detector stay silent there.
|
|
18
|
+
class SerializerUsageParser
|
|
19
|
+
PRELOAD_METHODS = %i[includes preload eager_load].freeze
|
|
20
|
+
RELATION_WRAPPERS = %i[pagy paginate page kaminari with_pagy].freeze
|
|
21
|
+
SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! sole find_sole_by
|
|
22
|
+
new build current_user current_account].freeze
|
|
23
|
+
RENDER_METHODS = %i[render render_as_hash render_as_json render_as_json! serialize].freeze
|
|
24
|
+
SERIALIZER_SUFFIXES = %w[Blueprint Serializer Resource].freeze
|
|
25
|
+
|
|
26
|
+
# serializer_basename => [ { view: sym_or_nil, preloaded: Set, collection: bool }, ... ]
|
|
27
|
+
attr_reader :usages
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@usages = Hash.new { |h, k| h[k] = [] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_file(ast)
|
|
34
|
+
return unless ast
|
|
35
|
+
|
|
36
|
+
each_scope(ast) do |body|
|
|
37
|
+
var_values = collect_assignments(body)
|
|
38
|
+
find_render_sites(body, var_values)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether an association read in `view` of `serializer` can be proven safe.
|
|
43
|
+
# `view` is the Blueprinter view the field lives in (nil = a base/default
|
|
44
|
+
# field, rendered by every site). The field is safe when, at every render
|
|
45
|
+
# site that renders it, the association is eager-loaded OR the site passes a
|
|
46
|
+
# single record. To avoid hiding a genuine N+1 we never conclude "safe" when
|
|
47
|
+
# we cannot see the render sites for a named view (it may be rendered
|
|
48
|
+
# dynamically) — only an EXISTING, uniformly-safe set of sites suppresses.
|
|
49
|
+
def safe_access?(serializer, view, association)
|
|
50
|
+
sites = sites_rendering(serializer, view)
|
|
51
|
+
return false if sites.empty?
|
|
52
|
+
|
|
53
|
+
sites.all? { |s| !s[:collection] || s[:preloaded].include?(association) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def known_serializer?(serializer)
|
|
57
|
+
@usages.key?(serializer)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Sites that render a field of the given view. A base field (view nil) is
|
|
63
|
+
# rendered by every site. A named-view field is rendered by sites that
|
|
64
|
+
# explicitly request that view.
|
|
65
|
+
def sites_rendering(serializer, view)
|
|
66
|
+
sites = @usages[serializer]
|
|
67
|
+
return [] unless sites
|
|
68
|
+
|
|
69
|
+
view.nil? ? sites : sites.select { |s| s[:view] == view }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def each_scope(ast)
|
|
73
|
+
yield ast
|
|
74
|
+
collect_defs(ast).each { |d| yield(def_body(d)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def collect_defs(node, acc = [])
|
|
78
|
+
return acc unless node.is_a?(Parser::AST::Node)
|
|
79
|
+
|
|
80
|
+
node.children.each do |child|
|
|
81
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
82
|
+
|
|
83
|
+
acc << child if %i[def defs].include?(child.type)
|
|
84
|
+
collect_defs(child, acc)
|
|
85
|
+
end
|
|
86
|
+
acc
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def def_body(node)
|
|
90
|
+
node.type == :def ? node.children[2] : node.children[3]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def collect_assignments(body)
|
|
94
|
+
values = {}
|
|
95
|
+
walk(body) do |node|
|
|
96
|
+
case node.type
|
|
97
|
+
when :lvasgn, :ivasgn
|
|
98
|
+
values[node.children[0]] = node.children[1] if node.children[1]
|
|
99
|
+
when :masgn
|
|
100
|
+
collect_multi_assignment(node, values)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
values
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def collect_multi_assignment(node, values)
|
|
107
|
+
mlhs, rhs = node.children
|
|
108
|
+
return unless mlhs && rhs
|
|
109
|
+
|
|
110
|
+
mlhs.children.each do |t|
|
|
111
|
+
next unless %i[lvasgn ivasgn].include?(t&.type)
|
|
112
|
+
|
|
113
|
+
values[t.children[0]] = rhs
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_render_sites(body, var_values)
|
|
118
|
+
walk(body) do |node|
|
|
119
|
+
next unless node.type == :send && RENDER_METHODS.include?(node.children[1])
|
|
120
|
+
|
|
121
|
+
serializer = serializer_name(node.children[0])
|
|
122
|
+
next unless serializer
|
|
123
|
+
|
|
124
|
+
arg = node.children[2]
|
|
125
|
+
view = view_option(node)
|
|
126
|
+
record_site(serializer, view, arg, var_values)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# `Foo::BarBlueprint.render_as_hash` => "BarBlueprint"; for Alba, the receiver
|
|
131
|
+
# is `BarResource.new(arg)` so peel the `.new`.
|
|
132
|
+
def serializer_name(recv)
|
|
133
|
+
return nil unless recv.is_a?(Parser::AST::Node)
|
|
134
|
+
|
|
135
|
+
const = recv.type == :send ? recv.children[0] : recv
|
|
136
|
+
return nil unless const.is_a?(Parser::AST::Node) && const.type == :const
|
|
137
|
+
|
|
138
|
+
name = const.children[1].to_s
|
|
139
|
+
SERIALIZER_SUFFIXES.any? { |s| name.end_with?(s) } ? name : nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def view_option(node) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
143
|
+
node.children[3..].each do |arg|
|
|
144
|
+
next unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
|
|
145
|
+
|
|
146
|
+
arg.children.each do |pair|
|
|
147
|
+
k = pair.children[0]
|
|
148
|
+
if k&.type == :sym && k.children[0] == :view && pair.children[1]&.type == :sym
|
|
149
|
+
return pair.children[1].children[0]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def record_site(serializer, view, arg, var_values)
|
|
157
|
+
resolved = resolve_value(arg, var_values)
|
|
158
|
+
@usages[serializer] << {
|
|
159
|
+
view: view,
|
|
160
|
+
preloaded: extract_preloads(resolved, var_values),
|
|
161
|
+
collection: !single_record?(resolved, var_values)
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Resolve a local/ivar render arg to the expression it was assigned, so
|
|
166
|
+
# `render(user_points)` after `user_points = UserPoint.includes(:point)` is
|
|
167
|
+
# seen as the relation, not an opaque variable.
|
|
168
|
+
def resolve_value(node, var_values, depth = 0)
|
|
169
|
+
return node if depth > 5 || !node.is_a?(Parser::AST::Node)
|
|
170
|
+
return node unless %i[lvar ivar].include?(node.type)
|
|
171
|
+
|
|
172
|
+
assigned = var_values[node.children[0]]
|
|
173
|
+
assigned ? resolve_value(assigned, var_values, depth + 1) : node
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def extract_preloads(node, var_values, depth = 0) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
177
|
+
preloads = Set.new
|
|
178
|
+
return preloads if depth > 8 || !node.is_a?(Parser::AST::Node)
|
|
179
|
+
|
|
180
|
+
current = node
|
|
181
|
+
while current.is_a?(Parser::AST::Node) && current.type == :send
|
|
182
|
+
current.children[2..].each { |a| collect_symbols(a, preloads) } if PRELOAD_METHODS.include?(current.children[1])
|
|
183
|
+
current.children[2..].each do |a|
|
|
184
|
+
if RELATION_WRAPPERS.include?(current.children[1])
|
|
185
|
+
preloads.merge(extract_preloads(resolve_value(a, var_values), var_values,
|
|
186
|
+
depth + 1))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
current = current.children[0]
|
|
190
|
+
end
|
|
191
|
+
preloads
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def collect_symbols(arg, set) # rubocop:disable Metrics/CyclomaticComplexity
|
|
195
|
+
case arg&.type
|
|
196
|
+
when :sym then set << arg.children[0]
|
|
197
|
+
when :array then arg.children.each { |c| collect_symbols(c, set) }
|
|
198
|
+
when :hash
|
|
199
|
+
arg.children.each do |pair|
|
|
200
|
+
key = pair.children[0]
|
|
201
|
+
set << key.children[0] if key&.type == :sym
|
|
202
|
+
collect_symbols(pair.children[1], set)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def single_record?(node, var_values, depth = 0)
|
|
208
|
+
return false if depth > 6 || !node.is_a?(Parser::AST::Node)
|
|
209
|
+
|
|
210
|
+
case node.type
|
|
211
|
+
when :send
|
|
212
|
+
method = node.children[1]
|
|
213
|
+
return true if SINGLE_RECORD_METHODS.include?(method)
|
|
214
|
+
|
|
215
|
+
single_record?(node.children[0], var_values, depth + 1)
|
|
216
|
+
when :array
|
|
217
|
+
node.children.size == 1
|
|
218
|
+
else
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def walk(node, &block)
|
|
224
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
225
|
+
|
|
226
|
+
yield node
|
|
227
|
+
node.children.each { |c| walk(c, &block) }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/eager_eye/version.rb
CHANGED
data/lib/eager_eye.rb
CHANGED
|
@@ -4,11 +4,14 @@ require "set"
|
|
|
4
4
|
require_relative "eager_eye/version"
|
|
5
5
|
require_relative "eager_eye/configuration"
|
|
6
6
|
require_relative "eager_eye/issue"
|
|
7
|
+
require_relative "eager_eye/baseline"
|
|
7
8
|
require_relative "eager_eye/association_parser"
|
|
8
9
|
require_relative "eager_eye/delegation_parser"
|
|
9
10
|
require_relative "eager_eye/scope_parser"
|
|
10
11
|
require_relative "eager_eye/validation_parser"
|
|
11
12
|
require_relative "eager_eye/method_query_parser"
|
|
13
|
+
require_relative "eager_eye/schema_parser"
|
|
14
|
+
require_relative "eager_eye/serializer_usage_parser"
|
|
12
15
|
require_relative "eager_eye/detectors/base"
|
|
13
16
|
require_relative "eager_eye/detectors/loop_association"
|
|
14
17
|
require_relative "eager_eye/detectors/serializer_nesting"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eager_eye
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.1
|
|
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-06-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- CONTRIBUTING.md
|
|
55
55
|
- LICENSE.txt
|
|
56
56
|
- README.md
|
|
57
|
+
- README.tr.md
|
|
57
58
|
- Rakefile
|
|
58
59
|
- SECURITY.md
|
|
59
60
|
- exe/eager_eye
|
|
@@ -61,6 +62,7 @@ files:
|
|
|
61
62
|
- lib/eager_eye/analyzer.rb
|
|
62
63
|
- lib/eager_eye/association_parser.rb
|
|
63
64
|
- lib/eager_eye/auto_fixer.rb
|
|
65
|
+
- lib/eager_eye/baseline.rb
|
|
64
66
|
- lib/eager_eye/cli.rb
|
|
65
67
|
- lib/eager_eye/comment_parser.rb
|
|
66
68
|
- lib/eager_eye/configuration.rb
|
|
@@ -94,7 +96,9 @@ files:
|
|
|
94
96
|
- lib/eager_eye/reporters/json.rb
|
|
95
97
|
- lib/eager_eye/rspec.rb
|
|
96
98
|
- lib/eager_eye/rspec/matchers.rb
|
|
99
|
+
- lib/eager_eye/schema_parser.rb
|
|
97
100
|
- lib/eager_eye/scope_parser.rb
|
|
101
|
+
- lib/eager_eye/serializer_usage_parser.rb
|
|
98
102
|
- lib/eager_eye/validation_parser.rb
|
|
99
103
|
- lib/eager_eye/version.rb
|
|
100
104
|
- sig/eager_eye.rbs
|