eager_eye 1.2.7 → 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: dd5177a12d64b54cc6cd426440f79018da9ccc7dd931db70c491c9154bb385a1
4
- data.tar.gz: dec16deb3b55ab821225d5d0e06a6fa308c41fd472dad405bf125c7e5839f2e0
3
+ metadata.gz: 79319b66a87234e31df5def9ba5c0a487e298de8620bd4022f9d7db63e455152
4
+ data.tar.gz: f400ab8eb6ca1762a9773f90d8d1311546ed58035b3a062c6d408f7a97fd17e2
5
5
  SHA512:
6
- metadata.gz: 4336421ff71269732f594163dc3dbe29ca70d1b10c19dd333d972ea63bff9da36a4e83a041263fa6f61d758c8b0551912e075e00ce8f05978ab59c7db698af41
7
- data.tar.gz: 51de528f0dca6b3279c0cb1fe6f21e5c35592dda81220419c209d8d43faf35b41375cbfffd33a463546f5e81e4807f8c48268f618d8383c82a570f81da823efa
6
+ metadata.gz: d70aff4b63cc9da79ca7956487a94d0e950aa85416b64636c1a2d8f94f898c1aea04712d3e964aaf5aafd8eea4fca9a817f09021806462123cc1b4bd45bec9ff
7
+ data.tar.gz: f77947b4812e87380618ac4544e717b5b17f08386540d6142a0e9f9fbbf9ed8fbdd6460beb8671e0a6e4fb8e8d2f21c1417eb9281082f5da67576bfd51990ab0
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.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
+
10
21
  ## [1.2.7] - 2026-03-10
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.7-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 10 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
@@ -53,6 +53,7 @@
53
53
  - Delegation N+1s (hidden via `delegate :method, to: :association`)
54
54
  - Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
55
55
  - Scope chain N+1s (named scopes on associations in loops)
56
+ - Validation N+1s (uniqueness validation in batch create/save)
56
57
 
57
58
  🔧 **Developer-friendly:**
58
59
  - Inline suppression (like RuboCop)
@@ -426,6 +427,35 @@ EagerEye detects these by:
426
427
  1. Scanning model files for `scope :name, -> { ... }` declarations
427
428
  2. Flagging known scope names called on association chains inside iteration blocks
428
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
+
429
459
  ## Inline Suppression
430
460
 
431
461
  Suppress false positives using inline comments (RuboCop-style):
@@ -471,6 +501,7 @@ Both CamelCase and snake_case formats are accepted:
471
501
  | Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
472
502
  | Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
473
503
  | Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
504
+ | Validation N+1 | `ValidationNPlusOne` | `validation_n_plus_one` |
474
505
  | All Detectors | `all` | `all` |
475
506
 
476
507
  ## Auto-fix (Experimental)
@@ -576,19 +607,21 @@ enabled_detectors:
576
607
  - delegation_n_plus_one
577
608
  - decorator_n_plus_one
578
609
  - scope_chain_n_plus_one
610
+ - validation_n_plus_one
579
611
 
580
612
  # Severity levels per detector (error, warning, info)
581
613
  severity_levels:
582
- loop_association: error # Definite N+1
614
+ loop_association: error # Definite N+1
583
615
  serializer_nesting: warning
584
616
  custom_method_query: warning
585
617
  count_in_iteration: warning
586
618
  callback_query: warning
587
- pluck_to_array: warning # Optimization
588
- delegation_n_plus_one: warning # Hidden delegation N+1
589
- decorator_n_plus_one: warning # Decorator/Presenter N+1
590
- scope_chain_n_plus_one: warning # Scope chain on association
591
- 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
592
625
 
593
626
  # Minimum severity to report (default: info)
594
627
  min_severity: warning
@@ -14,10 +14,11 @@ module EagerEye
14
14
  pluck_to_array: Detectors::PluckToArray,
15
15
  delegation_n_plus_one: Detectors::DelegationNPlusOne,
16
16
  decorator_n_plus_one: Detectors::DecoratorNPlusOne,
17
- scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne
17
+ scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne,
18
+ validation_n_plus_one: Detectors::ValidationNPlusOne
18
19
  }.freeze
19
20
 
20
- attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps
21
+ attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps, :uniqueness_models
21
22
 
22
23
  def initialize(paths: nil)
23
24
  @paths = Array(paths || EagerEye.configuration.app_path)
@@ -25,6 +26,7 @@ module EagerEye
25
26
  @association_preloads = {}
26
27
  @delegation_maps = {}
27
28
  @scope_maps = {}
29
+ @uniqueness_models = Set.new
28
30
  end
29
31
 
30
32
  def run
@@ -54,6 +56,10 @@ module EagerEye
54
56
  scope_parser = ScopeParser.new
55
57
  scope_parser.parse_model(ast, model_name)
56
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)
57
63
  rescue Errno::ENOENT, Errno::EACCES
58
64
  next
59
65
  end
@@ -115,6 +121,7 @@ module EagerEye
115
121
  args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
116
122
  args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
117
123
  args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
124
+ args << @uniqueness_models if detector.is_a?(Detectors::ValidationNPlusOne)
118
125
  args
119
126
  end
120
127
 
@@ -9,7 +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
12
+ scope_chain_n_plus_one validation_n_plus_one
13
13
  ].freeze
14
14
 
15
15
  DEFAULT_SEVERITY_LEVELS = {
@@ -22,7 +22,8 @@ module EagerEye
22
22
  pluck_to_array: :warning,
23
23
  delegation_n_plus_one: :warning,
24
24
  decorator_n_plus_one: :warning,
25
- scope_chain_n_plus_one: :warning
25
+ scope_chain_n_plus_one: :warning,
26
+ validation_n_plus_one: :warning
26
27
  }.freeze
27
28
 
28
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.7"
4
+ VERSION = "1.2.8"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "eager_eye/issue"
7
7
  require_relative "eager_eye/association_parser"
8
8
  require_relative "eager_eye/delegation_parser"
9
9
  require_relative "eager_eye/scope_parser"
10
+ require_relative "eager_eye/validation_parser"
10
11
  require_relative "eager_eye/detectors/base"
11
12
  require_relative "eager_eye/detectors/loop_association"
12
13
  require_relative "eager_eye/detectors/serializer_nesting"
@@ -18,6 +19,7 @@ require_relative "eager_eye/detectors/pluck_to_array"
18
19
  require_relative "eager_eye/detectors/delegation_n_plus_one"
19
20
  require_relative "eager_eye/detectors/decorator_n_plus_one"
20
21
  require_relative "eager_eye/detectors/scope_chain_n_plus_one"
22
+ require_relative "eager_eye/detectors/validation_n_plus_one"
21
23
  require_relative "eager_eye/comment_parser"
22
24
  require_relative "eager_eye/analyzer"
23
25
  require_relative "eager_eye/fixers/base"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.7
4
+ version: 1.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
@@ -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