eager_eye 1.0.4 → 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: f7ebed3daa9046b0b6bbda1cbb7a9cce182af014ac31334973b4d98a5a1059bc
4
- data.tar.gz: 61e220f6737089f52ab495d422cac8b91bf4720d6551c6a42abad7801c7d3c79
3
+ metadata.gz: 1e198460506f7e19906eeed320e9f9f11987ca1f4894b4b205a56d58799f3781
4
+ data.tar.gz: e61be4439b88d9731678fae64cb530390c7d2ab06dede3bbef570f8923c5542c
5
5
  SHA512:
6
- metadata.gz: 198f7d4c064f940a73e2ac41f401869274482f1388f9031f405c89cdeddd3e8e1cf829f8d107c7c3fa1a88fa1a5de07fe44a3d08acf4cb269aa063ebfafae729
7
- data.tar.gz: 814ce257bfc024ca1d83b61cddd3df9b56ed877dfb40d9eaf5564c951bebf11f4351d0c4965ff1510509f62aad3f97af8f0e4933b5a23b4daf048b186d5c748b
6
+ metadata.gz: b32e0853b58fd412511418aedf8ce2d452d0f5a20b20e54eb3c2bb260c7233af7f7bbbe465f7efd7b511114152ffb72b381f54b88bd12c67463a63b547f6ff91
7
+ data.tar.gz: bf371dcf0600ec9f57614eae0d2d52fee4a904f3be37bd5d2f8a0e8d7066135d78b86d6a914e90f61fbc26afcee09003f886c8415ce2d84ce3d35799825975ee
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ 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
+
10
19
  ## [1.0.4] - 2025-12-21
11
20
 
12
21
  ### 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.4-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)
@@ -114,6 +114,12 @@ end
114
114
  # Also works with preload and eager_load
115
115
  posts = Post.preload(:comments)
116
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
117
123
  ```
118
124
 
119
125
  ### 2. Serializer Nesting (N+1 in serializers)
@@ -5,22 +5,19 @@ module EagerEye
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
7
  PRELOAD_METHODS = %i[includes preload eager_load].freeze
8
-
9
- # Common singular association names (belongs_to pattern)
10
- SINGULAR_ASSOCIATIONS = %w[
11
- author user owner creator admin member customer client
12
- post article comment category tag parent company organization
13
- project task item order product account profile setting
14
- image avatar photo attachment document
15
- ].freeze
16
-
17
- # Common plural association names (has_many pattern)
18
- PLURAL_ASSOCIATIONS = %w[
19
- authors users owners creators admins members customers clients
20
- posts articles comments categories tags children companies organizations
21
- projects tasks items orders products accounts profiles settings
22
- images avatars photos attachments documents
23
- ].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
24
21
 
25
22
  # Methods that should NOT be treated as associations
26
23
  EXCLUDED_METHODS = %i[
@@ -38,9 +35,7 @@ module EagerEye
38
35
  return [] unless ast
39
36
 
40
37
  issues = []
41
-
42
- # Build a map of variable names to their preloaded associations
43
- @variable_preloads = build_variable_preloads_map(ast)
38
+ build_variable_maps(ast)
44
39
 
45
40
  traverse_ast(ast) do |node|
46
41
  next unless iteration_block?(node)
@@ -51,15 +46,13 @@ module EagerEye
51
46
  block_body = node.children[2]
52
47
  next unless block_body
53
48
 
54
- # Check if the collection already has includes (both chained and from variable assignment)
55
49
  collection_node = node.children[0]
56
- included_associations = extract_included_associations(collection_node)
50
+ next if single_record_iteration?(collection_node)
57
51
 
58
- # Also check if the collection comes from a variable that was assigned with preloads
59
- variable_preloads = extract_variable_preloads(collection_node)
60
- included_associations.merge(variable_preloads)
52
+ included = extract_included_associations(collection_node)
53
+ included.merge(extract_variable_preloads(collection_node))
61
54
 
62
- find_association_calls(block_body, block_var, file_path, issues, included_associations)
55
+ find_association_calls(block_body, block_var, file_path, issues, included)
63
56
  end
64
57
 
65
58
  issues
@@ -104,38 +97,28 @@ module EagerEye
104
97
  included
105
98
  end
106
99
 
107
- def build_variable_preloads_map(ast)
108
- preloads_map = {}
100
+ def build_variable_maps(ast)
101
+ @variable_preloads = {}
102
+ @single_record_variables = Set.new
109
103
 
110
104
  traverse_ast(ast) do |node|
111
- case node.type
112
- when :lvasgn
113
- record_variable_preloads(preloads_map, :lvar, node)
114
- when :ivasgn
115
- record_variable_preloads(preloads_map, :ivar, node)
116
- end
117
- end
118
-
119
- preloads_map
120
- end
105
+ next unless %i[lvasgn ivasgn].include?(node.type)
121
106
 
122
- def record_variable_preloads(preloads_map, var_type, node)
123
- var_name = node.children[0]
124
- value_node = node.children[1]
125
- return unless value_node
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
126
111
 
127
- preloaded = extract_included_associations(value_node)
128
- preloads_map[[var_type, var_name]] = preloaded unless preloaded.empty?
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
129
117
  end
130
118
 
131
- def extract_variable_preloads(collection_node)
132
- preloads = Set.new
133
- return preloads unless @variable_preloads
134
-
135
- key = variable_key_for_node(collection_node)
136
- merge_preloads_for_key(preloads, key) if key
137
-
138
- preloads
119
+ def extract_variable_preloads(node)
120
+ key = variable_key_for_node(node)
121
+ (key && @variable_preloads&.[](key)) || Set.new
139
122
  end
140
123
 
141
124
  def variable_key_for_node(node)
@@ -146,8 +129,19 @@ module EagerEye
146
129
  end
147
130
  end
148
131
 
149
- def merge_preloads_for_key(preloads, key)
150
- preloads.merge(@variable_preloads[key]) if @variable_preloads[key]
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)
151
145
  end
152
146
 
153
147
  def extract_includes_from_method(method_node, included_set)
@@ -211,9 +205,7 @@ module EagerEye
211
205
  def likely_association?(method_name)
212
206
  return false if EXCLUDED_METHODS.include?(method_name)
213
207
 
214
- name = method_name.to_s
215
-
216
- SINGULAR_ASSOCIATIONS.include?(name) || PLURAL_ASSOCIATIONS.include?(name)
208
+ ASSOCIATION_NAMES.include?(method_name.to_s)
217
209
  end
218
210
  end
219
211
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.0.4"
4
+ VERSION = "1.0.5"
5
5
  end
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.0.4
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya