eager_eye 1.0.3 → 1.0.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: ecc47488d2632329805448f55dfafc1f6670efd02b04a3dd0464c22ef9a3a4ba
4
- data.tar.gz: b37b1be91854abeea6a11d33f28df1ada2a3fb0a427b28a892cb1249c22a7e67
3
+ metadata.gz: 1e198460506f7e19906eeed320e9f9f11987ca1f4894b4b205a56d58799f3781
4
+ data.tar.gz: e61be4439b88d9731678fae64cb530390c7d2ab06dede3bbef570f8923c5542c
5
5
  SHA512:
6
- metadata.gz: 19adae5e3c000cfa658f5d2615da31c9a702ff031766240b86968d69165445bbbc7b630b5a1596822eaa9683c23db84379127d0c83ed4a59366475b3585972b7
7
- data.tar.gz: a87d19f36dcaf9bd7a360b31051ed32da241e7c0b867815e8e17962f4b29bdd43d6983d84981e54a350ba4d415a076a84a8b1abc6852311277f25398c51d6a1a
6
+ metadata.gz: b32e0853b58fd412511418aedf8ce2d452d0f5a20b20e54eb3c2bb260c7233af7f7bbbe465f7efd7b511114152ffb72b381f54b88bd12c67463a63b547f6ff91
7
+ data.tar.gz: bf371dcf0600ec9f57614eae0d2d52fee4a904f3be37bd5d2f8a0e8d7066135d78b86d6a914e90f61fbc26afcee09003f886c8415ce2d84ce3d35799825975ee
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.5] - 2025-12-21
11
+
12
+ ### Fixed
13
+
14
+ - Fixed false positive N+1 warnings when iterating over a single record's associations
15
+ - Now correctly skips warnings for patterns like `User.find(id).posts.each { |p| p.comments }`
16
+ - Supports `find`, `find_by`, `find_by!`, `first`, `last`, `take`, `second`, `third`, etc.
17
+ - Works with both inline chains and variable assignments
18
+
19
+ ## [1.0.4] - 2025-12-21
20
+
21
+ ### Fixed
22
+
23
+ - Fixed false positive N+1 warnings when associations are preloaded via `includes`, `preload`, or `eager_load` on a separate line
24
+ - Now correctly tracks variable assignments with preload methods (e.g., `posts = Post.includes(:author)`)
25
+ - Supports both local variables and instance variables
26
+ - Works with all three preload methods: `includes`, `preload`, `eager_load`
27
+
10
28
  ## [1.0.3] - 2025-12-20
11
29
 
12
30
  ### Fixed
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # EagerEye
2
2
 
3
3
  [![CI](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg)](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml)
