eager_eye 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2dee00b4a1b227d3fe506af3663b4a00730c312d61f9a1db876c4a59e7cbbd62
4
- data.tar.gz: 1ce8596e9752c3b2f4fc2fa2d9463fcb9eeca1f50f9031c9ef1acbaef95ba08f
3
+ metadata.gz: f996747b6c12e539180e66cc74c11c70acfab4a6e4b84aed4039cc7b58dafe53
4
+ data.tar.gz: f23d5a21ceecc39c71b0303a5748472a8ea5e0f5f3256ffd5a10084a5af8a39c
5
5
  SHA512:
6
- metadata.gz: 1c453c88107dfa5b3fbbdf1c66a2f2ca5fc2d2df3a99f91e27827f36a720e59d4974e9f880fad2668adf05116a81bcd69b8cdca6d3a0c0e24b8f3332f3c886a1
7
- data.tar.gz: a1bc480d5b3d1d82eb8eccb7697ff50cab9ba94fbf127a08cd39f3278dfe90a95ebc14df28cf518e26bac86ad2ba59eaacdb5efc783e58caae8187d670b4f071
6
+ metadata.gz: a75d25669ab2d7e96dbccb107ae30f817e5fd6a291651b68bc56dbea21f50bd5c8b24b0633dd21d6faeeb5a8abd999abc6baf57ac9c7e8c3c06aa4542dc570d3
7
+ data.tar.gz: 7d2e5af151c02923bf4e64f05bc277711228729fa08ddee3dd79699623efc33f3689290c1739c025d7851db0b59d90feeac99e3a65c9a13862fac8f36ddf46b6
data/.rubocop.yml CHANGED
@@ -20,7 +20,7 @@ Metrics/BlockLength:
20
20
  - "lib/eager_eye/railtie.rb"
21
21
 
22
22
  Metrics/ClassLength:
23
- Max: 165
23
+ Max: 170
24
24
 
25
25
  Metrics/ParameterLists:
26
26
  Max: 6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.6] - 2026-02-25
11
+
12
+ ### Changed
13
+
14
+ - Extract shared class-inspection logic into `ClassInspector` concern to reduce duplication across detectors
15
+ - Refactor all detectors to use `ClassInspector` for parent class and naming convention checks
16
+ - Simplify `Analyzer` by removing redundant delegation and streamlining detector orchestration
17
+ - Clean up `DelegationParser` with leaner parsing logic
18
+ - Improve `Base` detector with consolidated helper methods
19
+ - Streamline reporter classes (`Base`, `Console`) for clarity and consistency
20
+
21
+ ### Removed
22
+
23
+ - Remove duplicated class-matching code from `CallbackQuery`, `CountInIteration`, `CustomMethodQuery`, `DecoratorNPlusOne`, `DelegationNPlusOne`, `LoopAssociation`, and `SerializerNesting`
24
+
25
+ ## [1.2.5] - 2026-02-21
26
+
27
+ ### Added
28
+
29
+ - **New Detector: `DecoratorNPlusOne`** - Detects N+1 queries inside decorator/presenter classes
30
+ - Catches `object.comments.map(...)`, `__getobj__.items.each { ... }`, `model.posts`, `source.tags` patterns
31
+ - Identifies decorator classes by inheritance (`Draper::Decorator`, `SimpleDelegator`, `Delegator`) or name suffix (`Decorator`, `Presenter`, `ViewObject`)
32
+ - Targets all four object reference styles: `object`, `__getobj__`, `source`, `model`
33
+ - Skips ActiveStorage methods (`attached?`, `blob`, `variant`, etc.) to prevent false positives
34
+ - Suggests eager loading in the controller before decorating the collection
35
+
10
36
  ## [1.2.4] - 2026-02-21
11
37
 
12
38
  ### 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.4-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.6-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>
@@ -42,7 +42,7 @@
42
42
 
43
43
  ## Features
44
44
 
45
- ✨ **Detects 8 types of N+1 problems:**
45
+ ✨ **Detects 9 types of N+1 problems:**
46
46
  - Loop associations (queries in iterations)
47
47
  - Serializer nesting issues
48
48
  - Missing counter caches
@@ -51,6 +51,7 @@
51
51
  - Callback query N+1s
52
52
  - Pluck to array misuse
53
53
  - Delegation N+1s (hidden via `delegate :method, to: :association`)
54
+ - Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
54
55
 
55
56
  🔧 **Developer-friendly:**
56
57
  - Inline suppression (like RuboCop)
@@ -369,6 +370,34 @@ EagerEye detects these by:
369
370
  2. Tracking which methods delegate to which associations
370
371
  3. Flagging calls to those methods inside iteration blocks when the association is not preloaded
371
372
 
373
+ ### 9. Decorator N+1
374
+
375
+ Detects N+1 queries inside Draper decorators, SimpleDelegator subclasses, and classes named `Decorator`, `Presenter`, or `ViewObject`. Each decorator wraps a single record — when a collection is decorated without preloading, every method that accesses an association triggers a new query per record.
376
+
377
+ ```ruby
378
+ # Bad - N+1 on each decorated post
379
+ class PostDecorator < Draper::Decorator
380
+ def comment_summary
381
+ object.comments.map(&:body).join(", ") # Query for each post!
382
+ end
383
+
384
+ def tag_list
385
+ object.tags.map(&:name).join(", ") # Another query for each post!
386
+ end
387
+ end
388
+
389
+ # Controller - no includes = N+1
390
+ @posts = Post.all.decorate
391
+
392
+ # Good - Eager load before decorating
393
+ @posts = Post.includes(:comments, :tags).all.decorate
394
+ ```
395
+
396
+ Supports the following object references inside decorators:
397
+ - `object` — Draper standard
398
+ - `__getobj__` — SimpleDelegator standard
399
+ - `source`, `model` — alternative Draper aliases
400
+
372
401
  ## Inline Suppression
373
402
 
374
403
  Suppress false positives using inline comments (RuboCop-style):
@@ -412,6 +441,7 @@ Both CamelCase and snake_case formats are accepted:
412
441
  | Callback Query | `CallbackQuery` | `callback_query` |
413
442
  | Pluck to Array | `PluckToArray` | `pluck_to_array` |
414
443
  | Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
444
+ | Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
415
445
  | All Detectors | `all` | `all` |
416
446
 
417
447
  ## Auto-fix (Experimental)
@@ -515,6 +545,7 @@ enabled_detectors:
515
545
  - callback_query
516
546
  - pluck_to_array
517
547
  - delegation_n_plus_one
548
+ - decorator_n_plus_one
518
549
 
519
550
  # Severity levels per detector (error, warning, info)
520
551
  severity_levels:
@@ -525,6 +556,7 @@ severity_levels:
525
556
  callback_query: warning
526
557
  pluck_to_array: warning # Optimization
527
558
  delegation_n_plus_one: warning # Hidden delegation N+1
559
+ decorator_n_plus_one: warning # Decorator/Presenter N+1
528
560
  missing_counter_cache: info # Suggestion
529
561
 
530
562
  # Minimum severity to report (default: info)
@@ -12,7 +12,8 @@ module EagerEye
12
12
  count_in_iteration: Detectors::CountInIteration,
13
13
  callback_query: Detectors::CallbackQuery,
14
14
  pluck_to_array: Detectors::PluckToArray,
15
- delegation_n_plus_one: Detectors::DelegationNPlusOne
15
+ delegation_n_plus_one: Detectors::DelegationNPlusOne,
16
+ decorator_n_plus_one: Detectors::DecoratorNPlusOne
16
17
  }.freeze
17
18
 
18
19
  attr_reader :paths, :issues, :association_preloads, :delegation_maps
@@ -26,38 +27,30 @@ module EagerEye
26
27
 
27
28
  def run
28
29
  @issues = []
