eager_eye 1.2.6 → 1.2.8

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: f996747b6c12e539180e66cc74c11c70acfab4a6e4b84aed4039cc7b58dafe53
4
- data.tar.gz: f23d5a21ceecc39c71b0303a5748472a8ea5e0f5f3256ffd5a10084a5af8a39c
3
+ metadata.gz: 79319b66a87234e31df5def9ba5c0a487e298de8620bd4022f9d7db63e455152
4
+ data.tar.gz: f400ab8eb6ca1762a9773f90d8d1311546ed58035b3a062c6d408f7a97fd17e2
5
5
  SHA512:
6
- metadata.gz: a75d25669ab2d7e96dbccb107ae30f817e5fd6a291651b68bc56dbea21f50bd5c8b24b0633dd21d6faeeb5a8abd999abc6baf57ac9c7e8c3c06aa4542dc570d3
7
- data.tar.gz: 7d2e5af151c02923bf4e64f05bc277711228729fa08ddee3dd79699623efc33f3689290c1739c025d7851db0b59d90feeac99e3a65c9a13862fac8f36ddf46b6
6
+ metadata.gz: d70aff4b63cc9da79ca7956487a94d0e950aa85416b64636c1a2d8f94f898c1aea04712d3e964aaf5aafd8eea4fca9a817f09021806462123cc1b4bd45bec9ff
7
+ data.tar.gz: f77947b4812e87380618ac4544e717b5b17f08386540d6142a0e9f9fbbf9ed8fbdd6460beb8671e0a6e4fb8e8d2f21c1417eb9281082f5da67576bfd51990ab0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.8] - 2026-03-10
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `ValidationNPlusOne`** - Detects batch create/save inside iterations for models with uniqueness validations
15
+ - Catches `User.create!(attrs)` and `User.new(attrs) + save!` patterns inside loops
16
+ - Parses model files for `validates :attr, uniqueness: true` and `validates_uniqueness_of` declarations
17
+ - Each uniqueness validation triggers a SELECT query per record — 2N queries total
18
+ - Suggests using `insert_all` with unique index constraints or batch-validating before saving
19
+ - **New Parser: `ValidationParser`** - Collects models with uniqueness validations for cross-file detection
20
+
21
+ ## [1.2.7] - 2026-03-10
22
+
23
+ ### Added
24
+
25
+ - **New Detector: `ScopeChainNPlusOne`** - Detects scope calls on associations inside iterations
26
+ - Catches `post.comments.recent`, `post.comments.approved.count` patterns
27
+ - Parses model files for `scope :name, -> { ... }` declarations
28
+ - Flags known scope names called on association chains inside loops
29
+ - Each scope call executes a new query per iteration — suggests preloading or joined queries
30
+ - **New Parser: `ScopeParser`** - Collects scope definitions from model files for cross-file detection
31
+
10
32
  ## [1.2.6] - 2026-02-25
11
33
 
12
34
  ### Changed
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.6-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.8-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 9 types of N+1 problems:**
45
+ ✨ **Detects 11 types of N+1 problems:**
46
46
  - Loop associations (queries in iterations)
47
47
  - Serializer nesting issues
48
48
  - Missing counter caches
@@ -52,6 +52,8 @@
52
52
  - Pluck to array misuse
53
53
  - Delegation N+1s (hidden via `delegate :method, to: :association`)
54
54
  - Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
55
+ - Scope chain N+1s (named scopes on associations in loops)
56
+ - Validation N+1s (uniqueness validation in batch create/save)
55
57
 
56
58
  🔧 **Developer-friendly:**
57
59
  - Inline suppression (like RuboCop)
@@ -398,6 +400,62 @@ Supports the following object references inside decorators:
398
400
  - `__getobj__` — SimpleDelegator standard
399
401
  - `source`, `model` — alternative Draper aliases
400
402
 