4
- [![Gem Version](https://img.shields.io/badge/gem-v1.0.3-red.svg)](https://rubygems.org/gems/eager_eye)
4
+ [![Gem Version](https://img.shields.io/badge/gem-v1.0.5-red.svg)](https://rubygems.org/gems/eager_eye)
5
5
  [![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg)](https://github.com/hamzagedikkaya/eager_eye)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg)](https://www.ruby-lang.org/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -99,11 +99,27 @@ posts.each do |post|
99
99
  post.comments.count # Another query for each post!
100
100
  end
101
101
 
102
- # Good - Eager load associations
102
+ # Good - Eager load associations (chained)
103
103
  posts.includes(:author, :comments).each do |post|
104
104
  post.author.name # No additional query
105
105
  post.comments.count # No additional query
106
106
  end
107
+
108
+ # Good - Eager load on separate line (also detected correctly!)
109
+ @posts = Post.includes(:author)
110
+ @posts.each do |post|
111
+ post.author.name # No warning - EagerEye tracks the preload
112
+ end
113
+
114
+ # Also works with preload and eager_load
115
+ posts = Post.preload(:comments)
116
+ posts.each { |post| post.comments.size } # No warning
117
+
118
+ # Single record context - no N+1 possible (also detected correctly!)
119
+ @user = User.find(params[:id])
120
+ @user.posts.each do |post|
121
+ post.comments # No warning - single user, no N+1
122
+ end
107
123
  ```
108
124
 
109
125
  ### 2. Serializer Nesting (N+1 in serializers)
@@ -4,31 +4,27 @@ module EagerEye
4
4
  module Detectors
5
5
  class LoopAssociation < Base
6
6
  ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map].freeze
7
-
8
- # Common singular association names (belongs_to pattern)
9
- SINGULAR_ASSOCIATIONS = %w[
10
- author user owner creator admin member customer client
11
- post article comment category tag parent company organization
12
- project task item order product account profile setting
13
- image avatar photo attachment document
14
- ].freeze
15
-
16
- # Common plural association names (has_many pattern)
17
- PLURAL_ASSOCIATIONS = %w[
18
- authors users owners creators admins members customers clients
19
- posts articles comments categories tags children companies organizations
20
- projects tasks items orders products accounts profiles settings
21
- images avatars photos attachments documents
22
- ].freeze
7
+ PRELOAD_METHODS = %i[includes preload eager_load].freeze
8
+ # Methods that return a single record (not a collection)
9
+ SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
10
+ forty_two sole find_sole_by].freeze
11
+
12
+ # Common association names (belongs_to = singular, has_many = plural)
13
+ ASSOCIATION_NAMES = Set.new(%w[
14
+ author user owner creator admin member customer client post article comment category tag
15
+ parent company organization project task item order product account profile setting image
16
+ avatar photo attachment document authors users owners creators admins members customers
17
+ clients posts articles comments categories tags children companies organizations projects
18
+ tasks items orders products accounts profiles settings images avatars photos attachments
19
+ documents
20
+ ]).freeze
23
21
 
24
22
  # Methods that should NOT be treated as associations
25
23
  EXCLUDED_METHODS = %i[
26
- id to_s to_h to_a to_json to_xml inspect class object_id
27
- nil? blank? present? empty? any? none? size count length
28
- save save! update update! destroy destroy! delete delete!
29
- valid? invalid? errors new? persisted? changed? frozen?
30
- name title body content text description value key type status state
31
- created_at updated_at deleted_at
24
+ id to_s to_h to_a to_json to_xml inspect class object_id nil? blank? present? empty?
25
+ any? none? size count length save save! update update! destroy destroy! delete delete!
26
+ valid? invalid? errors new? persisted? changed? frozen? name title body content text
27
+ description value key type status state created_at updated_at deleted_at
32
28
  ].freeze
33
29
 
34
30
  def self.detector_name
@@ -39,6 +35,7 @@ module EagerEye
39
35
  return [] unless ast
40
36
 
41
37
  issues = []
38
+ build_variable_maps(ast)
42
39
 
43
40
  traverse_ast(ast) do |node|
44
41
  next unless iteration_block?(node)
@@ -49,11 +46,13 @@ module EagerEye
49
46
  block_body = node.children[2]
50
47
  next unless block_body
51
48
 
52
- # Check if the collection already has includes
53
49
  collection_node = node.children[0]
54
- included_associations = extract_included_associations(collection_node)
50
+ next if single_record_iteration?(collection_node)
55
51
 
56
- find_association_calls(block_body, block_var, file_path, issues, included_associations)
52
+ included = extract_included_associations(collection_node)
53
+ included.merge(extract_variable_preloads(collection_node))
54
+
55
+ find_association_calls(block_body, block_var, file_path, issues, included)
57
56
  end
58
57
 
59
58
  issues
@@ -86,11 +85,11 @@ module EagerEye
86
85
  included = Set.new
87
86
  return included unless collection_node&.type == :send
88
87
 
89
- # Traverse through chained method calls to find includes()
88
+ # Traverse through chained method calls to find includes/preload/eager_load
90
89
  current = collection_node
91
90
  while current&.type == :send
92
91
  method_name = current.children[1]
93
- extract_includes_from_method(current, included) if method_name == :includes
92
+ extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(method_name)
94
93
 
95
94
  current = current.children[0]
96
95
  end
@@ -98,6 +97,53 @@ module EagerEye
98
97
  included
99
98
  end
100
99
 
100
+ def build_variable_maps(ast)
101
+ @variable_preloads = {}
102
+ @single_record_variables = Set.new
103
+
104
+ traverse_ast(ast) do |node|
105
+ next unless %i[lvasgn ivasgn].include?(node.type)
106
+
107
+ var_type = node.type == :lvasgn ? :lvar : :ivar
108
+ var_name = node.children[0]
109
+ value_node = node.children[1]
110
+ next unless value_node
111
+
112
+ key = [var_type, var_name]
113
+ preloaded = extract_included_associations(value_node)
114
+ @variable_preloads[key] = preloaded unless preloaded.empty?
115
+ @single_record_variables.add(key) if single_record_query?(value_node)
116
+ end
117
+ end
118
+
119
+ def extract_variable_preloads(node)
120
+ key = variable_key_for_node(node)
121
+ (key && @variable_preloads&.[](key)) || Set.new
122
+ end
123
+
124
+ def variable_key_for_node(node)
125
+ case node&.type
126
+ when :lvar then [:lvar, node.children[0]]
127
+ when :ivar then [:ivar, node.children[0]]
128
+ when :send then variable_key_for_node(node.children[0])
129
+ end
130
+ end
131
+
132
+ def single_record_query?(node)
133
+ current = node
134
+ while current&.type == :send && !SINGLE_RECORD_METHODS.include?(current.children[1])
135
+ current = current.children[0]
136
+ end
137
+ current&.type == :send && SINGLE_RECORD_METHODS.include?(current.children[1])
138
+ end
139
+
140
+ def single_record_iteration?(node)
141
+ return false unless node&.type == :send && (receiver = node.children[0])
142
+
143
+ key = variable_key_for_node(receiver)
144
+ (key && @single_record_variables&.include?(key)) || single_record_query?(receiver)
145
+ end
146
+
101
147
  def extract_includes_from_method(method_node, included_set)
102
148
  args = method_node.children[2..]
103
149
  args&.each do |arg|
@@ -159,9 +205,7 @@ module EagerEye
159
205
  def likely_association?(method_name)
160
206
  return false if EXCLUDED_METHODS.include?(method_name)
161
207
 
162
- name = method_name.to_s
163
-
164
- SINGULAR_ASSOCIATIONS.include?(name) || PLURAL_ASSOCIATIONS.include?(name)
208
+ ASSOCIATION_NAMES.include?(method_name.to_s)
165
209
  end
166
210
  end
167
211
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.0.3"
4
+ VERSION = "1.0.5"
5
5
  end
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.0.3
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-20 00:00:00.000000000 Z
11
+ date: 2025-12-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast