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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +41 -8
- data/lib/eager_eye/analyzer.rb +9 -2
- data/lib/eager_eye/configuration.rb +3 -2
- data/lib/eager_eye/detectors/scope_chain_n_plus_one.rb +78 -0
- data/lib/eager_eye/scope_parser.rb +45 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79319b66a87234e31df5def9ba5c0a487e298de8620bd4022f9d7db63e455152
|
|
4
|
+
data.tar.gz: f400ab8eb6ca1762a9773f90d8d1311546ed58035b3a062c6d408f7a97fd17e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
588
|
-
delegation_n_plus_one: warning
|
|
589
|
-
decorator_n_plus_one: warning
|
|
590
|
-
scope_chain_n_plus_one: warning
|
|
591
|
-
|
|
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
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -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
|
data/lib/eager_eye/version.rb
CHANGED
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.
|
|
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
|