29
- collect_association_preloads
30
- collect_delegation_maps
31
- analyze_files
30
+ collect_model_metadata
31
+ ruby_files.each { |file_path| analyze_file(file_path) }
32
32
  @issues
33
33
  end
34
34
 
35
35
  private
36
36
 
37
- def collect_association_preloads
37
+ def collect_model_metadata
38
38
  model_files.each do |file_path|
39
39
  ast = parse_source(File.read(file_path))
40
40
  next unless ast
41
41
 
42
- parser = AssociationParser.new
43
- parser.parse_model(ast, extract_model_name(file_path))
44
- @association_preloads.merge!(parser.preloaded_associations)
45
- end
46
- rescue StandardError
47
- nil
48
- end
42
+ model_name = extract_model_name(file_path)
49
43
 
50
- def collect_delegation_maps
51
- model_files.each do |file_path|
52
- ast = parse_source(File.read(file_path))
53
- next unless ast
44
+ assoc_parser = AssociationParser.new
45
+ assoc_parser.parse_model(ast, model_name)
46
+ @association_preloads.merge!(assoc_parser.preloaded_associations)
54
47
 
55
- parser = DelegationParser.new
56
- parser.parse_model(ast, extract_model_name(file_path))
57
- @delegation_maps.merge!(parser.delegation_maps)
48
+ deleg_parser = DelegationParser.new
49
+ deleg_parser.parse_model(ast, model_name)
50
+ @delegation_maps.merge!(deleg_parser.delegation_maps)
51
+ rescue Errno::ENOENT, Errno::EACCES
52
+ next
58
53
  end
59
- rescue StandardError
60
- nil
61
54
  end
62
55
 
63
56
  def model_files
@@ -65,25 +58,19 @@ module EagerEye
65
58
  end
66
59
 
67
60
  def extract_model_name(file_path)
68
- File.basename(file_path, ".rb").camelize
61
+ name = File.basename(file_path, ".rb")
62
+ name.respond_to?(:camelize) ? name.camelize : name.split("_").map(&:capitalize).join
69
63
  end
70
64
 
71
- def analyze_files
72
- ruby_files.each { |file_path| analyze_file(file_path) }
65
+ def ruby_files
66
+ paths.flat_map { |path| resolve_path(path) }.reject { |file| excluded?(file) }
73
67
  end
74
68
 
75
- def ruby_files
76
- all_files = paths.flat_map do |path|
77
- if File.file?(path)
78
- [path]
79
- elsif File.directory?(path)
80
- Dir.glob(File.join(path, "**", "*.rb"))
81
- else
82
- Dir.glob(path)
83
- end
84
- end
69
+ def resolve_path(path)
70
+ return [path] if File.file?(path)
71
+ return Dir.glob(File.join(path, "**", "*.rb")) if File.directory?(path)
85
72
 
86
- all_files.reject { |file| excluded?(file) }
73
+ Dir.glob(path)
87
74
  end
88
75
 
89
76
  def excluded?(file_path)
@@ -102,9 +89,10 @@ module EagerEye
102
89
 
103
90
  enabled_detectors.each do |detector|
104
91
  file_issues = detector.detect(*detector_args(detector, ast, file_path))
105
- file_issues.reject! { |issue| comment_parser.disabled_at?(issue.line_number, issue.detector) }
106
- file_issues.select! { |issue| issue.meets_minimum_severity?(min_severity) }
107
- @issues.concat(file_issues)
92
+ @issues.concat(file_issues.select do |issue|
93
+ !comment_parser.disabled_at?(issue.line_number, issue.detector) &&
94
+ issue.meets_minimum_severity?(min_severity)
95
+ end)
108
96
  end
109
97
  rescue Errno::ENOENT, Errno::EACCES => e
110
98
  warn "EagerEye: Could not read file #{file_path}: #{e.message}"
@@ -125,8 +113,7 @@ module EagerEye
125
113
 
126
114
  def enabled_detectors
127
115
  @enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
