eager_eye 1.2.4 → 1.2.5

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: 6fa0e6e3acb2c37751e7ab49a58826a016a68c7a33cc6305ddb6b82d6787c462
4
+ data.tar.gz: af81d8c0cfde13914b00a9f64b4b1bef6731b12902a1358c7f27473c2ab9a68d
5
5
  SHA512:
6
- metadata.gz: 1c453c88107dfa5b3fbbdf1c66a2f2ca5fc2d2df3a99f91e27827f36a720e59d4974e9f880fad2668adf05116a81bcd69b8cdca6d3a0c0e24b8f3332f3c886a1
7
- data.tar.gz: a1bc480d5b3d1d82eb8eccb7697ff50cab9ba94fbf127a08cd39f3278dfe90a95ebc14df28cf518e26bac86ad2ba59eaacdb5efc783e58caae8187d670b4f071
6
+ metadata.gz: a0e233e5c53c6ee3c7a32239ca20625a58deb1b385af022cb582433d02070f7b861873f44cef5fe786ede0307caee4105202e49844902e8d75e14039623bdc74
7
+ data.tar.gz: 238ca3ba9a777686867752bc4abbf1d10081d73215e3c0f7ed0e9360f7c380298b4736177c703f1c921fb22b4296e7544379bef69021408f9e9ad86280a3716c
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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.5] - 2026-02-21
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `DecoratorNPlusOne`** - Detects N+1 queries inside decorator/presenter classes
15
+ - Catches `object.comments.map(...)`, `__getobj__.items.each { ... }`, `model.posts`, `source.tags` patterns
16
+ - Identifies decorator classes by inheritance (`Draper::Decorator`, `SimpleDelegator`, `Delegator`) or name suffix (`Decorator`, `Presenter`, `ViewObject`)
17
+ - Targets all four object reference styles: `object`, `__getobj__`, `source`, `model`
18
+ - Skips ActiveStorage methods (`attached?`, `blob`, `variant`, etc.) to prevent false positives
19
+ - Suggests eager loading in the controller before decorating the collection
20
+
10
21
  ## [1.2.4] - 2026-02-21
11
22
 
12
23
  ### 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.5-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
@@ -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
@@ -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
@@ -87,7 +90,8 @@ module EagerEye
87
90
 
88
91
  if iteration_block?(node)
89
92
  block_var = extract_block_variable(node)
90
- if block_var && contains_ar_query_on_variable?(node, block_var)
93
+ collection = node.children[0].children[0]
94
+ if block_var && !non_ar_collection?(collection) && contains_ar_query_on_variable?(node, block_var)
91
95
  add_iteration_issue(node, method_name, callback_type)
92
96
  find_query_calls_in_block(node, method_name, callback_type, block_var)
93
97
  end
@@ -128,26 +132,41 @@ module EagerEye
128
132
 
129
133
  def add_query_issue(node, method_name, callback_type)
130
134
  query_method = node.children[1]
135
+ suggestion = if transactional_callback?(callback_type)
136
+ "Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
137
+ else
138
+ "Callbacks run on every save/create/update. Consider moving to a background job"
139
+ end
131
140
 
132
141
  @issues << create_issue(
133
142
  file_path: @file_path,
134
143
  line_number: node.loc.line,
135
144
  message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
136
145
  severity: :warning,
137
- suggestion: "Callbacks run on every save/create/update. Consider moving to a background job"
146
+ suggestion: suggestion
138
147
  )
139
148
  end
140
149
 
141
150
  def add_iteration_issue(node, method_name, callback_type)
151
+ suggestion = if transactional_callback?(callback_type)
152
+ "Avoid DB queries in before_*/around_* callbacks. Preload data outside the iteration instead"
153
+ else
154
+ "Avoid iterations in callbacks. Use background jobs for bulk operations"
155
+ end
156
+
142
157
  @issues << create_issue(
143
158
  file_path: @file_path,
144
159
  line_number: node.loc.line,
145
160
  message: "Iteration found in `#{callback_type}` callback `:#{method_name}` - potential N+1",
146
161
  severity: :error,
147
- suggestion: "Avoid iterations in callbacks. Use background jobs for bulk operations"
162
+ suggestion: suggestion
148
163
  )
149
164
  end
150
165
 
166
+ def transactional_callback?(callback_type)
167
+ TRANSACTIONAL_CALLBACKS.include?(callback_type)
168
+ end
169
+
151
170
  def extract_block_variable(block_node)