403
+ ### 10. Scope Chain N+1
404
+
405
+ Detects named scope calls on associations inside iterations. Unlike explicit query methods (`.where`, `.find_by`) caught by `CustomMethodQuery`, named scopes (`.recent`, `.active`, `.published`) are invisible query triggers.
406
+
407
+ ```ruby
408
+ # Model
409
+ class Comment < ApplicationRecord
410
+ scope :recent, -> { where("created_at > ?", 1.week.ago) }
411
+ scope :approved, -> { where(approved: true) }
412
+ end
413
+
414
+ # Bad - scope call per iteration
415
+ posts.each do |post|
416
+ post.comments.recent # Query for each post!
417
+ post.comments.approved.count # Query for each post!
418
+ end
419
+
420
+ # Good - preload and filter in Ruby
421
+ posts.includes(:comments).each do |post|
422
+ post.comments.select { |c| c.created_at > 1.week.ago }
423
+ end
424
+ ```
425
+
426
+ EagerEye detects these by:
427
+ 1. Scanning model files for `scope :name, -> { ... }` declarations
428
+ 2. Flagging known scope names called on association chains inside iteration blocks
429
+
430
+ ### 11. Validation N+1
431
+
432
+ Detects batch create/save operations inside iterations for models with `validates uniqueness`. Each uniqueness validation triggers a SELECT query per record, resulting in 2N queries (SELECT + INSERT per record).
433
+
434
+ ```ruby
435
+ # Model
436
+ class User < ApplicationRecord
437
+ validates :email, uniqueness: true
438
+ end
439
+
440
+ # Bad - 2N queries (SELECT + INSERT per record)
441
+ params[:users].each do |user_params|
442
+ User.create!(user_params) # SELECT + INSERT for each!
443
+ end
444
+
445
+ # Bad - same problem with new + save
446
+ params[:users].each do |user_params|
447
+ user = User.new(user_params)
448
+ user.save! # SELECT + INSERT for each!
449
+ end
450
+
451
+ # Good - use insert_all with unique index
452
+ User.insert_all(params[:users]) # Single bulk INSERT, DB enforces uniqueness
453
+ ```
454
+
455
+ EagerEye detects these by:
456
+ 1. Scanning model files for `validates :attr, uniqueness: true` or `validates_uniqueness_of` declarations
457
+ 2. Flagging `Model.create/create!` or `Model.new` + `.save/.save!` patterns inside iteration blocks
458
+
401
459
  ## Inline Suppression
402
460
 
403
461
  Suppress false positives using inline comments (RuboCop-style):
@@ -442,6 +500,8 @@ Both CamelCase and snake_case formats are accepted:
442
500
  | Pluck to Array | `PluckToArray` | `pluck_to_array` |
443
501
  | Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
444
502
  | Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
503
+ | Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
504
+ | Validation N+1 | `ValidationNPlusOne` | `validation_n_plus_one` |
445
505
  | All Detectors | `all` | `all` |
446
506
 
447
507
  ## Auto-fix (Experimental)
@@ -546,18 +606,22 @@ enabled_detectors:
546
606
  - pluck_to_array
547
607
  - delegation_n_plus_one
548
608
  - decorator_n_plus_one
609
+ - scope_chain_n_plus_one
610
+ - validation_n_plus_one
549
611
 
550
612
  # Severity levels per detector (error, warning, info)
551
613
  severity_levels:
552
- loop_association: error # Definite N+1
614
+ loop_association: error # Definite N+1
553
615
  serializer_nesting: warning
554
616
  custom_method_query: warning
555
617
  count_in_iteration: warning
556
618
  callback_query: warning
557
- pluck_to_array: warning # Optimization
558
- delegation_n_plus_one: warning # Hidden delegation N+1
559
- decorator_n_plus_one: warning # Decorator/Presenter N+1
560
- missing_counter_cache: info # Suggestion
619
+ pluck_to_array: warning # Optimization
620
+ delegation_n_plus_one: warning # Hidden delegation N+1
621
+ decorator_n_plus_one: warning # Decorator/Presenter N+1
622
+ scope_chain_n_plus_one: warning # Scope chain on association
623
+ validation_n_plus_one: warning # Uniqueness validation in batch
624
+ missing_counter_cache: info # Suggestion
561
625
 
562
626
  # Minimum severity to report (default: info)
563
627
  min_severity: warning
@@ -13,16 +13,20 @@ module EagerEye
13
13
  callback_query: Detectors::CallbackQuery,
14
14
  pluck_to_array: Detectors::PluckToArray,
15
15
  delegation_n_plus_one: Detectors::DelegationNPlusOne,
16
- decorator_n_plus_one: Detectors::DecoratorNPlusOne
16
+ decorator_n_plus_one: Detectors::DecoratorNPlusOne,
17
+ scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne,
18
+ validation_n_plus_one: Detectors::ValidationNPlusOne
17
19
  }.freeze