128
- detector_class = DETECTOR_CLASSES[name]
129
- detector_class&.new
116
+ DETECTOR_CLASSES[name]&.new
130
117
  end
131
118
  end
132
119
  end
@@ -5,7 +5,6 @@ module EagerEye
5
5
  FILE_DISABLE_PATTERN = /eager_eye:disable-file\s+(.+?)(?:\s+--|$)/i
6
6
  NEXT_LINE_PATTERN = /eager_eye:disable-next-line(?:\s+(.+?))?(?:\s+--|$)/i
7
7
  BLOCK_START_PATTERN = /eager_eye:disable-block(?:\s+(.+?))?(?:\s+--|$)/i
8
- BLOCK_END_PATTERN = /eager_eye:enable-block(?:\s+(.+?))?(?:\s+--|$)/i
9
8
  INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
10
9
  ENABLE_PATTERN = /eager_eye:enable(?:\s+(.+?))?(?:\s+--|$)/i
11
10
 
@@ -8,7 +8,7 @@ module EagerEye
8
8
  DEFAULT_DETECTORS = %i[
9
9
  loop_association serializer_nesting missing_counter_cache
10
10
  custom_method_query count_in_iteration callback_query
11
- pluck_to_array delegation_n_plus_one
11
+ pluck_to_array delegation_n_plus_one decorator_n_plus_one
12
12
  ].freeze
13
13
 
14
14
  DEFAULT_SEVERITY_LEVELS = {
@@ -19,7 +19,8 @@ module EagerEye
19
19
  count_in_iteration: :warning,
20
20
  callback_query: :warning,
21
21
  pluck_to_array: :warning,
22
- delegation_n_plus_one: :warning
22
+ delegation_n_plus_one: :warning,
23
+ decorator_n_plus_one: :warning
23
24
  }.freeze
24
25
 
25
26
  VALID_SEVERITIES = %i[info warning error].freeze
@@ -24,50 +24,42 @@ module EagerEye
24
24
  end
25
25
 
26
26
  def check_delegate(node, model_name)
27
- return unless bare_delegate_call?(node)
27
+ return unless delegate_call?(node)
28
28
 
29
29
  args = node.children[2..]
30
- methods = delegate_methods(args)
30
+ methods = extract_sym_args(args)
31
31
  return if methods.empty?
32
32
 
33
33
  to_target = extract_to_target(args)
34
34
  return unless to_target
35
35
 
36
- register_delegates(model_name, methods, to_target)
36
+ @delegation_maps[model_name] ||= {}
37
+ methods.each { |m| @delegation_maps[model_name][m] = to_target }
37
38
  end
38
39
 
39
- def bare_delegate_call?(node)
40
+ def delegate_call?(node)
40
41
  node.type == :send && node.children[0].nil? && node.children[1] == :delegate
41
42
  end
42
43
 
43
- def delegate_methods(args)
44
+ def extract_sym_args(args)
44
45
  args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
45
46
  end
46
47
 
47
- def register_delegates(model_name, methods, to_target)
48
- @delegation_maps[model_name] ||= {}
49
- methods.each { |m| @delegation_maps[model_name][m] = to_target }
50
- end
51
-
52
48
  def extract_to_target(args)
53
49
  hash_arg = args.find { |a| a&.type == :hash }
54
50
  return unless hash_arg
55
51
 
56
- to_pair = hash_arg.children.find { |p| to_key_pair?(p) }
57
- extract_sym_value(to_pair)
58
- end
59
-
60
- def extract_sym_value(node)
61
- return unless node
52
+ to_pair = find_to_pair(hash_arg)
53
+ return unless to_pair
62
54
 
63
- value = node.children[1]
55
+ value = to_pair.children[1]
64
56
  value.children[0] if value&.type == :sym
65
57
  end
66
58
 
67
- def to_key_pair?(pair)
68
- pair.type == :pair &&
69
- pair.children[0]&.type == :sym &&
70
- pair.children[0].children[0] == :to
59
+ def find_to_pair(hash_node)
60
+ hash_node.children.find do |p|
61
+ p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
62
+ end
71
63
  end
