eager_eye 1.2.12 → 1.2.14
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 +22 -0
- data/README.md +15 -3
- data/lib/eager_eye/analyzer.rb +7 -3
- data/lib/eager_eye/association_parser.rb +6 -1
- data/lib/eager_eye/detectors/concerns/variable_model_inference.rb +64 -0
- data/lib/eager_eye/detectors/custom_method_query.rb +76 -11
- data/lib/eager_eye/detectors/loop_association.rb +164 -39
- data/lib/eager_eye/detectors/pluck_to_array.rb +29 -3
- data/lib/eager_eye/fixers/add_includes.rb +62 -0
- data/lib/eager_eye/fixers/count_to_size.rb +24 -0
- data/lib/eager_eye/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 102d5299d625a885f2916d113d68a966cfb6c261bcebb8e5caf53f7d497c2a97
|
|
4
|
+
data.tar.gz: 64e954f730dc260001a80540e6304aeed6c0838e6f003ea6c39dd166b93850ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39a182c18271764a2258a2d5f416cd1c44dba5c1d18c11b74fd2ab6025d36569cf28778320cf66cb2c935f501e3310f8fdae7b74e550b6bdf24f9e28e2680c83
|
|
7
|
+
data.tar.gz: 85297df8068df3aa88d2a69be379d8139850b82841cb98f90899e418fdff93cd360ea54f5c94a0d6722ab81b315cc3805c13bb66645d7f64a58e9f9dff6e826c
|
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.14] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Fixed (false-positive reduction pass)
|
|
13
|
+
|
|
14
|
+
- **Pagy / multi-assignment preload tracking** — `LoopAssociation` and `CustomMethodQuery` now follow preloads through `@pagy, items = pagy(query)` patterns and through wrapper calls like `pagy(...)`, `paginate(...)`, etc. Previously the `.includes(...)` on the wrapped query was lost across the multi-assign, generating false N+1 warnings on every preloaded association.
|
|
15
|
+
- **Preloads through conditional / variable chains** — `extract_included_associations_deep` now walks ternary branches and inlined begin-blocks, and resolves `:lvar`/`:ivar` receivers via the variable preload map. `base = cond ? Q.includes(...).where(...) : Q.none` no longer drops preloads.
|
|
16
|
+
- **Per-model association scoping** — `LoopAssociation` now infers the model class of the iteration variable and, when known, only flags methods that are actually associations on that model. This eliminates false positives where column accessors collide with hardcoded names (e.g. `log.tag`, `log.image`) or with associations defined on unrelated models.
|
|
17
|
+
- **Per-model `method_queries` lookup** — `LoopAssociation` and `CustomMethodQuery` no longer flag `obj.foo` just because *some other* model defines a `def foo` containing a query. The match is scoped to the receiver's model when known, and association names on that model are excluded from the query-method check.
|
|
18
|
+
- **`update_all` / `delete_all` / `destroy_all` chains** — `LoopAssociation` no longer flags association access whose only purpose is a non-loading terminal write (e.g. `avm.merchant_branches.update_all(...)`), since these don't trigger a SELECT for the association.
|
|
19
|
+
- **`PluckToArray` `.where(...).all.pluck(:id)` mis-classification** — only an *unscoped* `.all.pluck(:id)` is escalated to error severity ("loads entire table"); chains scoped by `where`/`joins`/`limit`/etc. are no longer misreported as table-scans.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `AssociationParser` now exposes `associations_by_model` (a per-model `Set` of association names). Models with no declared associations are still registered, so detectors can distinguish "no associations" from "model unknown".
|
|
24
|
+
- Detector signatures: `LoopAssociation#detect` and `CustomMethodQuery#detect` accept an `associations_by_model` argument (defaults to `{}`).
|
|
25
|
+
|
|
26
|
+
## [1.2.13] - 2026-03-23
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Fixed gem packaging issue where `CountToSize` and `AddIncludes` auto-fixer files were missing from the published gem
|
|
31
|
+
|
|
10
32
|
## [1.2.12] - 2026-03-20
|
|
11
33
|
|
|
12
34
|
### 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.14-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>
|
|
@@ -57,7 +57,8 @@
|
|
|
57
57
|
|
|
58
58
|
🔧 **Developer-friendly:**
|
|
59
59
|
- Inline suppression (like RuboCop)
|
|
60
|
-
- Auto-fix support (
|
|
60
|
+
- Auto-fix support (3 fixers: PluckToSelect, CountToSize, AddIncludes)
|
|
61
|
+
- `.jbuilder` file support (`json.array!` iteration detection)
|
|
61
62
|
- JSON/Console output formats
|
|
62
63
|
- RSpec integration
|
|
63
64
|
|
|
@@ -524,6 +525,8 @@ eager_eye --fix --force
|
|
|
524
525
|
| Issue | Fix |
|
|
525
526
|
|-------|-----|
|
|
526
527
|
| `.pluck(:id)` inline | → `.select(:id)` |
|
|
528
|
+
| `.count` in iteration | → `.size` |
|
|
529
|
+
| Missing `includes` before loop | → `.includes(:assoc)` inserted |
|
|
527
530
|
|
|
528
531
|
### Example
|
|
529
532
|
|
|
@@ -535,6 +538,15 @@ app/services/user_service.rb:
|
|
|
535
538
|
- Post.where(user_id: User.active.pluck(:id))
|
|
536
539
|
+ Post.where(user_id: User.active.select(:id))
|
|
537
540
|
|
|
541
|
+
app/controllers/posts_controller.rb:
|
|
542
|
+
Line 8:
|
|
543
|
+
- user.posts.count
|
|
544
|
+
+ user.posts.size
|
|
545
|
+
|
|
546
|
+
Line 5:
|
|
547
|
+
- @posts.each do |post|
|
|
548
|
+
+ @posts.includes(:author).each do |post|
|
|
549
|
+
|
|
538
550
|
$ eager_eye --fix
|
|
539
551
|
app/services/user_service.rb:12
|
|
540
552
|
- Post.where(user_id: User.active.pluck(:id))
|
|
@@ -739,7 +751,7 @@ EagerEye uses static analysis, which means:
|
|
|
739
751
|
- **No runtime context** - Cannot know if associations are already eager loaded elsewhere
|
|
740
752
|
- **Heuristic-based** - Uses naming conventions to identify associations (may have false positives)
|
|
741
753
|
- **Ruby code only** - Does not analyze SQL queries or ActiveRecord internals
|
|
742
|
-
- **Cross-file scope** - Cross-file analysis
|
|
754
|
+
- **Cross-file scope** - Cross-file analysis covers model-defined query methods; controller-to-view or service-to-service patterns are not yet tracked
|
|
743
755
|
|
|
744
756
|
For best results, use EagerEye alongside runtime tools like Bullet for comprehensive N+1 detection.
|
|
745
757
|
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -19,24 +19,25 @@ module EagerEye
|
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
DETECTOR_EXTRA_ARGS = {
|
|
22
|
-
Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
|
|
22
|
+
Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model],
|
|
23
23
|
Detectors::SerializerNesting => %i[association_names method_queries],
|
|
24
24
|
Detectors::MissingCounterCache => %i[association_names],
|
|
25
25
|
Detectors::DecoratorNPlusOne => %i[association_names method_queries],
|
|
26
|
-
Detectors::CustomMethodQuery => %i[method_queries],
|
|
26
|
+
Detectors::CustomMethodQuery => %i[method_queries associations_by_model],
|
|
27
27
|
Detectors::DelegationNPlusOne => %i[delegation_maps],
|
|
28
28
|
Detectors::ScopeChainNPlusOne => %i[scope_maps],
|
|
29
29
|
Detectors::ValidationNPlusOne => %i[uniqueness_models]
|
|
30
30
|
}.freeze
|
|
31
31
|
|
|
32
32
|
attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
|
|
33
|
-
:scope_maps, :uniqueness_models
|
|
33
|
+
:scope_maps, :uniqueness_models, :associations_by_model
|
|
34
34
|
|
|
35
35
|
def initialize(paths: nil)
|
|
36
36
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
37
37
|
@issues = []
|
|
38
38
|
@association_preloads = {}
|
|
39
39
|
@association_names = Set.new
|
|
40
|
+
@associations_by_model = {}
|
|
40
41
|
@method_queries = {}
|
|
41
42
|
@delegation_maps = {}
|
|
42
43
|
@scope_maps = {}
|
|
@@ -63,6 +64,9 @@ module EagerEye
|
|
|
63
64
|
assoc_parser.parse_model(ast, model_name)
|
|
64
65
|
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
65
66
|
@association_names.merge(assoc_parser.association_names)
|
|
67
|
+
assoc_parser.associations_by_model.each do |m, set|
|
|
68
|
+
(@associations_by_model[m] ||= Set.new).merge(set)
|
|
69
|
+
end
|
|
66
70
|
|
|
67
71
|
deleg_parser = DelegationParser.new
|
|
68
72
|
deleg_parser.parse_model(ast, model_name)
|
|
@@ -4,16 +4,20 @@ module EagerEye
|
|
|
4
4
|
class AssociationParser
|
|
5
5
|
ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
|
|
6
6
|
|
|
7
|
-
attr_reader :preloaded_associations, :association_names
|
|
7
|
+
attr_reader :preloaded_associations, :association_names, :associations_by_model
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@preloaded_associations = {}
|
|
11
11
|
@association_names = Set.new
|
|
12
|
+
@associations_by_model = {}
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def parse_model(ast, model_name)
|
|
15
16
|
return unless ast
|
|
16
17
|
|
|
18
|
+
# Register the model even if it declares no associations, so callers can
|
|
19
|
+
# distinguish "model has no associations" from "model unknown to parser".
|
|
20
|
+
@associations_by_model[model_name] ||= Set.new
|
|
17
21
|
traverse(ast, model_name)
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -37,6 +41,7 @@ module EagerEye
|
|
|
37
41
|
return unless association_name
|
|
38
42
|
|
|
39
43
|
@association_names << association_name
|
|
44
|
+
(@associations_by_model[model_name] ||= Set.new) << association_name
|
|
40
45
|
|
|
41
46
|
preloaded = extract_preloaded_associations(node)
|
|
42
47
|
return if preloaded.empty?
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
module Concerns
|
|
6
|
+
# Tracks the inferred model class behind a local/instance variable, by
|
|
7
|
+
# walking AST value-nodes down to a `:const` (e.g. `User.where(...)` →
|
|
8
|
+
# `"User"`). Recurses into argument lists of relation-wrapper methods
|
|
9
|
+
# (pagy, paginate, ...) so that `pagy(User.includes(...))` is still
|
|
10
|
+
# recognized as a User-relation.
|
|
11
|
+
module VariableModelInference
|
|
12
|
+
# Methods whose first positional argument is the underlying relation
|
|
13
|
+
# (Pagy/Kaminari/etc.). Walking into those args lets us see through
|
|
14
|
+
# the wrapper to the source query.
|
|
15
|
+
RELATION_WRAPPERS = %i[pagy paginate page kaminari with_pagy].freeze
|
|
16
|
+
|
|
17
|
+
# LHS names in `@pagy, items = pagy(query)`-style assignments that
|
|
18
|
+
# should not inherit the relation's model — they hold pagination
|
|
19
|
+
# metadata, not records.
|
|
20
|
+
PAGINATION_META_NAMES = %i[pagy paginator meta page_info pagination].freeze
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def infer_model_from_value(node, depth = 0) # rubocop:disable Metrics/CyclomaticComplexity
|
|
25
|
+
return nil if depth > 10 || !node.is_a?(Parser::AST::Node)
|
|
26
|
+
|
|
27
|
+
case node.type
|
|
28
|
+
when :const then node.children[1].to_s
|
|
29
|
+
when :send then infer_model_from_send(node, depth)
|
|
30
|
+
when :lvar, :ivar then variable_model_for(node)
|
|
31
|
+
when :if then infer_model_from_branches(node, depth)
|
|
32
|
+
when :begin then infer_model_from_first(node.children, depth)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def infer_model_from_send(node, depth)
|
|
37
|
+
method = node.children[1]
|
|
38
|
+
if RELATION_WRAPPERS.include?(method) && node.children[2]
|
|
39
|
+
from_arg = infer_model_from_value(node.children[2], depth + 1)
|
|
40
|
+
return from_arg if from_arg
|
|
41
|
+
end
|
|
42
|
+
infer_model_from_value(node.children[0], depth + 1)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def infer_model_from_branches(node, depth)
|
|
46
|
+
infer_model_from_value(node.children[1], depth + 1) ||
|
|
47
|
+
infer_model_from_value(node.children[2], depth + 1)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def infer_model_from_first(children, depth)
|
|
51
|
+
children.each do |child|
|
|
52
|
+
result = infer_model_from_value(child, depth + 1)
|
|
53
|
+
return result if result
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def variable_model_for(node)
|
|
59
|
+
@variable_models&.[]([node.type == :ivar ? :ivar : :lvar, node.children[0]])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/variable_model_inference"
|
|
4
|
+
|
|
3
5
|
module EagerEye
|
|
4
6
|
module Detectors
|
|
5
7
|
class CustomMethodQuery < Base
|
|
8
|
+
include Concerns::VariableModelInference
|
|
9
|
+
|
|
6
10
|
QUERY_METHODS = %i[where find_by find_by! exists? find first last take pluck ids count sum average minimum
|
|
7
11
|
maximum].freeze
|
|
8
12
|
SAFE_QUERY_METHODS = %i[first last take count sum find size length ids].freeze
|
|
@@ -16,16 +20,19 @@ module EagerEye
|
|
|
16
20
|
:custom_method_query
|
|
17
21
|
end
|
|
18
22
|
|
|
19
|
-
def detect(ast, file_path, method_queries = {})
|
|
23
|
+
def detect(ast, file_path, method_queries = {}, associations_by_model = {})
|
|
20
24
|
return [] unless ast
|
|
21
25
|
|
|
22
26
|
@issues = []
|
|
23
27
|
@file_path = file_path
|
|
24
28
|
@method_queries = method_queries
|
|
29
|
+
@associations_by_model = associations_by_model
|
|
30
|
+
@variable_models = {}
|
|
25
31
|
|
|
26
32
|
find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
|
|
33
|
+
model_name = infer_model_from_value(collection)
|
|
27
34
|
check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
|
|
28
|
-
check_block_for_model_query_methods(block_body, block_var)
|
|
35
|
+
check_block_for_model_query_methods(block_body, block_var, model_name)
|
|
29
36
|
end
|
|
30
37
|
|
|
31
38
|
@issues
|
|
@@ -36,7 +43,7 @@ module EagerEye
|
|
|
36
43
|
def find_iteration_blocks(node, definitions = {}, &block)
|
|
37
44
|
return unless node.is_a?(Parser::AST::Node)
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
record_definition(node, definitions)
|
|
40
47
|
|
|
41
48
|
if iteration_block?(node)
|
|
42
49
|
block_var = extract_iteration_variable(node)
|
|
@@ -46,6 +53,43 @@ module EagerEye
|
|
|
46
53
|
node.children.each { |child| find_iteration_blocks(child, definitions, &block) }
|
|
47
54
|
end
|
|
48
55
|
|
|
56
|
+
def record_definition(node, definitions)
|
|
57
|
+
case node.type
|
|
58
|
+
when :lvasgn then record_simple_definition(node, :lvar, definitions)
|
|
59
|
+
when :ivasgn then record_simple_definition(node, :ivar, nil)
|
|
60
|
+
when :masgn then record_multi_definition(node, definitions)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def record_simple_definition(node, var_type, definitions)
|
|
65
|
+
name = node.children[0]
|
|
66
|
+
value = node.children[1]
|
|
67
|
+
return unless name && value
|
|
68
|
+
|
|
69
|
+
definitions[name] = value if definitions
|
|
70
|
+
model = infer_model_from_value(value)
|
|
71
|
+
@variable_models[[var_type, name]] = model if model
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_multi_definition(node, definitions)
|
|
75
|
+
mlhs, rhs = node.children
|
|
76
|
+
return unless mlhs && rhs
|
|
77
|
+
|
|
78
|
+
model = infer_model_from_value(rhs)
|
|
79
|
+
mlhs.children.each { |target| record_multi_target(target, rhs, model, definitions) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_multi_target(target, rhs, model, definitions)
|
|
83
|
+
return unless %i[lvasgn ivasgn].include?(target&.type)
|
|
84
|
+
|
|
85
|
+
tname = target.children[0]
|
|
86
|
+
return if PAGINATION_META_NAMES.include?(tname)
|
|
87
|
+
|
|
88
|
+
var_type = target.type == :lvasgn ? :lvar : :ivar
|
|
89
|
+
@variable_models[[var_type, tname]] = model if model
|
|
90
|
+
definitions[tname] = rhs if target.type == :lvasgn
|
|
91
|
+
end
|
|
92
|
+
|
|
49
93
|
def iteration_block?(node)
|
|
50
94
|
node.type == :block && node.children[0]&.type == :send &&
|
|
51
95
|
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
@@ -115,10 +159,10 @@ module EagerEye
|
|
|
115
159
|
node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
|
|
116
160
|
end
|
|
117
161
|
|
|
118
|
-
def check_block_for_model_query_methods(node, block_var)
|
|
162
|
+
def check_block_for_model_query_methods(node, block_var, model_name)
|
|
119
163
|
return unless node.is_a?(Parser::AST::Node)
|
|
120
164
|
|
|
121
|
-
if model_query_call?(node, block_var)
|
|
165
|
+
if model_query_call?(node, block_var, model_name)
|
|
122
166
|
method = node.children[1]
|
|
123
167
|
@issues << create_issue(
|
|
124
168
|
file_path: @file_path,
|
|
@@ -127,17 +171,38 @@ module EagerEye
|
|
|
127
171
|
suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
|
|
128
172
|
)
|
|
129
173
|
end
|
|
130
|
-
node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
|
|
174
|
+
node.children.each { |child| check_block_for_model_query_methods(child, block_var, model_name) }
|
|
131
175
|
end
|
|
132
176
|
|
|
133
|
-
def model_query_call?(node, block_var)
|
|
134
|
-
return false unless node
|
|
177
|
+
def model_query_call?(node, block_var, model_name)
|
|
178
|
+
return false unless block_var_immediate_send?(node, block_var)
|
|
135
179
|
|
|
136
|
-
receiver = node.children[0]
|
|
137
180
|
method = node.children[1]
|
|
138
|
-
|
|
181
|
+
# If we know the receiver model AND the method is one of its
|
|
182
|
+
# associations, treat it as an association access — not a query method.
|
|
183
|
+
return false if association_on_model?(method, model_name)
|
|
184
|
+
|
|
185
|
+
method_defined_as_query?(method, model_name)
|
|
186
|
+
end
|
|
139
187
|
|
|
140
|
-
|
|
188
|
+
def block_var_immediate_send?(node, block_var)
|
|
189
|
+
node.type == :send &&
|
|
190
|
+
(receiver = node.children[0])&.type == :lvar &&
|
|
191
|
+
receiver.children[0] == block_var
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def association_on_model?(method, model_name)
|
|
195
|
+
model_name && @associations_by_model&.dig(model_name)&.include?(method)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def method_defined_as_query?(method, model_name)
|
|
199
|
+
return false unless @method_queries
|
|
200
|
+
|
|
201
|
+
if model_name
|
|
202
|
+
@method_queries[model_name]&.include?(method) || false
|
|
203
|
+
else
|
|
204
|
+
@method_queries.any? { |_model, methods| methods.include?(method) }
|
|
205
|
+
end
|
|
141
206
|
end
|
|
142
207
|
|
|
143
208
|
def add_issue(node)
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/variable_model_inference"
|
|
4
|
+
|
|
3
5
|
module EagerEye
|
|
4
6
|
module Detectors
|
|
5
|
-
class LoopAssociation < Base
|
|
7
|
+
class LoopAssociation < Base # rubocop:disable Metrics/ClassLength
|
|
8
|
+
include Concerns::VariableModelInference
|
|
9
|
+
|
|
6
10
|
ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map
|
|
7
11
|
each_with_index each_with_object reduce inject
|
|
8
12
|
find_each find_in_batches in_batches array!].freeze
|
|
9
13
|
PRELOAD_METHODS = %i[includes preload eager_load].freeze
|
|
10
14
|
SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
|
|
11
15
|
forty_two sole find_sole_by].freeze
|
|
16
|
+
# Terminal methods on an association that do NOT trigger a SELECT for
|
|
17
|
+
# loading the association — they translate directly to UPDATE/DELETE SQL
|
|
18
|
+
# against the association's foreign key.
|
|
19
|
+
NON_LOADING_TERMINAL_METHODS = %i[update_all delete_all destroy_all touch_all
|
|
20
|
+
increment_counter decrement_counter].freeze
|
|
12
21
|
ASSOCIATION_NAMES = Set.new(%w[
|
|
13
|
-
author user owner creator admin member customer client post article comment category
|
|
14
|
-
parent company organization project task item order product account profile
|
|
15
|
-
avatar photo
|
|
16
|
-
clients posts articles comments categories
|
|
17
|
-
tasks items orders products accounts profiles
|
|
18
|
-
documents
|
|
22
|
+
author user owner creator admin member customer client post article comment category
|
|
23
|
+
parent company organization project task item order product account profile
|
|
24
|
+
avatar photo authors users owners creators admins members customers
|
|
25
|
+
clients posts articles comments categories children companies organizations projects
|
|
26
|
+
tasks items orders products accounts profiles avatars photos
|
|
19
27
|
]).freeze
|
|
20
28
|
EXCLUDED_METHODS = %i[
|
|
21
29
|
id to_s to_h to_a to_json to_xml inspect class object_id nil? blank? present? empty?
|
|
@@ -24,20 +32,22 @@ module EagerEye
|
|
|
24
32
|
description value key type status state created_at updated_at deleted_at origin
|
|
25
33
|
priority level kind label code reason amount price quantity url path email phone
|
|
26
34
|
address notes memo data metadata position rank score rating enabled disabled active
|
|
27
|
-
published draft archived locked visible hidden
|
|
35
|
+
published draft archived locked visible hidden tag image attachment document setting
|
|
28
36
|
].freeze
|
|
29
37
|
|
|
30
38
|
def self.detector_name
|
|
31
39
|
:loop_association
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
def detect(ast, file_path, association_preloads = {}, association_names = Set.new,
|
|
42
|
+
def detect(ast, file_path, association_preloads = {}, association_names = Set.new, # rubocop:disable Metrics/ParameterLists
|
|
43
|
+
method_queries = {}, associations_by_model = {})
|
|
35
44
|
return [] unless ast
|
|
36
45
|
|
|
37
46
|
issues = []
|
|
38
47
|
@association_preloads = association_preloads
|
|
39
48
|
@dynamic_associations = association_names
|
|
40
49
|
@method_queries = method_queries
|
|
50
|
+
@associations_by_model = associations_by_model
|
|
41
51
|
build_variable_maps(ast)
|
|
42
52
|
|
|
43
53
|
traverse_ast(ast) do |node|
|
|
@@ -52,11 +62,11 @@ module EagerEye
|
|
|
52
62
|
collection_node = node.children[0]
|
|
53
63
|
next if single_record_iteration?(collection_node)
|
|
54
64
|
|
|
55
|
-
included =
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
included = collect_included_associations(collection_node)
|
|
66
|
+
model_name = infer_model_from_value(collection_node)
|
|
67
|
+
skip_nodes = collect_non_loading_skip_set(block_body)
|
|
58
68
|
|
|
59
|
-
find_association_calls(block_body, block_var, file_path, issues, included)
|
|
69
|
+
find_association_calls(block_body, block_var, file_path, issues, included, model_name, skip_nodes)
|
|
60
70
|
end
|
|
61
71
|
|
|
62
72
|
issues
|
|
@@ -64,56 +74,127 @@ module EagerEye
|
|
|
64
74
|
|
|
65
75
|
private
|
|
66
76
|
|
|
77
|
+
def collect_included_associations(collection_node)
|
|
78
|
+
included = extract_included_associations_deep(collection_node)
|
|
79
|
+
included.merge(extract_variable_preloads(collection_node))
|
|
80
|
+
included.merge(get_association_preloads(infer_model_from_value(collection_node)))
|
|
81
|
+
included
|
|
82
|
+
end
|
|
83
|
+
|
|
67
84
|
def get_association_preloads(model_name)
|
|
68
85
|
preloaded = Set.new
|
|
86
|
+
return preloaded unless model_name
|
|
87
|
+
|
|
69
88
|
@association_preloads&.each do |key, assocs|
|
|
70
89
|
preloaded.merge(assocs) if key.start_with?("#{model_name}#")
|
|
71
90
|
end
|
|
72
91
|
preloaded
|
|
73
92
|
end
|
|
74
93
|
|
|
75
|
-
def infer_model_name_from_collection(node)
|
|
76
|
-
return nil unless node&.type == :send
|
|
77
|
-
|
|
78
|
-
receiver = node.children[0]
|
|
79
|
-
receiver.children[1].to_s if receiver&.type == :const
|
|
80
|
-
end
|
|
81
|
-
|
|
82
94
|
def iteration_block?(node)
|
|
83
95
|
node.type == :block && node.children[0]&.type == :send &&
|
|
84
96
|
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
85
97
|
end
|
|
86
98
|
|
|
87
|
-
|
|
99
|
+
# Extract :includes/:preload/:eager_load arguments from a value node,
|
|
100
|
+
# walking BOTH the receiver chain and any method arguments. The arg
|
|
101
|
+
# recursion is what lets us see through wrappers like pagy(query, ...).
|
|
102
|
+
def extract_included_associations_deep(value_node, depth = 0)
|
|
88
103
|
included = Set.new
|
|
89
|
-
return included
|
|
104
|
+
return included if depth > 10 || !value_node.is_a?(Parser::AST::Node)
|
|
90
105
|
|
|
91
|
-
current =
|
|
92
|
-
|
|
106
|
+
current = walk_send_chain_for_preloads(value_node, included, depth)
|
|
107
|
+
merge_preloads_from_non_send(current, included, depth)
|
|
108
|
+
included
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def walk_send_chain_for_preloads(node, included, depth)
|
|
112
|
+
current = node
|
|
113
|
+
while current.is_a?(Parser::AST::Node) && current.type == :send
|
|
93
114
|
extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(current.children[1])
|
|
115
|
+
current.children[2..].each do |arg|
|
|
116
|
+
included.merge(extract_included_associations_deep(arg, depth + 1))
|
|
117
|
+
end
|
|
94
118
|
current = current.children[0]
|
|
95
119
|
end
|
|
96
|
-
|
|
120
|
+
current
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def merge_preloads_from_non_send(node, included, depth)
|
|
124
|
+
case node&.type
|
|
125
|
+
when :if then merge_branch_preloads(node, included, depth)
|
|
126
|
+
when :begin then node.children.each do |c|
|
|
127
|
+
included.merge(extract_included_associations_deep(c, depth + 1))
|
|
128
|
+
end
|
|
129
|
+
when :lvar, :ivar then merge_variable_preloads(node, included)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def merge_branch_preloads(node, included, depth)
|
|
134
|
+
included.merge(extract_included_associations_deep(node.children[1], depth + 1))
|
|
135
|
+
included.merge(extract_included_associations_deep(node.children[2], depth + 1))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def merge_variable_preloads(node, included)
|
|
139
|
+
key = [node.type == :ivar ? :ivar : :lvar, node.children[0]]
|
|
140
|
+
included.merge(@variable_preloads[key]) if @variable_preloads&.key?(key)
|
|
97
141
|
end
|
|
98
142
|
|
|
99
143
|
def build_variable_maps(ast)
|
|
100
144
|
@variable_preloads = {}
|
|
145
|
+
@variable_models = {}
|
|
101
146
|
@single_record_variables = Set.new
|
|
102
147
|
|
|
103
148
|
traverse_ast(ast) { |node| process_variable_assignment(node) }
|
|
104
149
|
end
|
|
105
150
|
|
|
106
151
|
def process_variable_assignment(node)
|
|
107
|
-
|
|
152
|
+
case node.type
|
|
153
|
+
when :lvasgn, :ivasgn then process_simple_assignment(node)
|
|
154
|
+
when :masgn then process_multi_assignment(node)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
108
157
|
|
|
158
|
+
def process_simple_assignment(node)
|
|
109
159
|
var_type = node.type == :lvasgn ? :lvar : :ivar
|
|
110
160
|
var_name = node.children[0]
|
|
111
161
|
value_node = node.children[1]
|
|
112
|
-
return unless value_node
|
|
162
|
+
return unless value_node && var_name
|
|
163
|
+
|
|
164
|
+
record_variable(var_type, var_name, value_node)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def process_multi_assignment(node)
|
|
168
|
+
mlhs, rhs = node.children
|
|
169
|
+
return unless mlhs && rhs
|
|
170
|
+
|
|
171
|
+
# mlhs.children is a list of lvasgn/ivasgn (LHS targets). RHS is
|
|
172
|
+
# typically a method call like `pagy(query)` returning [meta, records],
|
|
173
|
+
# or an array literal. We can't statically know which target gets which
|
|
174
|
+
# slot, so apply preloads/model to every LHS — except names that look
|
|
175
|
+
# like pagination metadata.
|
|
176
|
+
mlhs.children.each { |target| record_multi_target(target, rhs) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def record_multi_target(target, rhs)
|
|
180
|
+
return unless %i[lvasgn ivasgn].include?(target&.type)
|
|
113
181
|
|
|
182
|
+
name = target.children[0]
|
|
183
|
+
return if PAGINATION_META_NAMES.include?(name)
|
|
184
|
+
|
|
185
|
+
var_type = target.type == :lvasgn ? :lvar : :ivar
|
|
186
|
+
record_variable(var_type, name, rhs)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def record_variable(var_type, var_name, value_node)
|
|
114
190
|
key = [var_type, var_name]
|
|
115
|
-
|
|
191
|
+
|
|
192
|
+
preloaded = extract_included_associations_deep(value_node)
|
|
116
193
|
@variable_preloads[key] = preloaded unless preloaded.empty?
|
|
194
|
+
|
|
195
|
+
model = infer_model_from_value(value_node)
|
|
196
|
+
@variable_models[key] = model if model
|
|
197
|
+
|
|
117
198
|
@single_record_variables.add(key) if single_record_query?(value_node)
|
|
118
199
|
end
|
|
119
200
|
|
|
@@ -156,10 +237,33 @@ module EagerEye
|
|
|
156
237
|
included_set.merge(extract_symbols_from_args(extract_method_args(method_node)))
|
|
157
238
|
end
|
|
158
239
|
|
|
159
|
-
|
|
240
|
+
# Pre-scan: when an inner send like `block_var.assoc` is the receiver of a
|
|
241
|
+
# NON_LOADING_TERMINAL_METHODS call (e.g. `.update_all`), the assoc access
|
|
242
|
+
# does not trigger a SELECT. Track those receiver nodes so we don't flag them.
|
|
243
|
+
def collect_non_loading_skip_set(block_body)
|
|
244
|
+
skip = Set.new
|
|
245
|
+
traverse_ast(block_body) do |node|
|
|
246
|
+
next unless node.type == :send && NON_LOADING_TERMINAL_METHODS.include?(node.children[1])
|
|
247
|
+
|
|
248
|
+
receiver = node.children[0]
|
|
249
|
+
collect_chain_node_ids(receiver, skip)
|
|
250
|
+
end
|
|
251
|
+
skip
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def collect_chain_node_ids(node, set)
|
|
255
|
+
return unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
256
|
+
|
|
257
|
+
set.add(node.object_id)
|
|
258
|
+
collect_chain_node_ids(node.children[0], set)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def find_association_calls(node, block_var, file_path, issues, included_associations, model_name, skip_nodes) # rubocop:disable Metrics/ParameterLists
|
|
160
262
|
reported = Set.new
|
|
161
263
|
traverse_ast(node) do |child|
|
|
162
|
-
if
|
|
264
|
+
next if skip_nodes.include?(child.object_id)
|
|
265
|
+
|
|
266
|
+
if reportable_association_call?(child, block_var, reported, included_associations, model_name)
|
|
163
267
|
method = child.children[1]
|
|
164
268
|
issues << create_issue(
|
|
165
269
|
file_path: file_path,
|
|
@@ -167,7 +271,7 @@ module EagerEye
|
|
|
167
271
|
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
168
272
|
suggestion: "Use `includes(:#{method})` before iterating"
|
|
169
273
|
)
|
|
170
|
-
elsif reportable_method_query_call?(child, block_var, reported)
|
|
274
|
+
elsif reportable_method_query_call?(child, block_var, reported, model_name)
|
|
171
275
|
method = child.children[1]
|
|
172
276
|
issues << create_issue(
|
|
173
277
|
file_path: file_path,
|
|
@@ -179,35 +283,56 @@ module EagerEye
|
|
|
179
283
|
end
|
|
180
284
|
end
|
|
181
285
|
|
|
182
|
-
def reportable_association_call?(node, block_var, reported, included)
|
|
286
|
+
def reportable_association_call?(node, block_var, reported, included, model_name)
|
|
183
287
|
return false unless node.type == :send
|
|
184
288
|
|
|
185
289
|
receiver = node.children[0]
|
|
186
290
|
method = node.children[1]
|
|
187
291
|
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
188
|
-
return false if excluded_method?(method, included)
|
|
292
|
+
return false if excluded_method?(method, included, model_name)
|
|
189
293
|
|
|
190
294
|
reported.add?("#{node.loc.line}:#{method}")
|
|
191
295
|
end
|
|
192
296
|
|
|
193
|
-
def excluded_method?(method, included)
|
|
297
|
+
def excluded_method?(method, included, model_name)
|
|
194
298
|
EXCLUDED_METHODS.include?(method) ||
|
|
195
|
-
|
|
196
|
-
|
|
299
|
+
included.include?(method) ||
|
|
300
|
+
!known_association?(method, model_name)
|
|
197
301
|
end
|
|
198
302
|
|
|
199
|
-
|
|
303
|
+
# When we know the iteration variable's model AND have parsed that model's
|
|
304
|
+
# associations, trust that map exclusively — methods not in it are columns
|
|
305
|
+
# or scalar accessors, not associations. Otherwise fall back to heuristics
|
|
306
|
+
# (hardcoded common names + globally collected association names).
|
|
307
|
+
def known_association?(method, model_name)
|
|
308
|
+
if model_name && @associations_by_model&.key?(model_name)
|
|
309
|
+
return @associations_by_model[model_name].include?(method)
|
|
310
|
+
end
|
|
311
|
+
|
|
200
312
|
ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
|
|
201
313
|
end
|
|
202
314
|
|
|
203
|
-
def reportable_method_query_call?(node, block_var, reported)
|
|
315
|
+
def reportable_method_query_call?(node, block_var, reported, model_name)
|
|
204
316
|
return false unless block_var_send?(node, block_var)
|
|
205
317
|
return false if EXCLUDED_METHODS.include?(node.children[1])
|
|
206
|
-
return false unless
|
|
318
|
+
return false unless method_known_to_query?(node.children[1], model_name)
|
|
207
319
|
|
|
208
320
|
reported.add?("#{node.loc.line}:#{node.children[1]}")
|
|
209
321
|
end
|
|
210
322
|
|
|
323
|
+
# When the receiver model is known, only consider methods defined as a
|
|
324
|
+
# query on THAT model — not any model in the project. Without a known
|
|
325
|
+
# model, fall back to the global "any model has this method" heuristic.
|
|
326
|
+
def method_known_to_query?(method, model_name)
|
|
327
|
+
return false unless @method_queries
|
|
328
|
+
|
|
329
|
+
if model_name
|
|
330
|
+
@method_queries[model_name]&.include?(method) || false
|
|
331
|
+
else
|
|
332
|
+
@method_queries.any? { |_, ms| ms.include?(method) }
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
211
336
|
def block_var_send?(node, block_var)
|
|
212
337
|
node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
|
|
213
338
|
end
|
|
@@ -4,10 +4,14 @@ require_relative "concerns/non_ar_source_detector"
|
|
|
4
4
|
|
|
5
5
|
module EagerEye
|
|
6
6
|
module Detectors
|
|
7
|
-
class PluckToArray < Base
|
|
7
|
+
class PluckToArray < Base # rubocop:disable Metrics/ClassLength
|
|
8
8
|
include Concerns::NonArSourceDetector
|
|
9
9
|
|
|
10
10
|
SMALL_COLLECTIONS = %w[tags settings options categories roles permissions statuses types priorities].freeze
|
|
11
|
+
SCOPING_METHODS = %i[where not limit offset order group having distinct
|
|
12
|
+
joins left_joins left_outer_joins includes preload eager_load
|
|
13
|
+
lock select from references unscope merge or rewhere reorder regroup
|
|
14
|
+
in_order_of].freeze
|
|
11
15
|
|
|
12
16
|
def self.detector_name
|
|
13
17
|
:pluck_to_array
|
|
@@ -117,9 +121,31 @@ module EagerEye
|
|
|
117
121
|
node.is_a?(Parser::AST::Node) && node.type == :send && %i[pluck ids].include?(node.children[1])
|
|
118
122
|
end
|
|
119
123
|
|
|
124
|
+
# `.all.pluck(:id)` is "critical" only when the `.all` is unscoped — i.e.
|
|
125
|
+
# there's no `.where`/`.limit`/etc. earlier in the chain. Otherwise the
|
|
126
|
+
# query is already filtered and isn't loading the entire table.
|
|
120
127
|
def all_pluck_call?(node)
|
|
121
|
-
pluck_call?(node)
|
|
122
|
-
|
|
128
|
+
return false unless pluck_call?(node)
|
|
129
|
+
|
|
130
|
+
receiver = node.children[0]
|
|
131
|
+
return false unless receiver.is_a?(Parser::AST::Node) && receiver.type == :send
|
|
132
|
+
return false unless receiver.children[1] == :all
|
|
133
|
+
|
|
134
|
+
unscoped_all?(receiver)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unscoped_all?(all_node)
|
|
138
|
+
receiver = all_node.children[0]
|
|
139
|
+
# `.all` with no receiver (just `all` in a model context) — treat as unscoped.
|
|
140
|
+
return true if receiver.nil?
|
|
141
|
+
|
|
142
|
+
while receiver.is_a?(Parser::AST::Node) && receiver.type == :send
|
|
143
|
+
return false if SCOPING_METHODS.include?(receiver.children[1])
|
|
144
|
+
|
|
145
|
+
receiver = receiver.children[0]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
true
|
|
123
149
|
end
|
|
124
150
|
|
|
125
151
|
def small_collection_pluck?(node)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Fixers
|
|
5
|
+
class AddIncludes < Base
|
|
6
|
+
ITERATION_METHODS_RE = %w[
|
|
7
|
+
each map collect select find_all reject filter filter_map
|
|
8
|
+
flat_map find_each find_in_batches in_batches
|
|
9
|
+
].join("|")
|
|
10
|
+
ITERATION_PATTERN = /\.(#{ITERATION_METHODS_RE})\b/
|
|
11
|
+
|
|
12
|
+
def fixable?
|
|
13
|
+
issue.detector == :loop_association &&
|
|
14
|
+
!association_name.nil? &&
|
|
15
|
+
!iteration_line_index.nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def diff
|
|
19
|
+
return nil unless fixable?
|
|
20
|
+
|
|
21
|
+
idx = iteration_line_index
|
|
22
|
+
original_line = @source_lines[idx]
|
|
23
|
+
fixed_line = insert_includes(original_line)
|
|
24
|
+
return nil if original_line == fixed_line
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
file: issue.file_path,
|
|
28
|
+
line: idx + 1,
|
|
29
|
+
original: original_line.chomp,
|
|
30
|
+
fixed: fixed_line.chomp
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
protected
|
|
35
|
+
|
|
36
|
+
def fixed_content
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def association_name
|
|
43
|
+
return nil unless issue.suggestion
|
|
44
|
+
|
|
45
|
+
match = issue.suggestion.match(/includes\(:(\w+)\)/)
|
|
46
|
+
match && match[1]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def iteration_line_index
|
|
50
|
+
start = issue.line_number - 2
|
|
51
|
+
start.downto([start - 10, 0].max) do |i|
|
|
52
|
+
return i if @source_lines[i]&.match?(ITERATION_PATTERN)
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def insert_includes(line)
|
|
58
|
+
line.sub(ITERATION_PATTERN, ".includes(:#{association_name})\\0")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Fixers
|
|
5
|
+
class CountToSize < Base
|
|
6
|
+
def fixable?
|
|
7
|
+
issue.detector == :count_in_iteration &&
|
|
8
|
+
single_line_count?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
def fixed_content
|
|
14
|
+
line_content.sub(/\.count\b/, ".size")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def single_line_count?
|
|
20
|
+
line_content&.match?(/\.count\b/)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/eager_eye/version.rb
CHANGED
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
|
+
version: 1.2.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hamzagedikkaya
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -69,6 +69,7 @@ files:
|
|
|
69
69
|
- lib/eager_eye/detectors/callback_query.rb
|
|
70
70
|
- lib/eager_eye/detectors/concerns/class_inspector.rb
|
|
71
71
|
- lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
|
|
72
|
+
- lib/eager_eye/detectors/concerns/variable_model_inference.rb
|
|
72
73
|
- lib/eager_eye/detectors/count_in_iteration.rb
|
|
73
74
|
- lib/eager_eye/detectors/custom_method_query.rb
|
|
74
75
|
- lib/eager_eye/detectors/decorator_n_plus_one.rb
|
|
@@ -80,7 +81,9 @@ files:
|
|
|
80
81
|
- lib/eager_eye/detectors/serializer_nesting.rb
|
|
81
82
|
- lib/eager_eye/detectors/validation_n_plus_one.rb
|
|
82
83
|
- lib/eager_eye/fixer_registry.rb
|
|
84
|
+
- lib/eager_eye/fixers/add_includes.rb
|
|
83
85
|
- lib/eager_eye/fixers/base.rb
|
|
86
|
+
- lib/eager_eye/fixers/count_to_size.rb
|
|
84
87
|
- lib/eager_eye/fixers/pluck_to_select.rb
|
|
85
88
|
- lib/eager_eye/generators/install_generator.rb
|
|
86
89
|
- lib/eager_eye/issue.rb
|