eager_eye 1.3.0 → 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 +32 -1
- data/lib/eager_eye/analyzer.rb +31 -4
- 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/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 +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edf21044e597735154b0b1acdcda9230749bf027e8b8acafe70c6aaa1da22b62
|
|
4
|
+
data.tar.gz: b7d6b716ff42f9e160c292a54ecf0344027c73e24eae29f6aecd34aae27d60ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1849caab263614bdb85ed1ddcd15f63ae4199cd978ae56e2c8a690ca0e4c0e4616851f9a09f835d95e61adb457e35d5d07fbb3cb189c6394812f7064901cfaed
|
|
7
|
+
data.tar.gz: fe737b046298753a2fb9739f26307d46fbe4852a47f2a147fae56169259824dff4ced9f3f4cc3c6a37c58dbaea03785d2a1003d230d6b5c7cba56c4224ad011e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [1.3.
|
|
10
|
+
## [1.3.1] - 2026-06-12
|
|
11
|
+
|
|
12
|
+
### Changed — precision (fewer false positives)
|
|
13
|
+
|
|
14
|
+
Validated against two large production apps (~1,080 hand-checked findings). These
|
|
15
|
+
changes cut false positives by ~53% (339 → 158) with near-zero recall loss
|
|
16
|
+
(5 findings), and lift the trustworthy detectors toward 100% precision.
|
|
17
|
+
|
|
18
|
+
- **Schema-aware column guard.** New `SchemaParser` reads `db/schema.rb` (found by
|
|
19
|
+
walking up from the scanned path) to learn every table's real columns. When a
|
|
20
|
+
receiver's model can't be inferred, a method whose name the schema knows as a
|
|
21
|
+
column (`comsn_rate`, `vat_rate`, `service_fee_rate`) is no longer mistaken for
|
|
22
|
+
an association/query method. `loop_association` and `custom_method_query` no
|
|
23
|
+
longer flag column reads on unresolved receivers.
|
|
24
|
+
- **Per-iteration association dedup.** `loop_association` reports a memoized
|
|
25
|
+
`belongs_to`/`has_one` read once per iteration instead of on every line — a
|
|
26
|
+
repeated read hits Rails' instance cache, not the database. Associations used
|
|
27
|
+
as a query-chain base (`x.assoc.find_by`, `x.assoc.where`) still report each
|
|
28
|
+
occurrence, since those re-query.
|
|
29
|
+
- **Serializer render-site awareness.** New `SerializerUsageParser` scans render
|
|
30
|
+
sites (`XxxBlueprint.render*`, AMS `(each_)serializer:`) and records, per
|
|
31
|
+
serializer + Blueprinter view, which associations are eager-loaded and whether
|
|
32
|
+
only single records are passed. `serializer_nesting` stays silent when an
|
|
33
|
+
association is preloaded at every render site of that view, or the serializer
|
|
34
|
+
is only ever handed single records. It never concludes "safe" for a view it
|
|
35
|
+
can't see rendered, so genuine N+1s are preserved.
|
|
36
|
+
- **`custom_method_query` refinements.** Skips Enumerable aggregates with a block
|
|
37
|
+
argument (`relation.sum(&:amount)`), per-batch queries inside
|
|
38
|
+
`in_batches`/`find_in_batches`, and relation query methods called directly on a
|
|
39
|
+
single iteration element (a SELECT alias such as `record.ids`, not a query).
|
|
40
|
+
- **`validation_n_plus_one`** ignores saves that skip validations
|
|
41
|
+
(`save(validate: false)` / `create(..., validate: false)`).
|
|
11
42
|
|
|
12
43
|
### Added
|
|
13
44
|
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -19,18 +19,20 @@ module EagerEye
|
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
DETECTOR_EXTRA_ARGS = {
|
|
22
|
-
Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model
|
|
23
|
-
|
|
22
|
+
Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model
|
|
23
|
+
all_columns],
|
|
24
|
+
Detectors::SerializerNesting => %i[association_names method_queries serializer_usage all_columns],
|
|
24
25
|
Detectors::MissingCounterCache => %i[association_names],
|
|
25
26
|
Detectors::DecoratorNPlusOne => %i[association_names method_queries],
|
|
26
|
-
Detectors::CustomMethodQuery => %i[method_queries associations_by_model],
|
|
27
|
+
Detectors::CustomMethodQuery => %i[method_queries associations_by_model all_columns],
|
|
27
28
|
Detectors::DelegationNPlusOne => %i[delegation_maps],
|
|
28
29
|
Detectors::ScopeChainNPlusOne => %i[scope_maps],
|
|
29
30
|
Detectors::ValidationNPlusOne => %i[uniqueness_models]
|
|
30
31
|
}.freeze
|
|
31
32
|
|
|
32
33
|
attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
|
|
33
|
-
:scope_maps, :uniqueness_models, :associations_by_model
|
|
34
|
+
:scope_maps, :uniqueness_models, :associations_by_model, :all_columns, :columns_by_model,
|
|
35
|
+
:serializer_usage
|
|
34
36
|
|
|
35
37
|
def initialize(paths: nil)
|
|
36
38
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
@@ -42,17 +44,42 @@ module EagerEye
|
|
|
42
44
|
@delegation_maps = {}
|
|
43
45
|
@scope_maps = {}
|
|
44
46
|
@uniqueness_models = Set.new
|
|
47
|
+
@all_columns = Set.new
|
|
48
|
+
@columns_by_model = {}
|
|
49
|
+
@serializer_usage = SerializerUsageParser.new
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def run
|
|
48
53
|
@issues = []
|
|
54
|
+
collect_schema
|
|
49
55
|
collect_model_metadata
|
|
56
|
+
collect_serializer_usage
|
|
50
57
|
ruby_files.each { |file_path| analyze_file(file_path) }
|
|
51
58
|
@issues
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
private
|
|
55
62
|
|
|
63
|
+
def collect_schema
|
|
64
|
+
schema = SchemaParser.new
|
|
65
|
+
return unless schema.parse_from_path(@paths[0])
|
|
66
|
+
|
|
67
|
+
@all_columns = schema.all_columns
|
|
68
|
+
@columns_by_model = schema.columns_by_model
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Pre-pass over every analyzed file to learn how serializers are rendered
|
|
72
|
+
# (eager-loaded associations, single-record vs collection). The detector uses
|
|
73
|
+
# this to stay silent on associations preloaded at all render sites.
|
|
74
|
+
def collect_serializer_usage
|
|
75
|
+
ruby_files.each do |file_path|
|
|
76
|
+
ast = parse_source(File.read(file_path))
|
|
77
|
+
@serializer_usage.parse_file(ast) if ast
|
|
78
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
56
83
|
def collect_model_metadata
|
|
57
84
|
model_files.each do |file_path|
|
|
58
85
|
ast = parse_source(File.read(file_path))
|
|
@@ -20,13 +20,14 @@ module EagerEye
|
|
|
20
20
|
:custom_method_query
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def detect(ast, file_path, method_queries = {}, associations_by_model = {})
|
|
23
|
+
def detect(ast, file_path, method_queries = {}, associations_by_model = {}, all_columns = Set.new)
|
|
24
24
|
return [] unless ast
|
|
25
25
|
|
|
26
26
|
@issues = []
|
|
27
27
|
@file_path = file_path
|
|
28
28
|
@method_queries = method_queries
|
|
29
29
|
@associations_by_model = associations_by_model
|
|
30
|
+
@all_columns = all_columns
|
|
30
31
|
|
|
31
32
|
# Process each method def as its own scope so variable models from one
|
|
32
33
|
# method don't bleed into another (e.g. `orders = Order.all` in #index
|
|
@@ -195,6 +196,11 @@ module EagerEye
|
|
|
195
196
|
block_body = node.children[2]
|
|
196
197
|
next unless block_var && block_body
|
|
197
198
|
|
|
199
|
+
# `x.in_batches.each { |batch| batch.pluck(...) }` — the block var is a
|
|
200
|
+
# batch relation and any query on it runs once per batch, which is the
|
|
201
|
+
# recommended fix for N+1, not a symptom. Skip those iterations.
|
|
202
|
+
next if batch_iteration?(node.children[0])
|
|
203
|
+
|
|
198
204
|
model_name = infer_model_from_value(node.children[0])
|
|
199
205
|
check_block_for_query_methods(block_body, block_var, collection_is_array?(node.children[0], definitions))
|
|
200
206
|
check_block_for_model_query_methods(block_body, block_var, model_name)
|
|
@@ -243,6 +249,21 @@ module EagerEye
|
|
|
243
249
|
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
244
250
|
end
|
|
245
251
|
|
|
252
|
+
BATCH_METHODS = %i[in_batches find_in_batches].freeze # rubocop:disable Lint/UselessConstantScoping
|
|
253
|
+
|
|
254
|
+
# True when the iterated collection comes from `in_batches`/`find_in_batches`
|
|
255
|
+
# — the block variable is a batch relation, so per-batch queries on it are
|
|
256
|
+
# the intended batching, not an N+1.
|
|
257
|
+
def batch_iteration?(node)
|
|
258
|
+
current = node
|
|
259
|
+
while current.is_a?(Parser::AST::Node) && current.type == :send
|
|
260
|
+
return true if BATCH_METHODS.include?(current.children[1])
|
|
261
|
+
|
|
262
|
+
current = current.children[0]
|
|
263
|
+
end
|
|
264
|
+
false
|
|
265
|
+
end
|
|
266
|
+
|
|
246
267
|
def check_block_for_query_methods(node, block_var, is_array_collection = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
247
268
|
return unless node.is_a?(Parser::AST::Node)
|
|
248
269
|
|
|
@@ -255,12 +276,31 @@ module EagerEye
|
|
|
255
276
|
|
|
256
277
|
method_name = node.children[1]
|
|
257
278
|
return false unless QUERY_METHODS.include?(method_name)
|
|
279
|
+
return false if enumerable_form?(node)
|
|
280
|
+
return false if direct_block_var_receiver?(node, block_var, is_array_collection)
|
|
258
281
|
return false if skip_array_method?(node, block_var, is_array_collection)
|
|
259
282
|
return false if receiver_is_query_chain?(node.children[0])
|
|
260
283
|
|
|
261
284
|
receiver_chain_starts_with?(node.children[0], block_var)
|
|
262
285
|
end
|
|
263
286
|
|
|
287
|
+
# A relation query method called straight on the iteration element
|
|
288
|
+
# (`s_eod.ids`, `s_eod.pluck`) — with no association in between — is not an
|
|
289
|
+
# N+1: on a single record those names resolve to a column or a SELECT alias
|
|
290
|
+
# (e.g. `array_agg(...) AS ids`), not a relation query. The real N+1 shape
|
|
291
|
+
# is `element.association.query`, where the receiver is a send, not the bare
|
|
292
|
+
# block var. (When the element is itself a known array, the safe-method path
|
|
293
|
+
# handles it instead.)
|
|
294
|
+
def direct_block_var_receiver?(node, block_var, is_array_collection)
|
|
295
|
+
!is_array_collection && direct_block_var?(node.children[0], block_var)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# `relation.sum(&:amount)` / `relation.max(&:x)` run in Ruby over the loaded
|
|
299
|
+
# records (Enumerable), not as SQL — passing a block argument is the tell.
|
|
300
|
+
def enumerable_form?(node)
|
|
301
|
+
node.children[2..].any? { |arg| arg.is_a?(Parser::AST::Node) && arg.type == :block_pass }
|
|
302
|
+
end
|
|
303
|
+
|
|
264
304
|
def skip_array_method?(node, block_var, is_array_collection)
|
|
265
305
|
return true if receiver_ends_with_safe_transform_method?(node.children[0])
|
|
266
306
|
|
|
@@ -343,16 +383,27 @@ module EagerEye
|
|
|
343
383
|
model_name && @associations_by_model&.dig(model_name)&.include?(method)
|
|
344
384
|
end
|
|
345
385
|
|
|
386
|
+
# With a known receiver model, only flag a method defined as a query on
|
|
387
|
+
# THAT model. Without an inferred model the global "some model defines this
|
|
388
|
+
# query-method" fallback would fire on a same-named DB column (e.g. an
|
|
389
|
+
# EodItem's `comsn_rate` float that Wallet also exposes as a query method),
|
|
390
|
+
# so a name the schema knows as a column is never treated as a query.
|
|
346
391
|
def method_defined_as_query?(method, model_name)
|
|
347
392
|
return false unless @method_queries
|
|
348
393
|
|
|
349
394
|
if model_name
|
|
350
395
|
@method_queries[model_name]&.include?(method) || false
|
|
396
|
+
elsif known_column?(method)
|
|
397
|
+
false
|
|
351
398
|
else
|
|
352
399
|
@method_queries.any? { |_model, methods| methods.include?(method) }
|
|
353
400
|
end
|
|
354
401
|
end
|
|
355
402
|
|
|
403
|
+
def known_column?(method)
|
|
404
|
+
@all_columns&.include?(method) || false
|
|
405
|
+
end
|
|
406
|
+
|
|
356
407
|
def add_issue(node)
|
|
357
408
|
chain = reconstruct_chain(node.children[0])
|
|
358
409
|
@issues << create_issue(
|
|
@@ -40,7 +40,7 @@ module EagerEye
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def detect(ast, file_path, association_preloads = {}, association_names = Set.new, # rubocop:disable Metrics/ParameterLists
|
|
43
|
-
method_queries = {}, associations_by_model = {})
|
|
43
|
+
method_queries = {}, associations_by_model = {}, all_columns = Set.new)
|
|
44
44
|
return [] unless ast
|
|
45
45
|
|
|
46
46
|
@issues = []
|
|
@@ -49,6 +49,7 @@ module EagerEye
|
|
|
49
49
|
@dynamic_associations = association_names
|
|
50
50
|
@method_queries = method_queries
|
|
51
51
|
@associations_by_model = associations_by_model
|
|
52
|
+
@all_columns = all_columns
|
|
52
53
|
|
|
53
54
|
# Variable preloads/models leak across methods if tracked globally
|
|
54
55
|
# (e.g. controller#index sets `invoices = Invoice.includes(...)`, then
|
|
@@ -472,6 +473,7 @@ module EagerEye
|
|
|
472
473
|
|
|
473
474
|
def find_association_calls(node, block_var, file_path, issues, included_associations, model_name, skip_nodes) # rubocop:disable Metrics/ParameterLists
|
|
474
475
|
reported = Set.new
|
|
476
|
+
@requeried_methods = collect_requeried_methods(node, block_var)
|
|
475
477
|
traverse_ast(node) do |child|
|
|
476
478
|
next if skip_nodes.include?(child.object_id)
|
|
477
479
|
|
|
@@ -495,7 +497,7 @@ module EagerEye
|
|
|
495
497
|
end
|
|
496
498
|
end
|
|
497
499
|
|
|
498
|
-
def reportable_association_call?(node, block_var, reported, included, model_name)
|
|
500
|
+
def reportable_association_call?(node, block_var, reported, included, model_name) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
499
501
|
return false unless node.type == :send
|
|
500
502
|
|
|
501
503
|
receiver = node.children[0]
|
|
@@ -503,7 +505,40 @@ module EagerEye
|
|
|
503
505
|
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
504
506
|
return false if excluded_method?(method, included, model_name)
|
|
505
507
|
|
|
506
|
-
|
|
508
|
+
# A plain association read memoizes on the instance, so reading it more
|
|
509
|
+
# than once in one iteration only queries on the FIRST access — dedup the
|
|
510
|
+
# repeats. But an association used as a query-chain base
|
|
511
|
+
# (`x.assoc.find_by`, `x.assoc.where`) re-queries every time, so those
|
|
512
|
+
# occurrences are each real and are reported per line.
|
|
513
|
+
if @requeried_methods&.include?(method)
|
|
514
|
+
reported.add?("#{node.loc.line}:#{method}")
|
|
515
|
+
else
|
|
516
|
+
reported.add?(method.to_s)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Methods M where `block_var.M` is the receiver of a SQL-issuing query
|
|
521
|
+
# method (`.find_by`, `.where`, `.exists?`, `.count`, …) somewhere in the
|
|
522
|
+
# block. Those re-query on every call (they bypass the memoized association
|
|
523
|
+
# cache), so M's accesses must not be deduped. A plain chained association
|
|
524
|
+
# read (`x.prize.badges`) is not a re-query — `prize` stays memoized.
|
|
525
|
+
# rubocop:disable Lint/UselessConstantScoping
|
|
526
|
+
REQUERYING_METHODS = %i[where find_by find_by! exists? find first last take
|
|
527
|
+
pluck ids count sum average minimum maximum any? none? many? one?].freeze
|
|
528
|
+
# rubocop:enable Lint/UselessConstantScoping
|
|
529
|
+
|
|
530
|
+
def collect_requeried_methods(block_body, block_var) # rubocop:disable Metrics/CyclomaticComplexity
|
|
531
|
+
requeried = Set.new
|
|
532
|
+
traverse_ast(block_body) do |node|
|
|
533
|
+
next unless node.type == :send && REQUERYING_METHODS.include?(node.children[1])
|
|
534
|
+
|
|
535
|
+
inner = node.children[0]
|
|
536
|
+
next unless inner.is_a?(Parser::AST::Node) && inner.type == :send
|
|
537
|
+
next unless inner.children[0]&.type == :lvar && inner.children[0].children[0] == block_var
|
|
538
|
+
|
|
539
|
+
requeried << inner.children[1]
|
|
540
|
+
end
|
|
541
|
+
requeried
|
|
507
542
|
end
|
|
508
543
|
|
|
509
544
|
def excluded_method?(method, included, model_name)
|
|
@@ -514,14 +549,31 @@ module EagerEye
|
|
|
514
549
|
|
|
515
550
|
# When we know the iteration variable's model AND have parsed that model's
|
|
516
551
|
# associations, trust that map exclusively — methods not in it are columns
|
|
517
|
-
# or scalar accessors, not associations.
|
|
518
|
-
#
|
|
552
|
+
# or scalar accessors, not associations.
|
|
553
|
+
#
|
|
554
|
+
# When the model CANNOT be inferred (workers, lib/, service objects, blocks
|
|
555
|
+
# over params / method return values), fall back to the association-name
|
|
556
|
+
# heuristic — but never flag a name that the schema says is a real DB
|
|
557
|
+
# column. A column named like an association (`comsn_rate`, `vat_rate`) is
|
|
558
|
+
# the single largest false-positive source; the schema lets us exclude it
|
|
559
|
+
# while still catching genuine association access (`prize_point`, `user`).
|
|
519
560
|
def known_association?(method, model_name)
|
|
520
561
|
if model_name && @associations_by_model&.key?(model_name)
|
|
521
562
|
return @associations_by_model[model_name].include?(method)
|
|
522
563
|
end
|
|
523
564
|
|
|
524
|
-
|
|
565
|
+
# Receiver model unknown: a name the schema knows as a DB column is not
|
|
566
|
+
# an association access. Some names are BOTH a column and an association
|
|
567
|
+
# elsewhere (`vat_rate`, `currency`); without the model we can't tell, so
|
|
568
|
+
# we err toward silence (no false positive) and skip them. Names that are
|
|
569
|
+
# only ever associations (`prize_point`, `user`) are still flagged.
|
|
570
|
+
return false if known_column?(method)
|
|
571
|
+
|
|
572
|
+
@dynamic_associations.include?(method) || ASSOCIATION_NAMES.include?(method.to_s)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def known_column?(method)
|
|
576
|
+
@all_columns&.include?(method) || false
|
|
525
577
|
end
|
|
526
578
|
|
|
527
579
|
def reportable_method_query_call?(node, block_var, reported, model_name)
|
|
@@ -529,17 +581,21 @@ module EagerEye
|
|
|
529
581
|
return false if EXCLUDED_METHODS.include?(node.children[1])
|
|
530
582
|
return false unless method_known_to_query?(node.children[1], model_name)
|
|
531
583
|
|
|
532
|
-
reported.add?("
|
|
584
|
+
reported.add?("q:#{node.children[1]}")
|
|
533
585
|
end
|
|
534
586
|
|
|
535
587
|
# When the receiver model is known, only consider methods defined as a
|
|
536
|
-
# query on THAT model
|
|
537
|
-
#
|
|
588
|
+
# query on THAT model. Without a known model the global "any model has this
|
|
589
|
+
# method" heuristic can fire on a same-named DB column (e.g. a `comsn_rate`
|
|
590
|
+
# float that some other model also exposes as a query method), so a name
|
|
591
|
+
# the schema knows as a column is never treated as a query method.
|
|
538
592
|
def method_known_to_query?(method, model_name)
|
|
539
593
|
return false unless @method_queries
|
|
540
594
|
|
|
541
595
|
if model_name
|
|
542
596
|
@method_queries[model_name]&.include?(method) || false
|
|
597
|
+
elsif known_column?(method)
|
|
598
|
+
false
|
|
543
599
|
else
|
|
544
600
|
@method_queries.any? { |_, ms| ms.include?(method) }
|
|
545
601
|
end
|
|
@@ -15,11 +15,14 @@ module EagerEye
|
|
|
15
15
|
:serializer_nesting
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def detect(ast, file_path, association_names = Set.new, method_queries = {}
|
|
18
|
+
def detect(ast, file_path, association_names = Set.new, method_queries = {}, serializer_usage = nil, # rubocop:disable Metrics/ParameterLists
|
|
19
|
+
all_columns = Set.new)
|
|
19
20
|
return [] unless ast
|
|
20
21
|
|
|
21
22
|
@dynamic_associations = association_names
|
|
22
23
|
@method_queries = method_queries
|
|
24
|
+
@serializer_usage = serializer_usage
|
|
25
|
+
@all_columns = all_columns
|
|
23
26
|
issues = []
|
|
24
27
|
traverse_ast(ast) do |node|
|
|
25
28
|
next unless node.type == :class && serializer_class?(node)
|
|
@@ -66,11 +69,33 @@ module EagerEye
|
|
|
66
69
|
body = class_node.children[2]
|
|
67
70
|
return unless body
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
serializer = extract_class_name(class_node)
|
|
73
|
+
walk_with_view(body, nil) do |attr_block, view|
|
|
74
|
+
find_association_in_block(attr_block.children[2], file_path, issues, serializer, view)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Walk the class body, tracking the enclosing Blueprinter `view :name do ...
|
|
79
|
+
# end` so each attribute block is tagged with the view it belongs to (nil =
|
|
80
|
+
# a base/default field rendered by every view). Yields attribute blocks.
|
|
81
|
+
def walk_with_view(node, view, &block) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
82
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
if view_block?(node)
|
|
85
|
+
inner_view = node.children[0].children[2]&.children&.first
|
|
86
|
+
node.children[2..].each { |c| walk_with_view(c, inner_view, &block) }
|
|
87
|
+
return
|
|
73
88
|
end
|
|
89
|
+
|
|
90
|
+
yield(node, view) if attribute_block?(node) && node.children[2]
|
|
91
|
+
|
|
92
|
+
node.children.each { |c| walk_with_view(c, view, &block) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def view_block?(node)
|
|
96
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
97
|
+
node.children[0].children[1] == :view &&
|
|
98
|
+
node.children[0].children[2]&.type == :sym
|
|
74
99
|
end
|
|
75
100
|
|
|
76
101
|
def attribute_block?(node)
|
|
@@ -78,7 +103,7 @@ module EagerEye
|
|
|
78
103
|
ATTRIBUTE_METHODS.include?(node.children[0].children[1])
|
|
79
104
|
end
|
|
80
105
|
|
|
81
|
-
def find_association_in_block(block_body, file_path, issues)
|
|
106
|
+
def find_association_in_block(block_body, file_path, issues, serializer, view)
|
|
82
107
|
storage_lines = collect_active_storage_lines(block_body)
|
|
83
108
|
|
|
84
109
|
traverse_ast(block_body) do |node|
|
|
@@ -88,6 +113,7 @@ module EagerEye
|
|
|
88
113
|
receiver = node.children[0]
|
|
89
114
|
method_name = node.children[1]
|
|
90
115
|
next unless object_reference?(receiver)
|
|
116
|
+
next if suppressed?(serializer, view, method_name)
|
|
91
117
|
|
|
92
118
|
if likely_association?(method_name)
|
|
93
119
|
issues << create_issue(
|
|
@@ -107,6 +133,22 @@ module EagerEye
|
|
|
107
133
|
end
|
|
108
134
|
end
|
|
109
135
|
|
|
136
|
+
# Stay silent when the access is provably safe:
|
|
137
|
+
# * the name is a plain DB column (not a declared association anywhere), or
|
|
138
|
+
# * the render-site index shows that everywhere this serializer/view is
|
|
139
|
+
# rendered the association is eager-loaded or only single records are
|
|
140
|
+
# passed (so there is no collection to multiply into an N+1).
|
|
141
|
+
def suppressed?(serializer, view, method_name)
|
|
142
|
+
return true if column_not_association?(method_name)
|
|
143
|
+
return false unless @serializer_usage&.known_serializer?(serializer)
|
|
144
|
+
|
|
145
|
+
@serializer_usage.safe_access?(serializer, view, method_name)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def column_not_association?(method_name)
|
|
149
|
+
@all_columns&.include?(method_name) && !@dynamic_associations&.include?(method_name)
|
|
150
|
+
end
|
|
151
|
+
|
|
110
152
|
def object_reference?(node)
|
|
111
153
|
return false unless node
|
|
112
154
|
|
|
@@ -72,6 +72,7 @@ module EagerEye
|
|
|
72
72
|
|
|
73
73
|
def check_create_call(node)
|
|
74
74
|
return unless CREATE_METHODS.include?(node.children[1])
|
|
75
|
+
return if validations_skipped?(node)
|
|
75
76
|
|
|
76
77
|
receiver = node.children[0]
|
|
77
78
|
return unless receiver&.type == :const
|
|
@@ -82,6 +83,7 @@ module EagerEye
|
|
|
82
83
|
|
|
83
84
|
def check_save_call(node, new_model_vars)
|
|
84
85
|
return unless SAVE_METHODS.include?(node.children[1])
|
|
86
|
+
return if validations_skipped?(node)
|
|
85
87
|
|
|
86
88
|
receiver = node.children[0]
|
|
87
89
|
return unless receiver&.type == :lvar
|
|
@@ -90,6 +92,29 @@ module EagerEye
|
|
|
90
92
|
add_issue(node, model_name, node.children[1]) if model_name
|
|
91
93
|
end
|
|
92
94
|
|
|
95
|
+
# `save(validate: false)` / `create(..., validate: false)` skip ALL
|
|
96
|
+
# validations, so the uniqueness SELECT never runs — not an N+1.
|
|
97
|
+
def validations_skipped?(node)
|
|
98
|
+
node.children[2..].any? { |arg| hash_with_validate_false?(arg) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def hash_with_validate_false?(arg)
|
|
102
|
+
return false unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
|
|
103
|
+
|
|
104
|
+
arg.children.any? { |pair| validate_false_pair?(pair) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_false_pair?(pair)
|
|
108
|
+
return false unless pair.type == :pair
|
|
109
|
+
|
|
110
|
+
key, value = pair.children
|
|
111
|
+
key&.type == :sym && key.children[0] == :validate && false_literal?(value)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def false_literal?(node)
|
|
115
|
+
node.is_a?(Parser::AST::Node) && node.type == :false # rubocop:disable Lint/BooleanSymbol
|
|
116
|
+
end
|
|
117
|
+
|
|
93
118
|
def model_new_call?(node)
|
|
94
119
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
95
120
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/current"
|
|
4
|
+
|
|
5
|
+
module EagerEye
|
|
6
|
+
# Parses db/schema.rb to learn the real column names of each table. Columns are
|
|
7
|
+
# the dominant false-positive source: when a receiver's model can't be inferred,
|
|
8
|
+
# the name heuristics flag any method whose name collides with an association
|
|
9
|
+
# name — but a DB column (`comsn_rate`, `vat_rate`, `service_fee_rate`) is never
|
|
10
|
+
# an association. Knowing the column names lets the detectors disambiguate.
|
|
11
|
+
#
|
|
12
|
+
# Schema lookup walks up from the analyzed path so `eager_eye app/` still finds
|
|
13
|
+
# `<root>/db/schema.rb`. Parsing is AST-based (a `create_table "x" do |t| ... end`
|
|
14
|
+
# block whose body holds `t.<type> "col"` / `t.column "col"` / `t.references "y"`).
|
|
15
|
+
class SchemaParser
|
|
16
|
+
COLUMN_DEFINERS = %i[
|
|
17
|
+
string text integer bigint float decimal boolean date datetime time timestamp
|
|
18
|
+
binary json jsonb uuid inet cidr money hstore citext virtual primary_key column
|
|
19
|
+
].freeze
|
|
20
|
+
REFERENCE_DEFINERS = %i[references belongs_to].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :columns_by_table, :all_columns
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@columns_by_table = {}
|
|
26
|
+
@all_columns = Set.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns true if a schema was found and parsed.
|
|
30
|
+
def parse_from_path(start_path)
|
|
31
|
+
schema = locate_schema(start_path)
|
|
32
|
+
return false unless schema
|
|
33
|
+
|
|
34
|
+
ast = parse(File.read(schema))
|
|
35
|
+
return false unless ast
|
|
36
|
+
|
|
37
|
+
walk(ast)
|
|
38
|
+
true
|
|
39
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Column names mapped onto the model a table name classifies to
|
|
44
|
+
# ("eod_items" -> "EodItem"). Used for per-model column resolution.
|
|
45
|
+
def columns_by_model
|
|
46
|
+
@columns_by_model ||= @columns_by_table.transform_keys { |table| classify(table) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def locate_schema(start_path)
|
|
52
|
+
dir = File.expand_path(File.directory?(start_path) ? start_path : File.dirname(start_path))
|
|
53
|
+
12.times do
|
|
54
|
+
candidate = File.join(dir, "db", "schema.rb")
|
|
55
|
+
return candidate if File.file?(candidate)
|
|
56
|
+
|
|
57
|
+
parent = File.dirname(dir)
|
|
58
|
+
break if parent == dir
|
|
59
|
+
|
|
60
|
+
dir = parent
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse(source)
|
|
66
|
+
Parser::CurrentRuby.parse(source)
|
|
67
|
+
rescue Parser::SyntaxError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def walk(node)
|
|
72
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
73
|
+
|
|
74
|
+
capture_create_table(node) if create_table_block?(node)
|
|
75
|
+
node.children.each { |child| walk(child) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def create_table_block?(node)
|
|
79
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
80
|
+
node.children[0].children[1] == :create_table
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def capture_create_table(block_node)
|
|
84
|
+
table = string_arg(block_node.children[0])
|
|
85
|
+
return unless table
|
|
86
|
+
|
|
87
|
+
cols = (@columns_by_table[table] ||= Set.new)
|
|
88
|
+
collect_columns(block_node.children[2], cols)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def collect_columns(body, cols)
|
|
92
|
+
return unless body.is_a?(Parser::AST::Node)
|
|
93
|
+
|
|
94
|
+
add_column_from_send(body, cols) if body.type == :send
|
|
95
|
+
body.children.each { |child| collect_columns(child, cols) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_column_from_send(node, cols)
|
|
99
|
+
method = node.children[1]
|
|
100
|
+
name = string_arg(node)
|
|
101
|
+
return unless name
|
|
102
|
+
|
|
103
|
+
if COLUMN_DEFINERS.include?(method)
|
|
104
|
+
register(cols, name)
|
|
105
|
+
elsif REFERENCE_DEFINERS.include?(method)
|
|
106
|
+
register(cols, "#{name}_id")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def register(cols, name)
|
|
111
|
+
sym = name.to_sym
|
|
112
|
+
cols << sym
|
|
113
|
+
@all_columns << sym
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def string_arg(node)
|
|
117
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
118
|
+
|
|
119
|
+
node.children[2..]&.find { |a| a.is_a?(Parser::AST::Node) && a.type == :str }&.children&.first
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def classify(table)
|
|
123
|
+
base = table.to_s
|
|
124
|
+
singular = base.respond_to?(:singularize) ? base.singularize : base.sub(/s\z/, "")
|
|
125
|
+
singular.split("_").map(&:capitalize).join
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -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
|
@@ -10,6 +10,8 @@ require_relative "eager_eye/delegation_parser"
|
|
|
10
10
|
require_relative "eager_eye/scope_parser"
|
|
11
11
|
require_relative "eager_eye/validation_parser"
|
|
12
12
|
require_relative "eager_eye/method_query_parser"
|
|
13
|
+
require_relative "eager_eye/schema_parser"
|
|
14
|
+
require_relative "eager_eye/serializer_usage_parser"
|
|
13
15
|
require_relative "eager_eye/detectors/base"
|
|
14
16
|
require_relative "eager_eye/detectors/loop_association"
|
|
15
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.3.
|
|
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
|
|
@@ -96,7 +96,9 @@ files:
|
|
|
96
96
|
- lib/eager_eye/reporters/json.rb
|
|
97
97
|
- lib/eager_eye/rspec.rb
|
|
98
98
|
- lib/eager_eye/rspec/matchers.rb
|
|
99
|
+
- lib/eager_eye/schema_parser.rb
|
|
99
100
|
- lib/eager_eye/scope_parser.rb
|
|
101
|
+
- lib/eager_eye/serializer_usage_parser.rb
|
|
100
102
|
- lib/eager_eye/validation_parser.rb
|
|
101
103
|
- lib/eager_eye/version.rb
|
|
102
104
|
- sig/eager_eye.rbs
|