72
64
  end
73
65
  end
@@ -22,13 +22,12 @@ module EagerEye
22
22
  protected
23
23
 
24
24
  def create_issue(file_path:, line_number:, message:, severity: nil, suggestion: nil)
25
- resolved_severity = severity || configured_severity
26
25
  Issue.new(
27
26
  detector: self.class.detector_name,
28
27
  file_path: file_path,
29
28
  line_number: line_number,
30
29
  message: message,
31
- severity: resolved_severity,
30
+ severity: severity || configured_severity,
32
31
  suggestion: suggestion
33
32
  )
34
33
  end
@@ -73,6 +72,34 @@ module EagerEye
73
72
  symbols.add(key.children[0]) if key&.type == :sym
74
73
  end
75
74
  end
75
+
76
+ def extract_block_variable(block_node)
77
+ args = block_node&.children&.[](1)
78
+ first_arg = args&.children&.first
79
+ first_arg&.type == :arg ? first_arg.children[0] : nil
80
+ end
81
+
82
+ def receiver_chain_starts_with?(node, target_var)
83
+ return false unless node.is_a?(Parser::AST::Node)
84
+
85
+ case node.type
86
+ when :lvar then node.children[0] == target_var
87
+ when :send then receiver_chain_starts_with?(node.children[0], target_var)
88
+ else false
89
+ end
90
+ end
91
+
92
+ def reconstruct_chain(node)
93
+ return "" unless node.is_a?(Parser::AST::Node)
94
+
95
+ case node.type
96
+ when :lvar then node.children[0].to_s
97
+ when :send
98
+ receiver_str = reconstruct_chain(node.children[0])
99
+ receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
100
+ else ""
101
+ end
102
+ end
76
103
  end
77
104
  end
78
105
  end
@@ -29,6 +29,9 @@ module EagerEye
29
29
  ITERATION_METHODS = %i[each map select find_all reject collect
30
30
  find_each find_in_batches in_batches].freeze
31
31
  AR_BATCH_METHODS = %i[find_each find_in_batches in_batches].freeze
32
+ NON_AR_NAMESPACES = %w[Sidekiq Redis ActionCable ActionMailer Kafka].freeze
33
+ TRANSACTIONAL_CALLBACKS = %i[before_validation before_save before_create before_update before_destroy
34
+ around_save around_create around_update around_destroy].freeze
32
35
 
33
36
  def self.detector_name
34
37
  :callback_query
@@ -38,7 +41,6 @@ module EagerEye
38
41
  @issues = []
39
42
  @file_path = file_path
40
43
  @callback_methods = {}
41
-
42
44
  return @issues unless ast
43
45
 
44
46
  find_callback_definitions(ast)
@@ -64,9 +66,7 @@ module EagerEye
64
66
  node.children[2..].each do |arg|
65
67
  next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
66
68
 
67
- method_name = arg.children[0]
68
- callback_type = node.children[1]
69
- @callback_methods[method_name] = callback_type
69
+ @callback_methods[arg.children[0]] = node.children[1]
70
70
  end
71
71
  end
72
72
 
@@ -74,9 +74,8 @@ module EagerEye
74
74
  return unless node.is_a?(Parser::AST::Node)
75
75
 
76
76
  if node.type == :def && @callback_methods.key?(node.children[0])
77
- method_name = node.children[0]
78
77
  body = node.children[2]
79
- find_iterations_with_queries(body, method_name, @callback_methods[method_name]) if body
78
+ find_iterations_with_queries(body, node.children[0], @callback_methods[node.children[0]]) if body
80
79
  end
81
80
 
82
81
  node.children.each { |child| check_callback_methods(child) }
@@ -87,7 +86,8 @@ module EagerEye
87
86
 
88
87
  if iteration_block?(node)
89
88
  block_var = extract_block_variable(node)