152
171
  args_node = block_node.children[1]
153
172
  return nil unless args_node&.type == :args
@@ -171,6 +190,22 @@ module EagerEye
171
190
  end
172
191
  end
173
192
 
193
+ def non_ar_collection?(node)
194
+ ns = root_namespace(node)
195
+ ns && NON_AR_NAMESPACES.include?(ns)
196
+ end
197
+
198
+ def root_namespace(node)
199
+ return nil unless node.is_a?(Parser::AST::Node)
200
+
201
+ case node.type
202
+ when :const
203
+ node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
204
+ when :send, :block
205
+ root_namespace(node.children[0])
206
+ end
207
+ end
208
+
174
209
  def contains_ar_query_on_variable?(node, block_var)
175
210
  return false unless node.is_a?(Parser::AST::Node)
176
211
  return true if query_call?(node) && receiver_chain_starts_with?(node.children[0], block_var)
@@ -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
@@ -52,9 +53,17 @@ module EagerEye
52
53
 
53
54
  def count_on_association?(node, block_var)
54
55
  node.type == :send && COUNT_METHODS.include?(node.children[1]) &&
56
+ !array_returning_method?(node.children[0]) &&
55
57
  association_call_on_block_var?(node.children[0], block_var)
56
58
  end
57
59
 
60
+ def array_returning_method?(node)
61
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :send
62
+
63
+ method_name = node.children[1].to_s
64
+ ARRAY_METHOD_SUFFIXES.any? { |suffix| method_name.end_with?(suffix) }
65
+ end
66
+
58
67
  def association_call_on_block_var?(node, block_var)
59
68
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
60
69
 
@@ -7,7 +7,7 @@ module EagerEye
7
7
  maximum].freeze
8
8
  SAFE_QUERY_METHODS = %i[first last take count sum find size length ids].freeze
9
9
  SAFE_TRANSFORM_METHODS = %i[keys values split [] params sort pluck ids to_s to_a to_i chars bytes].freeze
10
- ARRAY_COLUMN_SUFFIXES = %w[_ids _tags _types _codes _names _values].freeze
10
+ ARRAY_COLUMN_SUFFIXES = %w[_ids _tags _types _codes _names _values _arr].freeze
11
11
  ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
12
12
  find_each find_in_batches in_batches].freeze
13
13
 
@@ -62,6 +62,7 @@ module EagerEye
62
62
  method_name = node.children[1]
63
63
  return false unless QUERY_METHODS.include?(method_name)
64
64
  return false if skip_array_method?(node, block_var, is_array_collection)
65
+ return false if receiver_is_query_chain?(node.children[0])
65
66
 
66
67
  receiver_chain_starts_with?(node.children[0], block_var)
67
68
  end
@@ -130,6 +131,10 @@ module EagerEye
130
131
  SAFE_TRANSFORM_METHODS.include?(method_name) || array_column_method?(method_name)
131
132
  end
132
133
 
134
+ def receiver_is_query_chain?(node)
135
+ node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
136
+ end
137
+
133
138
  def array_column_method?(method_name)
134
139
  method_str = method_name.to_s