18
20
 
19
- attr_reader :paths, :issues, :association_preloads, :delegation_maps
21
+ attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps, :uniqueness_models
20
22
 
21
23
  def initialize(paths: nil)
22
24
  @paths = Array(paths || EagerEye.configuration.app_path)
23
25
  @issues = []
24
26
  @association_preloads = {}
25
27
  @delegation_maps = {}
28
+ @scope_maps = {}
29
+ @uniqueness_models = Set.new
26
30
  end
27
31
 
28
32
  def run
@@ -48,6 +52,14 @@ module EagerEye
48
52
  deleg_parser = DelegationParser.new
49
53
  deleg_parser.parse_model(ast, model_name)
50
54
  @delegation_maps.merge!(deleg_parser.delegation_maps)
55
+
56
+ scope_parser = ScopeParser.new
57
+ scope_parser.parse_model(ast, model_name)
58
+ @scope_maps.merge!(scope_parser.scope_maps)
59
+
60
+ validation_parser = ValidationParser.new
61
+ validation_parser.parse_model(ast, model_name)
62
+ @uniqueness_models.merge(validation_parser.uniqueness_models)
51
63
  rescue Errno::ENOENT, Errno::EACCES
52
64
  next
53
65
  end
@@ -108,6 +120,8 @@ module EagerEye
108
120
  args = [ast, file_path]
109
121
  args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
110
122
  args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
123
+ args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
124
+ args << @uniqueness_models if detector.is_a?(Detectors::ValidationNPlusOne)
111
125
  args
112
126
  end
113
127
 
@@ -9,6 +9,7 @@ module EagerEye
9
9
  loop_association serializer_nesting missing_counter_cache
10
10
  custom_method_query count_in_iteration callback_query
11
11
  pluck_to_array delegation_n_plus_one decorator_n_plus_one
12
+ scope_chain_n_plus_one validation_n_plus_one
12
13
  ].freeze
13
14
 
14
15
  DEFAULT_SEVERITY_LEVELS = {
@@ -20,7 +21,9 @@ module EagerEye
20
21
  callback_query: :warning,
21
22
  pluck_to_array: :warning,
22
23
  delegation_n_plus_one: :warning,
23
- decorator_n_plus_one: :warning
24
+ decorator_n_plus_one: :warning,
25
+ scope_chain_n_plus_one: :warning,
26
+ validation_n_plus_one: :warning
24
27
  }.freeze
25
28
 