90
- if block_var && contains_ar_query_on_variable?(node, block_var)
89
+ collection = node.children[0].children[0]
90
+ if block_var && !non_ar_collection?(collection) && contains_ar_query_on_variable?(node, block_var)
91
91
  add_iteration_issue(node, method_name, callback_type)
92
92
  find_query_calls_in_block(node, method_name, callback_type, block_var)
93
93
  end
@@ -127,47 +127,52 @@ module EagerEye
127
127
  end
128
128
 
129
129
  def add_query_issue(node, method_name, callback_type)
130
- query_method = node.children[1]
130
+ suggestion = if transactional_callback?(callback_type)
131
+ "Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
132
+ else
133
+ "Callbacks run on every save/create/update. Consider moving to a background job"
134
+ end
131
135
 
132
136
  @issues << create_issue(
133
137
  file_path: @file_path,
134
138
  line_number: node.loc.line,
135
- message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
139
+ message: "Query method `.#{node.children[1]}` found in `#{callback_type}` callback `:#{method_name}`",
136
140
  severity: :warning,
137
- suggestion: "Callbacks run on every save/create/update. Consider moving to a background job"
141
+ suggestion: suggestion
138
142
  )
139
143
  end
140
144
 
141
145
  def add_iteration_issue(node, method_name, callback_type)
146
+ suggestion = if transactional_callback?(callback_type)
147
+ "Avoid DB queries in before_*/around_* callbacks. Preload data outside the iteration instead"
148
+ else
149
+ "Avoid iterations in callbacks. Use background jobs for bulk operations"
150
+ end
151
+
142
152
  @issues << create_issue(
143
153
  file_path: @file_path,
144
154
  line_number: node.loc.line,
145
155
  message: "Iteration found in `#{callback_type}` callback `:#{method_name}` - potential N+1",
146
156
  severity: :error,
147
- suggestion: "Avoid iterations in callbacks. Use background jobs for bulk operations"
157
+ suggestion: suggestion
148
158
  )
149
159
  end
150
160
 
151
- def extract_block_variable(block_node)
152
- args_node = block_node.children[1]
153
- return nil unless args_node&.type == :args
154
-
155
- first_arg = args_node.children[0]
156
- return nil unless first_arg&.type == :arg
161
+ def transactional_callback?(callback_type)
162
+ TRANSACTIONAL_CALLBACKS.include?(callback_type)
163
+ end
157
164
 
158
- first_arg.children[0]
165
+ def non_ar_collection?(node)
166
+ ns = root_namespace(node)
167
+ ns && NON_AR_NAMESPACES.include?(ns)
159
168
  end
160
169
 
161
- def receiver_chain_starts_with?(node, block_var)
162
- return false unless node.is_a?(Parser::AST::Node)
170
+ def root_namespace(node)
171
+ return nil unless node.is_a?(Parser::AST::Node)
163
172
 
164
173
  case node.type
165
- when :lvar
166
- node.children[0] == block_var
167
- when :send
168
- receiver_chain_starts_with?(node.children[0], block_var)
169
- else
170
- false
174
+ when :const then node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
175
+ when :send, :block then root_namespace(node.children[0])
171
176
  end
172
177
  end
173
178
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ module Concerns
6
+ module ClassInspector
7
+ HAS_MANY_ASSOCIATIONS = %w[
8
+ authors users owners creators admins members customers clients
9
+ posts articles comments categories tags children companies organizations
10
+ projects tasks items orders products accounts profiles settings
11
+ images avatars photos attachments documents
12
+ ].freeze
13
+
14
+ ACTIVE_STORAGE_METHODS = %i[
15
+ attached? attach attachment attachments blob blobs purge purge_later variant preview
16
+ ].freeze
17
+
18
+ private
19
+
20
+ def const_to_string(node)
21
+ return nil unless node&.type == :const
22
+
23
+ parts = []
24
+ current = node
25
+ while current&.type == :const
26
+ parts.unshift(current.children[1].to_s)
27
+ current = current.children[0]
28
+ end
29
+ parts.join("::")
30
+ end
31
+
32
+ def extract_class_name(class_node)
33
+ name_node = class_node.children[0]
34
+ name_node.children[1].to_s if name_node&.type == :const
35
+ end
36
+
37
+ def likely_association?(method_name)
38
+ HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
39
+ end
40
+
41
+ def collect_active_storage_lines(body)
42
+ lines = Set.new
43
+ traverse_ast(body) do |node|
44
+ lines << node.loc.line if node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
45
+ end
46
+ lines
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -6,6 +6,7 @@ module EagerEye
6
6
  COUNT_METHODS = %i[count].freeze