135
140
  ARRAY_COLUMN_SUFFIXES.any? { |suffix| method_str.end_with?(suffix) }
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class DecoratorNPlusOne < Base
6
+ DECORATOR_PATTERNS = %w[Draper::Decorator SimpleDelegator Delegator].freeze
7
+ OBJECT_REFS = %i[object __getobj__ source model].freeze
8
+ ACTIVE_STORAGE_METHODS = %i[attached? attach attachment attachments blob blobs purge purge_later variant
9
+ preview].freeze
10
+ HAS_MANY_ASSOCIATIONS = %w[
11
+ authors users owners creators admins members customers clients
12
+ posts articles comments categories tags children companies organizations
13
+ projects tasks items orders products accounts profiles settings
14
+ images avatars photos attachments documents
15
+ ].freeze
16
+
17
+ def self.detector_name
18
+ :decorator_n_plus_one
19
+ end
20
+
21
+ def detect(ast, file_path)
22
+ return [] unless ast
23
+
24
+ issues = []
25
+
26
+ traverse_ast(ast) do |node|
27
+ next unless decorator_class?(node)
28
+
29
+ find_association_accesses(node, file_path, issues)
30
+ end
31
+
32
+ issues
33
+ end
34
+
35
+ private
36
+
37
+ def decorator_class?(node)
38
+ return false unless node.type == :class
39
+
40
+ class_name = extract_class_name(node)
41
+ return false unless class_name
42
+
43
+ decorator_name_pattern?(class_name) || inherits_from_decorator?(node)
44
+ end
45
+
46
+ def decorator_name_pattern?(class_name)
47
+ class_name.end_with?("Decorator", "Presenter", "ViewObject")
48
+ end
49
+
50
+ def extract_class_name(class_node)
51
+ name_node = class_node.children[0]
52
+ name_node.children[1].to_s if name_node&.type == :const
53
+ end
54
+
55
+ def inherits_from_decorator?(class_node)
56
+ parent_node = class_node.children[1]
57
+ return false unless parent_node
58
+
59
+ parent_name = const_to_string(parent_node)
60
+ DECORATOR_PATTERNS.any? { |p| parent_name&.include?(p.split("::").last) }
61
+ end
62
+
63
+ def const_to_string(node)
64
+ return nil unless node&.type == :const
65
+
66
+ parts = []
67
+ current = node
68
+ while current&.type == :const
69
+ parts.unshift(current.children[1].to_s)
70
+ current = current.children[0]
71
+ end
72
+ parts.join("::")
73
+ end
74
+
75
+ def find_association_accesses(class_node, file_path, issues)
76
+ body = class_node.children[2]
77
+ return unless body
78
+
79
+ traverse_ast(body) do |node|
80
+ next unless node.type == :def
81
+
82
+ find_associations_in_method(node.children[2], file_path, issues)
83
+ end
84
+ end
85
+
86
+ def find_associations_in_method(method_body, file_path, issues)
87
+ return unless method_body
88
+
89
+ storage_lines = collect_active_storage_lines(method_body)
90
+ traverse_ast(method_body) do |node|
91
+ next unless association_access?(node, storage_lines)
92
+
93
+ receiver = node.children[0]
94
+ method_name = node.children[1]
95
+ issues << create_decorator_issue(file_path, node.loc.line, receiver, method_name)
96
+ end
97
+ end
98
+
99
+ def association_access?(node, storage_lines)
100
+ return false unless node.type == :send
101
+ return false if storage_lines.include?(node.loc.line)
102
+
103
+ object_reference?(node.children[0]) && likely_association?(node.children[1])
104
+ end
105
+
106
+ def collect_active_storage_lines(body)
107
+ lines = Set.new
108
+ traverse_ast(body) do |node|
109
+ next unless node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
110
+
111
+ lines << node.loc.line
112
+ end
113
+ lines
114
+ end
115
+
116
+ def object_reference?(node)
117
+ return false unless node&.type == :send
118
+
119
+ node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
120
+ end
121
+
122
+ def likely_association?(method_name)
123
+ HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
124
+ end
125
+
126
+ def create_decorator_issue(file_path, line, receiver, method_name)
127
+ ref = receiver.children[1]
128
+ create_issue(
129
+ file_path: file_path,
130
+ line_number: line,
131
+ message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
132
+ suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
133
+ )
134
+ end
135
+ end
136
+ end
137
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.4"
4
+ VERSION = "1.2.5"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "eager_eye/detectors/count_in_iteration"
15
15
  require_relative "eager_eye/detectors/callback_query"
16
16
  require_relative "eager_eye/detectors/pluck_to_array"
17
17
  require_relative "eager_eye/detectors/delegation_n_plus_one"
18
+ require_relative "eager_eye/detectors/decorator_n_plus_one"
18
19
  require_relative "eager_eye/comment_parser"
19
20
  require_relative "eager_eye/analyzer"
20
21
  require_relative "eager_eye/fixers/base"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.4
4
+ version: 1.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-21 00:00:00.000000000 Z
11
+ date: 2026-02-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -70,6 +70,7 @@ files:
70
70
  - lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
71
71
  - lib/eager_eye/detectors/count_in_iteration.rb
72
72
  - lib/eager_eye/detectors/custom_method_query.rb
73
+ - lib/eager_eye/detectors/decorator_n_plus_one.rb
73
74
  - lib/eager_eye/detectors/delegation_n_plus_one.rb
74
75
  - lib/eager_eye/detectors/loop_association.rb
75
76
  - lib/eager_eye/detectors/missing_counter_cache.rb