skinny_includes 0.4.0
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 +7 -0
- data/.github/workflows/ci.yml +27 -0
- data/CHANGELOG.md +75 -0
- data/README.md +163 -0
- data/RELEASING.md +62 -0
- data/Rakefile +4 -0
- data/lib/skinny_includes/version.rb +5 -0
- data/lib/skinny_includes.rb +291 -0
- data/spec/skinny_includes_spec.rb +1079 -0
- metadata +64 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# tl;dr
|
|
4
|
+
# post = Post.first
|
|
5
|
+
# post.comments.each do |comment|
|
|
6
|
+
# comment.author
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# ok just preload them to avoid n+1
|
|
10
|
+
# post.includes(:comments)
|
|
11
|
+
#
|
|
12
|
+
# but you don't need columns other than author
|
|
13
|
+
# post.includes(:comments).with_columns(comments: [:author])
|
|
14
|
+
#
|
|
15
|
+
# now only author, id, and post_id are selected. pk and fk always included.
|
|
16
|
+
#
|
|
17
|
+
# obvious use case is large json or text columns that slow queries and inflate memory
|
|
18
|
+
# instead of writing custom scoped associations, exclude them:
|
|
19
|
+
#
|
|
20
|
+
# post.without_columns(comments: [:metadata_json, :body])
|
|
21
|
+
#
|
|
22
|
+
# loads all columns except metadata_json and body
|
|
23
|
+
#
|
|
24
|
+
|
|
25
|
+
require 'bundler/inline'
|
|
26
|
+
|
|
27
|
+
gemfile do
|
|
28
|
+
source 'https://rubygems.org'
|
|
29
|
+
gem 'activerecord'
|
|
30
|
+
gem 'sqlite3'
|
|
31
|
+
gem 'rspec'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require 'active_record'
|
|
35
|
+
require 'rspec'
|
|
36
|
+
|
|
37
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
|
38
|
+
# ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
39
|
+
|
|
40
|
+
ActiveRecord::Schema.define do
|
|
41
|
+
create_table :posts do |t|
|
|
42
|
+
t.string :title
|
|
43
|
+
t.text :body
|
|
44
|
+
t.integer :author_id
|
|
45
|
+
t.timestamps
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
create_table :comments do |t|
|
|
49
|
+
t.integer :post_id
|
|
50
|
+
t.integer :author_id
|
|
51
|
+
t.text :body
|
|
52
|
+
t.integer :upvotes
|
|
53
|
+
t.boolean :published, default: false
|
|
54
|
+
t.timestamps
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
create_table :tags do |t|
|
|
58
|
+
t.integer :post_id
|
|
59
|
+
t.string :name
|
|
60
|
+
t.string :color
|
|
61
|
+
t.timestamps
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
create_table :categories do |t|
|
|
65
|
+
t.integer :post_id
|
|
66
|
+
t.string :name
|
|
67
|
+
t.text :description
|
|
68
|
+
t.timestamps
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
create_table :authors do |t|
|
|
72
|
+
t.string :name
|
|
73
|
+
t.text :bio
|
|
74
|
+
t.string :email
|
|
75
|
+
t.timestamps
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
create_table :profiles do |t|
|
|
79
|
+
t.integer :author_id
|
|
80
|
+
t.string :website
|
|
81
|
+
t.text :preferences
|
|
82
|
+
t.timestamps
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Define models
|
|
87
|
+
class Post < ActiveRecord::Base
|
|
88
|
+
has_many :comments
|
|
89
|
+
has_many :tags
|
|
90
|
+
has_many :categories
|
|
91
|
+
belongs_to :author, optional: true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Comment < ActiveRecord::Base
|
|
95
|
+
belongs_to :post
|
|
96
|
+
belongs_to :author, optional: true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class Tag < ActiveRecord::Base
|
|
100
|
+
belongs_to :post
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class Category < ActiveRecord::Base
|
|
104
|
+
belongs_to :post
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Author < ActiveRecord::Base
|
|
108
|
+
has_many :posts
|
|
109
|
+
has_many :comments
|
|
110
|
+
has_one :profile
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class Profile < ActiveRecord::Base
|
|
114
|
+
belongs_to :author
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
require_relative '../lib/skinny_includes'
|
|
118
|
+
|
|
119
|
+
module QueryCounter
|
|
120
|
+
class << self
|
|
121
|
+
attr_accessor :query_count, :queries
|
|
122
|
+
|
|
123
|
+
def reset!
|
|
124
|
+
self.query_count = 0
|
|
125
|
+
self.queries = []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def count_queries(&block)
|
|
129
|
+
reset!
|
|
130
|
+
|
|
131
|
+
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_, _, _, _, details|
|
|
132
|
+
# Skip schema queries and SAVEPOINT queries
|
|
133
|
+
unless details[:sql] =~ /PRAGMA|SCHEMA|SAVEPOINT|RELEASE/i
|
|
134
|
+
self.query_count += 1
|
|
135
|
+
self.queries << details[:sql]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
yield
|
|
140
|
+
|
|
141
|
+
self.query_count
|
|
142
|
+
ensure
|
|
143
|
+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
RSpec.describe 'MinifyPreload' do
|
|
149
|
+
before(:each) do
|
|
150
|
+
Post.delete_all
|
|
151
|
+
Comment.delete_all
|
|
152
|
+
Tag.delete_all
|
|
153
|
+
Category.delete_all
|
|
154
|
+
Author.delete_all
|
|
155
|
+
Profile.delete_all
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
let(:author) do
|
|
159
|
+
Author.create!(name: 'Jane Author', bio: 'A great author bio', email: 'jane@example.com')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
let(:post) do
|
|
163
|
+
Post.create!(title: 'Test Post', body: 'Post body', author_id: author.id)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
let!(:comments) do
|
|
167
|
+
5.times.map do |i|
|
|
168
|
+
post.comments.create!(
|
|
169
|
+
author_id: author.id,
|
|
170
|
+
body: "Comment body #{i}",
|
|
171
|
+
upvotes: i * 10,
|
|
172
|
+
published: i < 3 # First 3 comments are published
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
let!(:tags) do
|
|
178
|
+
3.times.map do |i|
|
|
179
|
+
post.tags.create!(
|
|
180
|
+
name: "Tag #{i}",
|
|
181
|
+
color: "Color #{i}"
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe '#with_columns' do
|
|
187
|
+
context 'basic functionality' do
|
|
188
|
+
it 'returns only specified columns for association' do
|
|
189
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
190
|
+
comment = result.comments.first
|
|
191
|
+
|
|
192
|
+
expect(comment.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'always includes primary key even when not specified' do
|
|
196
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
197
|
+
comment = result.comments.first
|
|
198
|
+
|
|
199
|
+
expect(comment.id).to be_present
|
|
200
|
+
expect(comment.attributes.keys).to include('id')
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'always includes foreign key even when not specified' do
|
|
204
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
205
|
+
comment = result.comments.first
|
|
206
|
+
|
|
207
|
+
expect(comment.post_id).to eq(post.id)
|
|
208
|
+
expect(comment.attributes.keys).to include('post_id')
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it 'loads specified columns with correct values' do
|
|
212
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
213
|
+
comment = result.comments.first
|
|
214
|
+
|
|
215
|
+
expect(comment.body).to eq('Comment body 0')
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'returns nil for non-selected columns' do
|
|
219
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
220
|
+
comment = result.comments.first
|
|
221
|
+
|
|
222
|
+
expect(comment.attributes['author_id']).to be_nil
|
|
223
|
+
expect(comment.attributes['upvotes']).to be_nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'handles multiple columns' do
|
|
227
|
+
result = Post.includes(:comments).with_columns(comments: [:body, :upvotes]).first
|
|
228
|
+
comment = result.comments.first
|
|
229
|
+
|
|
230
|
+
expect(comment.attributes.keys).to contain_exactly('id', 'post_id', 'body', 'upvotes')
|
|
231
|
+
expect(comment.body).to eq('Comment body 0')
|
|
232
|
+
expect(comment.upvotes).to eq(0)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'handles empty column list (only FK and PK)' do
|
|
236
|
+
result = Post.includes(:comments).with_columns(comments: []).first
|
|
237
|
+
comment = result.comments.first
|
|
238
|
+
|
|
239
|
+
expect(comment.attributes.keys).to contain_exactly('id', 'post_id')
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it 'handles nil column list (only FK and PK)' do
|
|
243
|
+
result = Post.includes(:comments).with_columns(comments: nil).first
|
|
244
|
+
comment = result.comments.first
|
|
245
|
+
|
|
246
|
+
expect(comment.attributes.keys).to contain_exactly('id', 'post_id')
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
context 'multiple associations' do
|
|
251
|
+
it 'handles multiple associations simultaneously' do
|
|
252
|
+
result = Post.includes(:comments, :tags).with_columns(
|
|
253
|
+
comments: [:body],
|
|
254
|
+
tags: [:name]
|
|
255
|
+
).first
|
|
256
|
+
|
|
257
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
258
|
+
expect(result.tags.first.attributes.keys).to contain_exactly('id', 'post_id', 'name')
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it 'handles three associations' do
|
|
262
|
+
post.categories.create!(name: 'Ruby', description: 'Ruby programming')
|
|
263
|
+
|
|
264
|
+
result = Post.includes(:comments, :tags, :categories).with_columns(
|
|
265
|
+
comments: [:body],
|
|
266
|
+
tags: [:name],
|
|
267
|
+
categories: [:name]
|
|
268
|
+
).first
|
|
269
|
+
|
|
270
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
271
|
+
expect(result.tags.first.attributes.keys).to contain_exactly('id', 'post_id', 'name')
|
|
272
|
+
expect(result.categories.first.attributes.keys).to contain_exactly('id', 'post_id', 'name')
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it 'allows different columns for each association' do
|
|
276
|
+
result = Post.includes(:comments, :tags).with_columns(
|
|
277
|
+
comments: [:body, :upvotes],
|
|
278
|
+
tags: [:name, :color]
|
|
279
|
+
).first
|
|
280
|
+
|
|
281
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body', 'upvotes')
|
|
282
|
+
expect(result.tags.first.attributes.keys).to contain_exactly('id', 'post_id', 'name', 'color')
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
context 'without includes' do
|
|
287
|
+
it 'works when called directly on model' do
|
|
288
|
+
result = Post.with_columns(comments: [:body]).first
|
|
289
|
+
comment = result.comments.first
|
|
290
|
+
|
|
291
|
+
expect(comment.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'works with where clauses' do
|
|
295
|
+
result = Post.where(id: post.id).with_columns(comments: [:body]).first
|
|
296
|
+
comment = result.comments.first
|
|
297
|
+
|
|
298
|
+
expect(comment.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
context 'loading all records' do
|
|
303
|
+
it 'loads all associated records' do
|
|
304
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
305
|
+
|
|
306
|
+
expect(result.comments.count).to eq(5)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it 'loads multiple associations completely' do
|
|
310
|
+
result = Post.includes(:comments, :tags).with_columns(
|
|
311
|
+
comments: [:body],
|
|
312
|
+
tags: [:name]
|
|
313
|
+
).first
|
|
314
|
+
|
|
315
|
+
expect(result.comments.count).to eq(5)
|
|
316
|
+
expect(result.tags.count).to eq(3)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it 'preserves association data integrity' do
|
|
320
|
+
result = Post.includes(:comments).with_columns(comments: [:body, :upvotes]).first
|
|
321
|
+
|
|
322
|
+
result.comments.each_with_index do |comment, i|
|
|
323
|
+
expect(comment.body).to eq("Comment body #{i}")
|
|
324
|
+
expect(comment.upvotes).to eq(i * 10)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
context 'error handling' do
|
|
330
|
+
it 'raises error for unknown association' do
|
|
331
|
+
expect {
|
|
332
|
+
Post.includes(:comments).with_columns(unknown_assoc: [:id])
|
|
333
|
+
}.to raise_error(ArgumentError, /Unknown association: unknown_assoc/)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it 'raises error for invalid association name' do
|
|
337
|
+
expect {
|
|
338
|
+
Post.with_columns(not_real: [:id])
|
|
339
|
+
}.to raise_error(ArgumentError, /Unknown association/)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
context 'N+1 query prevention' do
|
|
344
|
+
it 'prevents N+1 queries for single association' do
|
|
345
|
+
query_count = QueryCounter.count_queries do
|
|
346
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
347
|
+
result.comments.each { |c| c.body }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
expect(query_count).to eq(2)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it 'prevents N+1 queries with multiple associations' do
|
|
354
|
+
query_count = QueryCounter.count_queries do
|
|
355
|
+
result = Post.includes(:comments, :tags).with_columns(
|
|
356
|
+
comments: [:body],
|
|
357
|
+
tags: [:name]
|
|
358
|
+
).first
|
|
359
|
+
|
|
360
|
+
result.comments.each { |c| c.body }
|
|
361
|
+
result.tags.each { |t| t.name }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
expect(query_count).to eq(3)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it 'prevents N+1 when iterating over multiple posts' do
|
|
368
|
+
3.times do |i|
|
|
369
|
+
p = Post.create!(title: "Post #{i}", body: "Body #{i}")
|
|
370
|
+
2.times { |j| p.comments.create!(body: "Comment body #{j}") }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
query_count = QueryCounter.count_queries do
|
|
374
|
+
posts = Post.includes(:comments).with_columns(comments: [:body])
|
|
375
|
+
|
|
376
|
+
posts.each do |post|
|
|
377
|
+
post.comments.each { |c| c.body }
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
expect(query_count).to eq(2)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it 'prevents N+1 with complex iteration' do
|
|
385
|
+
2.times do |i|
|
|
386
|
+
p = Post.create!(title: "Extra Post #{i}", body: "Body #{i}")
|
|
387
|
+
3.times { |j| p.comments.create!(body: "Body #{j}", upvotes: j) }
|
|
388
|
+
2.times { |j| p.tags.create!(name: "Tag #{j}", color: "Color") }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
query_count = QueryCounter.count_queries do
|
|
392
|
+
posts = Post.includes(:comments, :tags).with_columns(
|
|
393
|
+
comments: [:upvotes],
|
|
394
|
+
tags: [:name]
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
posts.each do |post|
|
|
398
|
+
post.comments.sum(&:upvotes)
|
|
399
|
+
post.tags.map(&:name).join(', ')
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
expect(query_count).to eq(3)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it 'uses same query count as regular includes for comparison' do
|
|
407
|
+
Post.delete_all
|
|
408
|
+
Comment.delete_all
|
|
409
|
+
|
|
410
|
+
5.times do |i|
|
|
411
|
+
p = Post.create!(title: "Post #{i}")
|
|
412
|
+
3.times { |j| p.comments.create!(body: "Body #{j}") }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
with_columns_count = QueryCounter.count_queries do
|
|
416
|
+
posts = Post.includes(:comments).with_columns(comments: [:body])
|
|
417
|
+
posts.each { |post| post.comments.each { |c| c.body } }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
regular_count = QueryCounter.count_queries do
|
|
421
|
+
posts = Post.includes(:comments)
|
|
422
|
+
posts.each { |post| post.comments.each { |c| c.body } }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
expect(with_columns_count).to eq(regular_count)
|
|
426
|
+
expect(with_columns_count).to eq(2)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
context 'chaining and composition' do
|
|
431
|
+
it 'works with where clauses before with_columns' do
|
|
432
|
+
result = Post.where(title: 'Test Post').includes(:comments).with_columns(comments: [:body]).first
|
|
433
|
+
|
|
434
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
it 'works with order clauses' do
|
|
438
|
+
result = Post.order(:id).includes(:comments).with_columns(comments: [:body]).first
|
|
439
|
+
|
|
440
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
it 'works with limit' do
|
|
444
|
+
result = Post.limit(1).includes(:comments).with_columns(comments: [:body]).first
|
|
445
|
+
|
|
446
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
context 'edge cases' do
|
|
451
|
+
it 'handles posts with no comments' do
|
|
452
|
+
empty_post = Post.create!(title: 'Empty', body: 'No comments')
|
|
453
|
+
result = Post.where(id: empty_post.id).includes(:comments).with_columns(comments: [:body]).first
|
|
454
|
+
|
|
455
|
+
expect(result.comments).to be_empty
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
it 'handles selecting timestamp columns' do
|
|
459
|
+
result = Post.includes(:comments).with_columns(comments: [:body, :created_at]).first
|
|
460
|
+
comment = result.comments.first
|
|
461
|
+
|
|
462
|
+
expect(comment.attributes.keys).to include('created_at')
|
|
463
|
+
expect(comment.created_at).to be_present
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it 'handles selecting all columns explicitly' do
|
|
467
|
+
result = Post.includes(:comments).with_columns(
|
|
468
|
+
comments: [:author_id, :body, :upvotes, :created_at, :updated_at]
|
|
469
|
+
).first
|
|
470
|
+
comment = result.comments.first
|
|
471
|
+
|
|
472
|
+
expect(comment.attributes.keys).to include('author_id', 'body', 'upvotes', 'created_at', 'updated_at')
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
context 'without_columns option' do
|
|
477
|
+
it 'loads all columns except specified ones' do
|
|
478
|
+
result = Post.without_columns(comments: [:body, :upvotes]).first
|
|
479
|
+
comment = result.comments.first
|
|
480
|
+
|
|
481
|
+
# Should have all columns except body and upvotes
|
|
482
|
+
expect(comment.attributes.keys).to include('id', 'post_id', 'author_id', 'created_at', 'updated_at')
|
|
483
|
+
expect(comment.attributes.keys).not_to include('body', 'upvotes')
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
it 'handles single excluded column' do
|
|
487
|
+
result = Post.without_columns(comments: :body).first
|
|
488
|
+
comment = result.comments.first
|
|
489
|
+
|
|
490
|
+
expect(comment.attributes.keys).to include('id', 'post_id', 'author_id', 'upvotes')
|
|
491
|
+
expect(comment.attributes.keys).not_to include('body')
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
it 'always includes PK and FK even when excluded' do
|
|
495
|
+
result = Post.without_columns(comments: [:id, :post_id, :body]).first
|
|
496
|
+
comment = result.comments.first
|
|
497
|
+
|
|
498
|
+
expect(comment.id).to be_present
|
|
499
|
+
expect(comment.post_id).to eq(post.id)
|
|
500
|
+
expect(comment.attributes.keys).not_to include('body')
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
it 'works with multiple associations' do
|
|
504
|
+
result = Post.without_columns(
|
|
505
|
+
comments: :body,
|
|
506
|
+
tags: :color
|
|
507
|
+
).first
|
|
508
|
+
|
|
509
|
+
expect(result.comments.first.attributes.keys).not_to include('body')
|
|
510
|
+
expect(result.comments.first.attributes.keys).to include('upvotes')
|
|
511
|
+
expect(result.tags.first.attributes.keys).not_to include('color')
|
|
512
|
+
expect(result.tags.first.attributes.keys).to include('name')
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
it 'works with includes' do
|
|
516
|
+
result = Post.includes(:comments).without_columns(comments: :body).first
|
|
517
|
+
|
|
518
|
+
expect(result.comments.first.attributes.keys).not_to include('body')
|
|
519
|
+
expect(result.comments.first.attributes.keys).to include('upvotes')
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
it 'prevents N+1 queries' do
|
|
523
|
+
query_count = QueryCounter.count_queries do
|
|
524
|
+
result = Post.without_columns(comments: [:body, :upvotes]).first
|
|
525
|
+
result.comments.each { |c| c.author_id }
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
expect(query_count).to eq(2)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
describe 'belongs_to associations' do
|
|
534
|
+
let!(:profile) do
|
|
535
|
+
author.create_profile!(website: 'https://example.com', preferences: 'Dark mode enabled')
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
context 'with_columns' do
|
|
539
|
+
it 'loads only specified columns from belongs_to association' do
|
|
540
|
+
result = Post.includes(:author).with_columns(author: [:name]).first
|
|
541
|
+
loaded_author = result.author
|
|
542
|
+
|
|
543
|
+
expect(loaded_author.attributes.keys).to contain_exactly('id', 'name')
|
|
544
|
+
expect(loaded_author.name).to eq('Jane Author')
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
it 'does not include foreign key in associated table query' do
|
|
548
|
+
# This is the bug we fixed - author_id exists on posts, not authors
|
|
549
|
+
result = Post.includes(:author).with_columns(author: [:name]).first
|
|
550
|
+
loaded_author = result.author
|
|
551
|
+
|
|
552
|
+
# Should only have id and name, NOT author_id
|
|
553
|
+
expect(loaded_author.attributes.keys).to contain_exactly('id', 'name')
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
it 'handles multiple columns for belongs_to' do
|
|
557
|
+
result = Post.includes(:author).with_columns(author: [:name, :email]).first
|
|
558
|
+
loaded_author = result.author
|
|
559
|
+
|
|
560
|
+
expect(loaded_author.attributes.keys).to contain_exactly('id', 'name', 'email')
|
|
561
|
+
expect(loaded_author.name).to eq('Jane Author')
|
|
562
|
+
expect(loaded_author.email).to eq('jane@example.com')
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
it 'returns nil for non-selected columns in belongs_to' do
|
|
566
|
+
result = Post.includes(:author).with_columns(author: [:name]).first
|
|
567
|
+
loaded_author = result.author
|
|
568
|
+
|
|
569
|
+
expect(loaded_author.attributes['bio']).to be_nil
|
|
570
|
+
expect(loaded_author.attributes['email']).to be_nil
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it 'prevents N+1 queries with belongs_to' do
|
|
574
|
+
3.times { |i| Post.create!(title: "Post #{i}", author_id: author.id) }
|
|
575
|
+
|
|
576
|
+
query_count = QueryCounter.count_queries do
|
|
577
|
+
posts = Post.includes(:author).with_columns(author: [:name])
|
|
578
|
+
posts.each { |p| p.author&.name }
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
expect(query_count).to eq(2)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
it 'handles belongs_to with nil foreign key' do
|
|
585
|
+
post_without_author = Post.create!(title: 'Orphan Post', author_id: nil)
|
|
586
|
+
result = Post.where(id: post_without_author.id).includes(:author).with_columns(author: [:name]).first
|
|
587
|
+
|
|
588
|
+
expect(result.author).to be_nil
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
context 'without_columns' do
|
|
593
|
+
it 'excludes specified columns from belongs_to association' do
|
|
594
|
+
result = Post.includes(:author).without_columns(author: [:bio]).first
|
|
595
|
+
loaded_author = result.author
|
|
596
|
+
|
|
597
|
+
expect(loaded_author.attributes.keys).to include('id', 'name', 'email')
|
|
598
|
+
expect(loaded_author.attributes.keys).not_to include('bio')
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
it 'excludes multiple columns from belongs_to' do
|
|
602
|
+
result = Post.includes(:author).without_columns(author: [:bio, :email]).first
|
|
603
|
+
loaded_author = result.author
|
|
604
|
+
|
|
605
|
+
expect(loaded_author.attributes.keys).to include('id', 'name')
|
|
606
|
+
expect(loaded_author.attributes.keys).not_to include('bio', 'email')
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
it 'always includes primary key even when excluded' do
|
|
610
|
+
result = Post.includes(:author).without_columns(author: [:id, :bio]).first
|
|
611
|
+
loaded_author = result.author
|
|
612
|
+
|
|
613
|
+
expect(loaded_author.id).to be_present
|
|
614
|
+
expect(loaded_author.attributes.keys).not_to include('bio')
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
context 'mixed associations' do
|
|
619
|
+
it 'handles both has_many and belongs_to together' do
|
|
620
|
+
result = Post.includes(:comments, :author).with_columns(
|
|
621
|
+
comments: [:body],
|
|
622
|
+
author: [:name]
|
|
623
|
+
).first
|
|
624
|
+
|
|
625
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
626
|
+
expect(result.author.attributes.keys).to contain_exactly('id', 'name')
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
it 'prevents N+1 with mixed associations' do
|
|
630
|
+
query_count = QueryCounter.count_queries do
|
|
631
|
+
result = Post.includes(:comments, :author).with_columns(
|
|
632
|
+
comments: [:body],
|
|
633
|
+
author: [:name]
|
|
634
|
+
).first
|
|
635
|
+
|
|
636
|
+
result.comments.each { |c| c.body }
|
|
637
|
+
result.author.name
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
expect(query_count).to eq(3)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
describe 'has_one associations' do
|
|
646
|
+
let!(:profile) do
|
|
647
|
+
author.create_profile!(website: 'https://example.com', preferences: 'Dark mode enabled')
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
context 'with_columns' do
|
|
651
|
+
it 'loads only specified columns from has_one association' do
|
|
652
|
+
result = Author.includes(:profile).with_columns(profile: [:website]).first
|
|
653
|
+
loaded_profile = result.profile
|
|
654
|
+
|
|
655
|
+
expect(loaded_profile.attributes.keys).to contain_exactly('id', 'author_id', 'website')
|
|
656
|
+
expect(loaded_profile.website).to eq('https://example.com')
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
it 'includes foreign key in has_one query' do
|
|
660
|
+
result = Author.includes(:profile).with_columns(profile: [:website]).first
|
|
661
|
+
loaded_profile = result.profile
|
|
662
|
+
|
|
663
|
+
expect(loaded_profile.attributes.keys).to include('author_id')
|
|
664
|
+
expect(loaded_profile.author_id).to eq(author.id)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
it 'returns nil for non-selected columns in has_one' do
|
|
668
|
+
result = Author.includes(:profile).with_columns(profile: [:website]).first
|
|
669
|
+
loaded_profile = result.profile
|
|
670
|
+
|
|
671
|
+
expect(loaded_profile.attributes['preferences']).to be_nil
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
it 'handles has_one with nil association' do
|
|
675
|
+
author_without_profile = Author.create!(name: 'No Profile Author', email: 'noprofile@example.com')
|
|
676
|
+
result = Author.where(id: author_without_profile.id).includes(:profile).with_columns(profile: [:website]).first
|
|
677
|
+
|
|
678
|
+
expect(result.profile).to be_nil
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
it 'prevents N+1 queries with has_one' do
|
|
682
|
+
2.times do |i|
|
|
683
|
+
a = Author.create!(name: "Author #{i}", email: "author#{i}@example.com")
|
|
684
|
+
a.create_profile!(website: "https://example#{i}.com")
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
query_count = QueryCounter.count_queries do
|
|
688
|
+
authors = Author.includes(:profile).with_columns(profile: [:website])
|
|
689
|
+
authors.each { |a| a.profile&.website }
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
expect(query_count).to eq(2)
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
context 'without_columns' do
|
|
697
|
+
it 'excludes specified columns from has_one association' do
|
|
698
|
+
result = Author.includes(:profile).without_columns(profile: [:preferences]).first
|
|
699
|
+
loaded_profile = result.profile
|
|
700
|
+
|
|
701
|
+
expect(loaded_profile.attributes.keys).to include('id', 'author_id', 'website')
|
|
702
|
+
expect(loaded_profile.attributes.keys).not_to include('preferences')
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
it 'always includes foreign key even when excluded' do
|
|
706
|
+
result = Author.includes(:profile).without_columns(profile: [:author_id, :preferences]).first
|
|
707
|
+
loaded_profile = result.profile
|
|
708
|
+
|
|
709
|
+
expect(loaded_profile.author_id).to eq(author.id)
|
|
710
|
+
expect(loaded_profile.attributes.keys).not_to include('preferences')
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
context 'complex scenarios' do
|
|
715
|
+
it 'handles has_many, has_one, and belongs_to together' do
|
|
716
|
+
result = Post.includes(:comments, :author).with_columns(
|
|
717
|
+
comments: [:body],
|
|
718
|
+
author: [:name]
|
|
719
|
+
).first
|
|
720
|
+
|
|
721
|
+
# Load the author's profile separately to test has_one
|
|
722
|
+
author_result = Author.includes(:profile, :posts).with_columns(
|
|
723
|
+
profile: [:website],
|
|
724
|
+
posts: [:title]
|
|
725
|
+
).first
|
|
726
|
+
|
|
727
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
728
|
+
expect(result.author.attributes.keys).to contain_exactly('id', 'name')
|
|
729
|
+
expect(author_result.profile.attributes.keys).to contain_exactly('id', 'author_id', 'website')
|
|
730
|
+
expect(author_result.posts.first.attributes.keys).to contain_exactly('id', 'author_id', 'title')
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
describe 'advanced features' do
|
|
736
|
+
context 'scoped associations with has_many' do
|
|
737
|
+
before do
|
|
738
|
+
Post.class_eval do
|
|
739
|
+
has_many :published_comments, -> { where(published: true) }, class_name: 'Comment', foreign_key: :post_id
|
|
740
|
+
has_many :low_upvote_comments, -> { where('upvotes < 20') }, class_name: 'Comment', foreign_key: :post_id
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
it 'respects association scopes with boolean conditions' do
|
|
745
|
+
result = Post.includes(:published_comments).with_columns(published_comments: [:body, :published]).first
|
|
746
|
+
|
|
747
|
+
# Only loads published comments (first 3: upvotes 0, 10, 20)
|
|
748
|
+
expect(result.published_comments.count).to eq(3)
|
|
749
|
+
expect(result.published_comments.map(&:published).uniq).to eq([true])
|
|
750
|
+
|
|
751
|
+
# Column selection works
|
|
752
|
+
expect(result.published_comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body', 'published')
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
it 'respects association scopes with numeric conditions' do
|
|
756
|
+
result = Post.includes(:low_upvote_comments).with_columns(low_upvote_comments: [:body, :upvotes]).first
|
|
757
|
+
|
|
758
|
+
# Only loads comments with upvotes < 20 (comments 0 and 1)
|
|
759
|
+
expect(result.low_upvote_comments.count).to eq(2)
|
|
760
|
+
expect(result.low_upvote_comments.map(&:upvotes)).to contain_exactly(0, 10)
|
|
761
|
+
|
|
762
|
+
# Column selection works
|
|
763
|
+
expect(result.low_upvote_comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body', 'upvotes')
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
it 'works with without_columns on scoped associations' do
|
|
767
|
+
result = Post.includes(:published_comments).without_columns(published_comments: [:body]).first
|
|
768
|
+
|
|
769
|
+
# Scope still respected
|
|
770
|
+
expect(result.published_comments.count).to eq(3)
|
|
771
|
+
|
|
772
|
+
# Body column excluded
|
|
773
|
+
expect(result.published_comments.first.attributes.keys).not_to include('body')
|
|
774
|
+
expect(result.published_comments.first.attributes.keys).to include('published', 'upvotes')
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
context 'scoped associations with belongs_to' do
|
|
779
|
+
before do
|
|
780
|
+
Author.class_eval do
|
|
781
|
+
has_many :verified_posts, -> { where('id > 0') }, class_name: 'Post', foreign_key: :author_id
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
Post.class_eval do
|
|
785
|
+
belongs_to :verified_author, -> { where('id > 0') }, class_name: 'Author', foreign_key: :author_id
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
it 'respects scopes on belongs_to associations' do
|
|
790
|
+
result = Post.includes(:verified_author).with_columns(verified_author: [:name]).first
|
|
791
|
+
|
|
792
|
+
# Scope respected (all authors have id > 0)
|
|
793
|
+
expect(result.verified_author).to be_present
|
|
794
|
+
expect(result.verified_author.name).to eq('Jane Author')
|
|
795
|
+
expect(result.verified_author.attributes.keys).to contain_exactly('id', 'name')
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
context 'scoped associations with has_one' do
|
|
800
|
+
before do
|
|
801
|
+
Author.class_eval do
|
|
802
|
+
has_one :active_profile, -> { where('id > 0') }, class_name: 'Profile', foreign_key: :author_id
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
it 'respects scopes on has_one associations' do
|
|
807
|
+
author.create_profile!(website: 'https://example.com', preferences: 'Dark mode')
|
|
808
|
+
|
|
809
|
+
result = Author.includes(:active_profile).with_columns(active_profile: [:website]).first
|
|
810
|
+
|
|
811
|
+
# Scope respected
|
|
812
|
+
expect(result.active_profile).to be_present
|
|
813
|
+
expect(result.active_profile.website).to eq('https://example.com')
|
|
814
|
+
expect(result.active_profile.attributes.keys).to contain_exactly('id', 'author_id', 'website')
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
context 'nested/chained includes' do
|
|
819
|
+
it 'supports nested includes with hash syntax' do
|
|
820
|
+
result = Post.with_columns(
|
|
821
|
+
comments: {
|
|
822
|
+
columns: [:body],
|
|
823
|
+
include: { author: [:name] }
|
|
824
|
+
}
|
|
825
|
+
).first
|
|
826
|
+
|
|
827
|
+
# Comments loaded with only body column
|
|
828
|
+
expect(result.comments.count).to eq(5)
|
|
829
|
+
expect(result.comments.first.attributes.keys).to include('id', 'post_id', 'body', 'author_id')
|
|
830
|
+
expect(result.comments.first.attributes.keys).not_to include('upvotes', 'published')
|
|
831
|
+
|
|
832
|
+
# Nested author loaded with only name column
|
|
833
|
+
expect(result.comments.first.author).to be_present
|
|
834
|
+
expect(result.comments.first.author.name).to eq('Jane Author')
|
|
835
|
+
expect(result.comments.first.author.attributes.keys).to contain_exactly('id', 'name')
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
it 'supports multi-level nesting (3 levels deep)' do
|
|
839
|
+
# Create a profile for the author
|
|
840
|
+
author.create_profile!(website: 'https://example.com', preferences: 'Dark mode')
|
|
841
|
+
|
|
842
|
+
result = Post.with_columns(
|
|
843
|
+
comments: {
|
|
844
|
+
columns: [:body],
|
|
845
|
+
include: {
|
|
846
|
+
author: {
|
|
847
|
+
columns: [:name],
|
|
848
|
+
include: { profile: [:website] }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
).first
|
|
853
|
+
|
|
854
|
+
# Level 1: Post -> Comments
|
|
855
|
+
expect(result.comments.count).to eq(5)
|
|
856
|
+
expect(result.comments.first.attributes.keys).to include('body', 'author_id')
|
|
857
|
+
|
|
858
|
+
# Level 2: Comments -> Author
|
|
859
|
+
comment_author = result.comments.first.author
|
|
860
|
+
expect(comment_author).to be_present
|
|
861
|
+
expect(comment_author.attributes.keys).to contain_exactly('id', 'name')
|
|
862
|
+
|
|
863
|
+
# Level 3: Author -> Profile
|
|
864
|
+
expect(comment_author.profile).to be_present
|
|
865
|
+
expect(comment_author.profile.website).to eq('https://example.com')
|
|
866
|
+
expect(comment_author.profile.attributes.keys).to contain_exactly('id', 'author_id', 'website')
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
it 'supports nested includes with belongs_to' do
|
|
870
|
+
result = Post.with_columns(
|
|
871
|
+
author: {
|
|
872
|
+
columns: [:name],
|
|
873
|
+
include: { profile: [:website] }
|
|
874
|
+
}
|
|
875
|
+
).first
|
|
876
|
+
|
|
877
|
+
# Top-level belongs_to
|
|
878
|
+
expect(result.author).to be_present
|
|
879
|
+
expect(result.author.attributes.keys).to contain_exactly('id', 'name')
|
|
880
|
+
|
|
881
|
+
# Nested has_one (note: profile needs to exist first)
|
|
882
|
+
author.create_profile!(website: 'https://example.com')
|
|
883
|
+
|
|
884
|
+
result = Post.with_columns(
|
|
885
|
+
author: {
|
|
886
|
+
columns: [:name],
|
|
887
|
+
include: { profile: [:website] }
|
|
888
|
+
}
|
|
889
|
+
).first
|
|
890
|
+
|
|
891
|
+
expect(result.author.profile).to be_present
|
|
892
|
+
expect(result.author.profile.website).to eq('https://example.com')
|
|
893
|
+
expect(result.author.profile.attributes.keys).to contain_exactly('id', 'author_id', 'website')
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
it 'automatically includes foreign keys needed for nesting' do
|
|
897
|
+
result = Post.with_columns(
|
|
898
|
+
comments: {
|
|
899
|
+
columns: [:body], # author_id NOT explicitly listed
|
|
900
|
+
include: { author: [:name] }
|
|
901
|
+
}
|
|
902
|
+
).first
|
|
903
|
+
|
|
904
|
+
# author_id should be automatically included to load the nested author
|
|
905
|
+
expect(result.comments.first.attributes.keys).to include('author_id')
|
|
906
|
+
expect(result.comments.first.author).to be_present
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
it 'works with without_columns and nested includes' do
|
|
910
|
+
result = Post.without_columns(
|
|
911
|
+
comments: {
|
|
912
|
+
columns: [:upvotes, :published], # Exclude these
|
|
913
|
+
include: { author: [:bio, :email] } # Exclude these from author
|
|
914
|
+
}
|
|
915
|
+
).first
|
|
916
|
+
|
|
917
|
+
# Comments should have all columns except upvotes and published
|
|
918
|
+
expect(result.comments.first.attributes.keys).to include('body', 'author_id')
|
|
919
|
+
expect(result.comments.first.attributes.keys).not_to include('upvotes', 'published')
|
|
920
|
+
|
|
921
|
+
# Author should have all columns except bio and email
|
|
922
|
+
expect(result.comments.first.author.attributes.keys).to include('name')
|
|
923
|
+
expect(result.comments.first.author.attributes.keys).not_to include('bio', 'email')
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
it 'supports scoped associations in nested includes' do
|
|
927
|
+
Post.class_eval do
|
|
928
|
+
has_many :published_comments, -> { where(published: true) }, class_name: 'Comment', foreign_key: :post_id
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
result = Post.with_columns(
|
|
932
|
+
published_comments: {
|
|
933
|
+
columns: [:body, :published],
|
|
934
|
+
include: { author: [:name] }
|
|
935
|
+
}
|
|
936
|
+
).first
|
|
937
|
+
|
|
938
|
+
# Only published comments loaded (scoped at top level)
|
|
939
|
+
expect(result.published_comments.count).to eq(3)
|
|
940
|
+
expect(result.published_comments.map(&:published).uniq).to eq([true])
|
|
941
|
+
|
|
942
|
+
# Nested author works
|
|
943
|
+
expect(result.published_comments.first.author).to be_present
|
|
944
|
+
expect(result.published_comments.first.author.name).to eq('Jane Author')
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
it 'prevents N+1 queries with nested includes' do
|
|
948
|
+
query_count = QueryCounter.count_queries do
|
|
949
|
+
result = Post.with_columns(
|
|
950
|
+
comments: {
|
|
951
|
+
columns: [:body],
|
|
952
|
+
include: { author: [:name] }
|
|
953
|
+
}
|
|
954
|
+
).first
|
|
955
|
+
|
|
956
|
+
# Access nested data
|
|
957
|
+
result.comments.each do |comment|
|
|
958
|
+
comment.body
|
|
959
|
+
comment.author.name
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Should be 3 queries: posts, comments, authors (no N+1)
|
|
964
|
+
expect(query_count).to eq(3)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
it 'prevents N+1 with multi-level nesting' do
|
|
968
|
+
author.create_profile!(website: 'https://example.com', preferences: 'Dark mode')
|
|
969
|
+
|
|
970
|
+
query_count = QueryCounter.count_queries do
|
|
971
|
+
result = Post.with_columns(
|
|
972
|
+
comments: {
|
|
973
|
+
columns: [:body],
|
|
974
|
+
include: {
|
|
975
|
+
author: {
|
|
976
|
+
columns: [:name],
|
|
977
|
+
include: { profile: [:website] }
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
).first
|
|
982
|
+
|
|
983
|
+
# Access all nested data
|
|
984
|
+
result.comments.each do |comment|
|
|
985
|
+
comment.body
|
|
986
|
+
comment.author.name
|
|
987
|
+
comment.author.profile.website
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Should be 4 queries: posts, comments, authors, profiles (no N+1)
|
|
992
|
+
expect(query_count).to eq(4)
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
it 'raises error for unknown nested association' do
|
|
996
|
+
expect {
|
|
997
|
+
Post.with_columns(
|
|
998
|
+
comments: {
|
|
999
|
+
columns: [:body],
|
|
1000
|
+
include: { fake_assoc: [:name] }
|
|
1001
|
+
}
|
|
1002
|
+
).first
|
|
1003
|
+
}.to raise_error(ArgumentError, /Unknown association: fake_assoc/)
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
it 'handles empty nested includes hash' do
|
|
1007
|
+
# include: {} should be treated as no nesting
|
|
1008
|
+
result = Post.with_columns(
|
|
1009
|
+
comments: {
|
|
1010
|
+
columns: [:body],
|
|
1011
|
+
include: {}
|
|
1012
|
+
}
|
|
1013
|
+
).first
|
|
1014
|
+
|
|
1015
|
+
expect(result.comments.count).to eq(5)
|
|
1016
|
+
expect(result.comments.first.attributes.keys).to include('body')
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
it 'supports mixing legacy array syntax with new hash syntax' do
|
|
1020
|
+
# Top level uses legacy, nested uses new hash syntax
|
|
1021
|
+
result = Post.with_columns(
|
|
1022
|
+
comments: {
|
|
1023
|
+
columns: [:body],
|
|
1024
|
+
include: { author: [:name] }
|
|
1025
|
+
},
|
|
1026
|
+
tags: [:name] # Legacy array syntax
|
|
1027
|
+
).first
|
|
1028
|
+
|
|
1029
|
+
# Both work
|
|
1030
|
+
expect(result.comments.first.author.name).to eq('Jane Author')
|
|
1031
|
+
expect(result.tags.first.name).to be_present
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
it 'legacy array syntax still works for top-level associations' do
|
|
1035
|
+
# Ensure backwards compatibility
|
|
1036
|
+
result = Post.with_columns(
|
|
1037
|
+
comments: [:body],
|
|
1038
|
+
tags: [:name]
|
|
1039
|
+
).first
|
|
1040
|
+
|
|
1041
|
+
expect(result.comments.first.attributes.keys).to include('body')
|
|
1042
|
+
expect(result.tags.first.attributes.keys).to include('name')
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
describe 'supported loading strategies' do
|
|
1049
|
+
context 'eager_load' do
|
|
1050
|
+
it 'works with eager_load by intercepting it' do
|
|
1051
|
+
# The gem intercepts eager_load and converts it to its own loading strategy
|
|
1052
|
+
result = Post.eager_load(:comments).with_columns(comments: [:body]).first
|
|
1053
|
+
|
|
1054
|
+
# This works! The gem strips it from eager_load and handles it manually
|
|
1055
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
context 'preload' do
|
|
1060
|
+
it 'works with explicit preload' do
|
|
1061
|
+
result = Post.preload(:comments).with_columns(comments: [:body]).first
|
|
1062
|
+
|
|
1063
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
context 'includes' do
|
|
1068
|
+
it 'works with includes' do
|
|
1069
|
+
result = Post.includes(:comments).with_columns(comments: [:body]).first
|
|
1070
|
+
|
|
1071
|
+
expect(result.comments.first.attributes.keys).to contain_exactly('id', 'post_id', 'body')
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
if __FILE__ == $PROGRAM_NAME
|
|
1078
|
+
RSpec::Core::Runner.run([])
|
|
1079
|
+
end
|