7
7
  ITERATION_METHODS = %i[each map select find_all reject collect each_with_index each_with_object flat_map
8
8
  find_each find_in_batches in_batches].freeze
9
+ ARRAY_METHOD_SUFFIXES = %w[_ids _tags _types _codes _names _values].freeze
9
10
 
10
11
  def self.detector_name
11
12
  :count_in_iteration
@@ -14,7 +15,6 @@ module EagerEye
14
15
  def detect(ast, file_path)
15
16
  @issues = []
16
17
  @file_path = file_path
17
-
18
18
  return @issues unless ast
19
19
 
20
20
  find_iteration_blocks(ast) do |block_body, block_var|
@@ -52,63 +52,30 @@ module EagerEye
52
52
 
53
53
  def count_on_association?(node, block_var)
54
54
  node.type == :send && COUNT_METHODS.include?(node.children[1]) &&
55
+ !array_returning_method?(node.children[0]) &&
55
56
  association_call_on_block_var?(node.children[0], block_var)
56
57
  end
57
58
 
58
- def association_call_on_block_var?(node, block_var)
59
+ def array_returning_method?(node)
59
60
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
60
61
 
61
- receiver = node.children[0]
62
- return false unless receiver.is_a?(Parser::AST::Node)
63
-
64
- return true if receiver.type == :lvar && receiver.children[0] == block_var
65
-
66
- receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
67
- end
68
-
69
- def chain_starts_with_block_var?(node, block_var)
70
- return false unless node.is_a?(Parser::AST::Node)
71
-
72
- case node.type
73
- when :lvar then node.children[0] == block_var
74
- when :send then chain_starts_with_block_var?(node.children[0], block_var)
75
- else false
76
- end
62
+ ARRAY_METHOD_SUFFIXES.any? { |suffix| node.children[1].to_s.end_with?(suffix) }
77
63
  end
78
64
 
79
- def extract_block_variable(block_node)
80
- args_node = block_node.children[1]
81
- return nil unless args_node&.type == :args
82
-
83
- first_arg = args_node.children[0]
84
- first_arg&.type == :arg ? first_arg.children[0] : nil
65
+ def association_call_on_block_var?(node, block_var)
66
+ node.is_a?(Parser::AST::Node) && node.type == :send &&
67
+ receiver_chain_starts_with?(node.children[0], block_var)
85
68
  end
86
69
 
87
70
  def add_issue(node)
88
- receiver_chain = reconstruct_chain(node.children[0])
89
-
71
+ chain = reconstruct_chain(node.children[0])
90
72
  @issues << create_issue(
91
73
  file_path: @file_path,
92
74
  line_number: node.loc.line,
93
- message: "`.count` called on `#{receiver_chain}` inside iteration always executes a COUNT query",
75
+ message: "`.count` called on `#{chain}` inside iteration always executes a COUNT query",
94
76
  suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
95
77
  )
96
78
  end
97
-
98
- def reconstruct_chain(node)
99
- return "" unless node.is_a?(Parser::AST::Node)
100
-
101
- case node.type
102
- when :lvar
103
- node.children[0].to_s
104
- when :send
105
- receiver_str = reconstruct_chain(node.children[0])
106
- method = node.children[1]
107
- receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
108
- else
109
- ""
110
- end
111
- end
112
79
  end
113
80
  end
114
81
  end