26
29
  VALID_SEVERITIES = %i[info warning error].freeze
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class ScopeChainNPlusOne < Base
6
+ ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
7
+ find_each find_in_batches in_batches].freeze
8
+
9
+ def self.detector_name
10
+ :scope_chain_n_plus_one
11
+ end
12
+
13
+ def detect(ast, file_path, scope_maps = {})
14
+ return [] unless ast
15
+
16
+ @issues = []
17
+ @file_path = file_path
18
+ @all_scopes = scope_maps.each_value.reduce(Set.new, :merge)
19
+ return [] if @all_scopes.empty?
20
+
21
+ find_iteration_blocks(ast)
22
+ @issues
23
+ end
24
+
25
+ private
26
+
27
+ def find_iteration_blocks(node)
28
+ return unless node.is_a?(Parser::AST::Node)
29
+
30
+ if iteration_block?(node)
31
+ block_var = extract_block_variable(node)
32
+ check_block(node.children[2], block_var) if block_var
33
+ end
34
+
35
+ node.children.each { |child| find_iteration_blocks(child) }
36
+ end
37
+
38
+ def iteration_block?(node)
39
+ node.type == :block && node.children[0]&.type == :send &&
40
+ ITERATION_METHODS.include?(node.children[0].children[1])
41
+ end
42
+
43
+ def check_block(node, block_var)
44
+ return unless node.is_a?(Parser::AST::Node)
45
+
46
+ add_issue(node) if scope_chain_on_association?(node, block_var)
47
+ node.children.each { |child| check_block(child, block_var) }
48
+ end
49
+
50
+ def scope_chain_on_association?(node, block_var)
51
+ return false unless node.type == :send
52
+
53
+ method_name = node.children[1]
54
+ return false unless @all_scopes.include?(method_name)
55
+
56
+ receiver = node.children[0]
57
+ receiver_chain_starts_with?(receiver, block_var) && chain_depth(receiver, block_var) >= 1
58
+ end
59
+
60
+ def chain_depth(node, block_var)
61
+ return 0 unless node.is_a?(Parser::AST::Node)
62
+ return 0 if node.type == :lvar && node.children[0] == block_var
63
+
64
+ node.type == :send ? 1 + chain_depth(node.children[0], block_var) : 0
65
+ end
66
+
67
+ def add_issue(node)
68
+ chain = reconstruct_chain(node.children[0])
69
+ @issues << create_issue(
70
+ file_path: @file_path,
71
+ line_number: node.loc.line,
72
+ message: "Scope `.#{node.children[1]}` called on `#{chain}` inside iteration",
73
+ suggestion: "Each scope call executes a new query. Consider preloading or using a joined query."
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ class ScopeParser
5
+ attr_reader :scope_maps
6
+
7
+ def initialize
8
+ @scope_maps = {}
9
+ end
10
+
11
+ def parse_model(ast, model_name)
12
+ return unless ast
13
+
14
+ traverse(ast, model_name)
15
+ end
16
+
17
+ private
18
+
19
+ def traverse(node, model_name)
20
+ return unless node.is_a?(Parser::AST::Node)
21
+
22
+ check_scope(node, model_name)
23
+ node.children.each { |child| traverse(child, model_name) }
24
+ end
25
+
26
+ def check_scope(node, model_name)
27
+ return unless scope_call?(node)
28
+
29
+ scope_name = extract_scope_name(node)
30
+ return unless scope_name
31
+
32
+ @scope_maps[model_name] ||= Set.new
33
+ @scope_maps[model_name] << scope_name
34
+ end
35
+
36
+ def scope_call?(node)
37
+ node.type == :send && node.children[0].nil? && node.children[1] == :scope
38
+ end
39
+
40
+ def extract_scope_name(node)
41
+ first_arg = node.children[2]
42
+ first_arg&.type == :sym ? first_arg.children[0] : nil
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.6"
4
+ VERSION = "1.2.8"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -6,6 +6,8 @@ require_relative "eager_eye/configuration"
6
6
  require_relative "eager_eye/issue"
7
7
  require_relative "eager_eye/association_parser"
8
8
  require_relative "eager_eye/delegation_parser"
9
+ require_relative "eager_eye/scope_parser"
10
+ require_relative "eager_eye/validation_parser"
9
11
  require_relative "eager_eye/detectors/base"
10
12
  require_relative "eager_eye/detectors/loop_association"
11
13
  require_relative "eager_eye/detectors/serializer_nesting"
@@ -16,6 +18,8 @@ require_relative "eager_eye/detectors/callback_query"
16
18
  require_relative "eager_eye/detectors/pluck_to_array"
17
19
  require_relative "eager_eye/detectors/delegation_n_plus_one"
18
20
  require_relative "eager_eye/detectors/decorator_n_plus_one"
21
+ require_relative "eager_eye/detectors/scope_chain_n_plus_one"
22
+ require_relative "eager_eye/detectors/validation_n_plus_one"
19
23
  require_relative "eager_eye/comment_parser"
20
24
  require_relative "eager_eye/analyzer"
21
25
  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.6
4
+ version: 1.2.8
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-25 00:00:00.000000000 Z
11
+ date: 2026-03-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -76,6 +76,7 @@ files:
76
76
  - lib/eager_eye/detectors/loop_association.rb
77
77
  - lib/eager_eye/detectors/missing_counter_cache.rb
78
78
  - lib/eager_eye/detectors/pluck_to_array.rb
79
+ - lib/eager_eye/detectors/scope_chain_n_plus_one.rb
79
80
  - lib/eager_eye/detectors/serializer_nesting.rb
80
81
  - lib/eager_eye/fixer_registry.rb
81
82
  - lib/eager_eye/fixers/base.rb
@@ -88,6 +89,7 @@ files:
88
89
  - lib/eager_eye/reporters/json.rb
89
90
  - lib/eager_eye/rspec.rb
90
91
  - lib/eager_eye/rspec/matchers.rb
92
+ - lib/eager_eye/scope_parser.rb
91
93
  - lib/eager_eye/version.rb
92
94
  - sig/eager_eye.rbs
93
95
  homepage: https://github.com/hamzagedikkaya/eager_eye