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.
@@ -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