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.
@@ -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
- reported.add?("#{node.loc.line}:#{method}")
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. Otherwise fall back to heuristics
518
- # (hardcoded common names + globally collected association names).
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
- ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
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?("#{node.loc.line}:#{node.children[1]}")
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 not any model in the project. Without a known
537
- # model, fall back to the global "any model has this method" heuristic.
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
- traverse_ast(body) do |node|
70
- next unless attribute_block?(node) && node.children[2]
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
- find_association_in_block(node.children[2], file_path, issues)
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
 
@@ -16,6 +16,18 @@ module EagerEye
16
16
  @suggestion = suggestion
17
17
  end
18
18
 
19
+ def self.from_h(hash)
20
+ h = hash.transform_keys(&:to_sym)
21
+ new(
22
+ detector: h.fetch(:detector).to_sym,
23
+ file_path: h.fetch(:file_path),
24
+ line_number: h.fetch(:line_number),
25
+ message: h.fetch(:message),
26
+ severity: (h[:severity] || :warning).to_sym,
27
+ suggestion: h[:suggestion]
28
+ )
29
+ end
30
+
19
31
  def severity_level
20
32
  SEVERITY_ORDER[severity]
21
33
  end
@@ -26,12 +38,12 @@ module EagerEye
26
38
 
27
39
  def to_h
28
40
  {
29
- detector: detector,
30
- file_path: file_path,
31
- line_number: line_number,
32
- message: message,
33
- severity: severity,
34
- suggestion: suggestion
41
+ detector:,
42
+ file_path:,
43
+ line_number:,
44
+ message:,
45
+ severity:,
46
+ suggestion:
35
47
  }
36
48
  end
37
49
 
@@ -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