torque-postgresql 4.0.0 → 4.1.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +24 -0
  3. data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
  4. data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
  5. data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
  6. data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
  7. data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
  8. data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
  9. data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
  11. data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
  12. data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
  13. data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
  14. data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
  15. data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
  16. data/lib/torque/postgresql/adapter/quoting.rb +11 -2
  17. data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
  18. data/lib/torque/postgresql/adapter/schema_dumper.rb +28 -2
  19. data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
  20. data/lib/torque/postgresql/adapter.rb +2 -5
  21. data/lib/torque/postgresql/arel/nodes.rb +63 -1
  22. data/lib/torque/postgresql/arel/visitors.rb +19 -8
  23. data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
  24. data/lib/torque/postgresql/associations.rb +3 -0
  25. data/lib/torque/postgresql/attributes/base.rb +94 -0
  26. data/lib/torque/postgresql/attributes/composite.rb +102 -0
  27. data/lib/torque/postgresql/attributes/enum.rb +13 -2
  28. data/lib/torque/postgresql/attributes/lquery.rb +180 -0
  29. data/lib/torque/postgresql/attributes/ltree.rb +169 -0
  30. data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
  31. data/lib/torque/postgresql/attributes/struct.rb +137 -0
  32. data/lib/torque/postgresql/base.rb +17 -14
  33. data/lib/torque/postgresql/config.rb +81 -14
  34. data/lib/torque/postgresql/inheritance/expander.rb +66 -0
  35. data/lib/torque/postgresql/inheritance/record.rb +52 -0
  36. data/lib/torque/postgresql/inheritance.rb +138 -65
  37. data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
  38. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
  39. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
  40. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
  41. data/lib/torque/postgresql/predicate_builder.rb +26 -0
  42. data/lib/torque/postgresql/predicate_table.rb +61 -0
  43. data/lib/torque/postgresql/railtie.rb +35 -0
  44. data/lib/torque/postgresql/relation/inheritance.rb +146 -28
  45. data/lib/torque/postgresql/relation/join_series.rb +8 -6
  46. data/lib/torque/postgresql/relation/merger.rb +23 -7
  47. data/lib/torque/postgresql/relation.rb +12 -11
  48. data/lib/torque/postgresql/schema_cache.rb +1 -0
  49. data/lib/torque/postgresql/validations.rb +29 -0
  50. data/lib/torque/postgresql/version.rb +1 -1
  51. data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
  52. data/lib/torque/postgresql.rb +6 -0
  53. data/spec/initialize.rb +20 -2
  54. data/spec/mocks/cache_query.rb +12 -0
  55. data/spec/models/author.rb +1 -1
  56. data/spec/models/comment.rb +2 -0
  57. data/spec/models/place.rb +2 -0
  58. data/spec/models/profile.rb +31 -0
  59. data/spec/schema.rb +34 -4
  60. data/spec/tests/arel_spec.rb +5 -3
  61. data/spec/tests/composite_spec.rb +800 -0
  62. data/spec/tests/ltree_spec.rb +552 -0
  63. data/spec/tests/struct_spec.rb +968 -0
  64. data/spec/tests/table_inheritance_spec.rb +1027 -56
  65. metadata +49 -7
@@ -104,6 +104,265 @@ RSpec.describe 'TableInheritance' do
104
104
  end
105
105
  end
106
106
 
107
+ context 'on syncing features' do
108
+ let(:parent) { 'sync_parents' }
109
+ let(:child) { 'sync_children' }
110
+ let(:migration) { ActiveRecord::Migration::Current.new('Testing') }
111
+
112
+ before do
113
+ allow_any_instance_of(ActiveRecord::Migration).to receive(:puts)
114
+
115
+ connection.create_table(:sync_targets) { |t| t.string :name }
116
+ connection.create_table(parent) do |t|
117
+ t.string :title
118
+ t.string :code
119
+ t.integer :author_id
120
+ t.tsrange :during
121
+ end
122
+
123
+ connection.add_index parent, :title
124
+ connection.add_unique_constraint parent, [:code]
125
+ connection.add_exclusion_constraint parent, 'during WITH &&', using: :gist
126
+ connection.add_foreign_key parent, :sync_targets, column: :author_id
127
+
128
+ connection.create_table(child, inherits: parent) { |t| t.string :extra }
129
+ end
130
+
131
+ it 'copies the primary key, which postgresql does not inherit' do
132
+ expect(connection.primary_keys(child)).to be_empty
133
+ connection.sync_inheritance_features(parent)
134
+ expect(connection.primary_keys(child)).to eql(%w[id])
135
+ end
136
+
137
+ it 'copies the indexes of the parent' do
138
+ connection.sync_inheritance_features(parent)
139
+ expect(connection.indexes(child).map(&:columns)).to include(%w[title])
140
+ end
141
+
142
+ it 'copies the unique constraints of the parent' do
143
+ connection.sync_inheritance_features(parent)
144
+ expect(connection.unique_constraints(child).map(&:column)).to eql([%w[code]])
145
+ end
146
+
147
+ it 'copies the exclusion constraints of the parent' do
148
+ connection.sync_inheritance_features(parent)
149
+ expect(connection.exclusion_constraints(child).map(&:expression)).to eql(['during WITH &&'])
150
+ end
151
+
152
+ it 'copies the outgoing foreign keys of the parent' do
153
+ connection.sync_inheritance_features(parent)
154
+ expect(connection.foreign_keys(child).map(&:to_table)).to eql(%w[sync_targets])
155
+ end
156
+
157
+ it 'excludes a feature that was asked for as false' do
158
+ connection.sync_inheritance_features(parent, primary_key: false)
159
+
160
+ expect(connection.primary_keys(child)).to be_empty
161
+ expect(connection.indexes(child).map(&:columns)).to include(%w[title])
162
+ end
163
+
164
+ it 'takes any feature asked for as true as the whole selection' do
165
+ connection.sync_inheritance_features(parent, indexes: true)
166
+
167
+ expect(connection.indexes(child).map(&:columns)).to include(%w[title])
168
+ expect(connection.primary_keys(child)).to be_empty
169
+ expect(connection.unique_constraints(child)).to be_empty
170
+ expect(connection.foreign_keys(child)).to be_empty
171
+ end
172
+
173
+ it 'refuses a feature it does not know about' do
174
+ expect do
175
+ connection.sync_inheritance_features(parent, comments: true)
176
+ end.to raise_error(ArgumentError, /comments/)
177
+ end
178
+
179
+ it 'names every copy after the marker' do
180
+ connection.sync_inheritance_features(parent)
181
+ names = connection.indexes(child).map(&:name) + connection.foreign_keys(child).map(&:name)
182
+
183
+ expect(names).to all(match(/\Async_inh_[0-9a-f]{10}\z/))
184
+ expect(names.map(&:size)).to all(be(19))
185
+ end
186
+
187
+ it 'gives a copy the same name every single time' do
188
+ connection.sync_inheritance_features(parent, indexes: true)
189
+ names = connection.indexes(child).map(&:name).sort
190
+
191
+ connection.indexes(child).each { |item| connection.remove_index(child, name: item.name) }
192
+ connection.sync_inheritance_features(parent, indexes: true)
193
+
194
+ expect(connection.indexes(child).map(&:name).sort).to eql(names)
195
+ end
196
+
197
+ it 'does not issue any statement on a second run' do
198
+ connection.sync_inheritance_features(parent)
199
+
200
+ statements = []
201
+ subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
202
+ statements << payload[:sql] if payload[:sql].match?(/\A(CREATE|ALTER|DROP)/i)
203
+ end
204
+
205
+ connection.sync_inheritance_features(parent)
206
+ ActiveSupport::Notifications.unsubscribe(subscriber)
207
+
208
+ expect(statements).to be_empty
209
+ end
210
+
211
+ it 'leaves an equivalent index the child already has alone' do
212
+ connection.add_index child, :title, name: 'written_by_hand'
213
+ connection.sync_inheritance_features(parent)
214
+
215
+ titles = connection.indexes(child).select { |item| item.columns == %w[title] }
216
+ expect(titles.map(&:name)).to eql(%w[written_by_hand])
217
+ end
218
+
219
+ it 'copies the index that backs a constraint only through the constraint' do
220
+ connection.sync_inheritance_features(parent)
221
+
222
+ codes = connection.indexes(child).select { |item| item.columns == %w[code] }
223
+ expect(codes.size).to be(1)
224
+ expect(codes.first.name).to eql(connection.unique_constraints(child).first.name)
225
+ end
226
+
227
+ it 'cascades, carrying what each level added of its own' do
228
+ connection.add_index child, :extra
229
+ connection.create_table(:sync_grandchildren, inherits: child)
230
+
231
+ connection.sync_inheritance_features(parent)
232
+
233
+ columns = connection.indexes('sync_grandchildren').map(&:columns)
234
+ expect(columns).to include(%w[title])
235
+ expect(columns).to include(%w[extra])
236
+ end
237
+
238
+ it 'writes only to the children it was given' do
239
+ connection.create_table(:sync_grandchildren, inherits: child)
240
+ connection.sync_inheritance_features(parent, [child])
241
+
242
+ expect(connection.indexes(child).map(&:columns)).to include(%w[title])
243
+ expect(connection.indexes('sync_grandchildren')).to be_empty
244
+ end
245
+
246
+ it 'pulls only from the parents that are inside the requested tree' do
247
+ connection.create_table(:sync_others, id: false) { |t| t.string :label }
248
+ connection.add_index :sync_others, :label
249
+ connection.create_table(:sync_mixed, inherits: [parent, :sync_others])
250
+
251
+ connection.sync_inheritance_features(parent)
252
+
253
+ columns = connection.indexes('sync_mixed').map(&:columns)
254
+ expect(columns).to include(%w[title])
255
+ expect(columns).not_to include(%w[label])
256
+ end
257
+
258
+ it 'refuses a table that does not inherit from the parent' do
259
+ expect do
260
+ connection.sync_inheritance_features(parent, %w[authors])
261
+ end.to raise_error(ArgumentError, /authors/)
262
+ end
263
+
264
+ it 'drops a copy whose source is gone when pruning' do
265
+ connection.sync_inheritance_features(parent)
266
+ connection.remove_index(parent, :title)
267
+ connection.sync_inheritance_features(parent, prune: true)
268
+
269
+ expect(connection.indexes(child).map(&:columns)).not_to include(%w[title])
270
+ end
271
+
272
+ it 'keeps an index written by hand when pruning, even on an inherited column' do
273
+ connection.add_index child, :title, name: 'written_by_hand'
274
+ connection.remove_index(parent, :title)
275
+ connection.sync_inheritance_features(parent, prune: true)
276
+
277
+ expect(connection.indexes(child).map(&:name)).to include('written_by_hand')
278
+ end
279
+
280
+ it 'never drops the primary key when pruning' do
281
+ connection.sync_inheritance_features(parent)
282
+ connection.execute("ALTER TABLE #{parent} DROP CONSTRAINT #{parent}_pkey")
283
+ connection.sync_inheritance_features(parent, prune: true)
284
+
285
+ expect(connection.primary_keys(child)).to eql(%w[id])
286
+ end
287
+
288
+ it 'syncs everything when the table is created with sync' do
289
+ connection.create_table(:sync_others, inherits: parent, sync: true) { |t| t.string :note }
290
+
291
+ expect(connection.primary_keys('sync_others')).to eql(%w[id])
292
+ expect(connection.indexes('sync_others').map(&:columns)).to include(%w[title])
293
+ expect(connection.foreign_keys('sync_others').map(&:to_table)).to eql(%w[sync_targets])
294
+ end
295
+
296
+ it 'syncs from every parent named by a multiple inheritance' do
297
+ connection.create_table(:sync_others, id: false) { |t| t.string :label }
298
+ connection.add_index :sync_others, :label
299
+ connection.create_table(:sync_mixed, inherits: [parent, :sync_others], sync: true)
300
+
301
+ columns = connection.indexes('sync_mixed').map(&:columns)
302
+ expect(columns).to include(%w[title])
303
+ expect(columns).to include(%w[label])
304
+ end
305
+
306
+ it 'syncs only what the sync option picks' do
307
+ connection.create_table(:sync_others, inherits: parent, sync: { indexes: true })
308
+
309
+ expect(connection.indexes('sync_others').map(&:columns)).to include(%w[title])
310
+ expect(connection.primary_keys('sync_others')).to be_empty
311
+ expect(connection.foreign_keys('sync_others')).to be_empty
312
+ end
313
+
314
+ it 'reverts into a sync that prunes' do
315
+ connection.sync_inheritance_features(parent)
316
+ connection.remove_index(parent, :title)
317
+
318
+ migration.revert { migration.connection.sync_inheritance_features(parent) }
319
+
320
+ expect(connection.indexes(child).map(&:columns)).not_to include(%w[title])
321
+ end
322
+
323
+ it 'prunes only once the parent is back to what it was' do
324
+ connection.sync_inheritance_features(parent)
325
+ expect(connection.indexes(child).map(&:columns)).to include(%w[title])
326
+
327
+ migration.revert do
328
+ migration.connection.add_index parent, :title
329
+ migration.connection.sync_inheritance_features(parent)
330
+ end
331
+
332
+ expect(connection.indexes(parent).map(&:columns)).not_to include(%w[title])
333
+ expect(connection.indexes(child).map(&:columns)).not_to include(%w[title])
334
+ end
335
+
336
+ context 'on schema' do
337
+ let(:dump_result) do
338
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, (io = StringIO.new))
339
+ io.string
340
+ end
341
+
342
+ before { connection.sync_inheritance_features(parent) }
343
+
344
+ it 'describes an inherited primary key through the sync option' do
345
+ line = dump_result.lines.find { |item| item.include?("create_table \"#{child}\"") }
346
+
347
+ expect(line).to include('id: false')
348
+ expect(line).to include(%[inherits: "#{parent}"])
349
+ expect(line).to match(/sync: \{:?primary_key(?:: | => |=>)true\}/)
350
+ end
351
+
352
+ it 'never describes the inherited column all over again' do
353
+ line = dump_result.lines.find { |item| item.include?("create_table \"#{child}\"") }
354
+
355
+ expect(line).to include('id: false')
356
+ expect(line).not_to match(/id: :\w+/)
357
+ expect(line).not_to match(/, primary_key: /)
358
+ end
359
+
360
+ it 'carries the marker of every copy, so pruning still works after a load' do
361
+ expect(dump_result).to match(/t\.index \["title"\], name: "sync_inh_[0-9a-f]{10}"/)
362
+ end
363
+ end
364
+ end
365
+
107
366
  context 'on schema cache' do
108
367
  let(:schema_cache) { ActiveRecord::Base.connection.schema_cache }
109
368
  let(:schema_cache_reflection) { schema_cache.instance_variable_get(:@schema_reflection) }
@@ -242,6 +501,32 @@ RSpec.describe 'TableInheritance' do
242
501
  expect(other.casted_dependents.values.map(&:name)).to eql(%w())
243
502
  end
244
503
 
504
+ it 'only considers dependents that add columns as expandable' do
505
+ expect(base.inheritance_expandable_dependents.keys).to \
506
+ eql(%w(activity_books activity_posts activity_post_samples))
507
+ expect(child.inheritance_expandable_dependents).to be_empty
508
+ expect(child2.inheritance_expandable_dependents).to be_empty
509
+ end
510
+
511
+ it 'recomputes inheritance metadata after resetting column information' do
512
+ base.casted_dependents
513
+ base.inheritance_expandable_dependents
514
+ base.physically_inheritances?
515
+ base.inheritance_column_prefix
516
+ base.inheritance_dependent_prefixes
517
+ child2.inheritance_foreign_attribute_names
518
+ base.reset_column_information
519
+
520
+ expect(base.instance_variable_get(:@casted_dependents)).to be_nil
521
+ expect(base.instance_variable_get(:@inheritance_expandable_dependents)).to be_nil
522
+ expect(base.instance_variable_get(:@physically_inheritances)).to be_nil
523
+ expect(base.instance_variable_get(:@inheritance_column_prefix)).to be_nil
524
+ expect(base.instance_variable_get(:@inheritance_dependent_prefixes)).to be_nil
525
+ expect(child2.instance_variable_get(:@inheritance_foreign_attribute_names)).to be_nil
526
+ expect(base.casted_dependents.values.map(&:name)).to \
527
+ eql(%w(ActivityBook ActivityPost ActivityPost::Sample))
528
+ end
529
+
245
530
  it 'correctly generates the tables name' do
246
531
  expect(base.table_name).to eql('activities')
247
532
  expect(child.table_name).to eql('activity_posts')
@@ -263,6 +548,347 @@ RSpec.describe 'TableInheritance' do
263
548
  end
264
549
  end
265
550
 
551
+ context 'on partial records' do
552
+ let(:record) { Activity.create!(title: 'Activity test') }
553
+
554
+ it 'is neither partial nor read-only by default' do
555
+ expect(record.partial_record?).to be_falsey
556
+ expect(record.readonly?).to be_falsey
557
+ end
558
+
559
+ it 'becomes read-only when marked as partial' do
560
+ record.send(:mark_as_partial_record!)
561
+
562
+ expect(record.partial_record?).to be_truthy
563
+ expect(record.readonly?).to be_truthy
564
+ expect { record.update!(title: 'Changed') }.to \
565
+ raise_error(ActiveRecord::ReadOnlyRecord)
566
+ end
567
+
568
+ it 'clears both flags when marked as full' do
569
+ record.send(:mark_as_partial_record!)
570
+ record.send(:mark_as_full_record!)
571
+
572
+ expect(record.partial_record?).to be_falsey
573
+ expect(record.readonly?).to be_falsey
574
+ end
575
+
576
+ it 'clears the partial state on reload' do
577
+ record.send(:mark_as_partial_record!)
578
+ record.reload
579
+
580
+ expect(record.partial_record?).to be_falsey
581
+ expect(record.readonly?).to be_falsey
582
+ end
583
+
584
+ it 'keeps an explicit readonly through a reload on a non-inheriting model' do
585
+ User.create!(name: 'A user')
586
+ user = User.readonly.first
587
+
588
+ expect(user.readonly?).to be_truthy
589
+ expect(user.reload.readonly?).to be_truthy
590
+ end
591
+
592
+ it 'keeps an explicit readonly through a reload on a non-partial record' do
593
+ record.readonly!
594
+ expect(record.reload.readonly?).to be_truthy
595
+ end
596
+ end
597
+
598
+ context 'on automatic casting' do
599
+ before :each do
600
+ Activity.create!(title: 'Plain activity')
601
+ ActivityBook.create!(title: 'A book', url: 'bookurl1')
602
+ ActivityPost.create!(title: 'A post', url: 'posturl1')
603
+ ActivityPost::Sample.create!(title: 'A sample')
604
+ end
605
+
606
+ it 'returns the correct class without any opt-in' do
607
+ expect(Activity.order(:id).load.map(&:class)).to \
608
+ eql([Activity, ActivityBook, ActivityPost, ActivityPost::Sample])
609
+ end
610
+
611
+ it 'marks casted records as partial and read-only' do
612
+ records = Activity.order(:id).load.to_a
613
+
614
+ expect(records[0].partial_record?).to be_falsey
615
+ expect(records[0].readonly?).to be_falsey
616
+ expect(records[1].partial_record?).to be_truthy
617
+ expect(records[1].readonly?).to be_truthy
618
+ end
619
+
620
+ it 'raises when reading a column that was not loaded' do
621
+ book = Activity.order(:id).load.to_a[1]
622
+ expect { book.url }.to raise_error(ActiveModel::MissingAttributeError)
623
+ end
624
+
625
+ it 'raises when persisting a partial record' do
626
+ book = Activity.order(:id).load.to_a[1]
627
+ expect { book.update!(title: 'Changed') }.to \
628
+ raise_error(ActiveRecord::ReadOnlyRecord)
629
+ end
630
+
631
+ it 'destroys a partial record from both tables' do
632
+ book = Activity.order(:id).load.to_a[1]
633
+
634
+ expect(book.readonly?).to be_truthy
635
+ expect(book.destroy).to be_truthy
636
+ expect(Activity.where(id: book.id)).to be_empty
637
+ expect(ActivityBook.where(id: book.id)).to be_empty
638
+ end
639
+
640
+ it 'keeps a partial record read-only after destroying it' do
641
+ book = Activity.order(:id).load.to_a[1]
642
+ book.destroy
643
+
644
+ expect(book.readonly?).to be_truthy
645
+ end
646
+
647
+ it 'destroys partial records through a dependent association' do
648
+ author = Author.create!(name: 'An author name')
649
+ book = ActivityBook.create!(title: 'Owned book', url: 'ownedurl', author: author)
650
+ author.destroy
651
+
652
+ expect(Activity.where(id: book.id)).to be_empty
653
+ expect(ActivityBook.where(id: book.id)).to be_empty
654
+ end
655
+
656
+ it 'does not mark a dependent that adds no columns as partial' do
657
+ sample = ActivityPost.order(:id).load.to_a.last
658
+
659
+ expect(sample).to be_instance_of(ActivityPost::Sample)
660
+ expect(sample.partial_record?).to be_falsey
661
+ expect(sample.readonly?).to be_falsey
662
+ expect(sample.update!(title: 'Changed')).to be_truthy
663
+ end
664
+
665
+ it 'loads the full record on reload' do
666
+ book = Activity.order(:id).load.to_a[1]
667
+ book.reload
668
+
669
+ expect(book.url).to eql('bookurl1')
670
+ expect(book.partial_record?).to be_falsey
671
+ expect(book.readonly?).to be_falsey
672
+ expect(book.changed?).to be_falsey
673
+ end
674
+
675
+ it 'does not expose the internal record class attribute' do
676
+ book = Activity.order(:id).load.to_a[1]
677
+
678
+ expect(book).to be_instance_of(ActivityBook)
679
+ expect(book).not_to respond_to(:_record_class)
680
+ end
681
+
682
+ it 'does not expose the internal record class attribute on a non-casted record' do
683
+ plain = Activity.order(:id).load.first
684
+
685
+ expect(plain).to be_instance_of(Activity)
686
+ expect(plain).not_to respond_to(:_record_class)
687
+ expect(plain.attributes).not_to have_key('_record_class')
688
+ expect { Activity.new(plain.attributes) }.not_to raise_error
689
+ end
690
+
691
+ it 'does not affect single table inheritance' do
692
+ AuthorJournalist.create!(name: 'An author name')
693
+ expect(AuthorJournalist.first).to be_instance_of(AuthorJournalist)
694
+ end
695
+
696
+ it 'does not cast records loaded through an explicit select' do
697
+ book = ActivityBook.create!(title: 'Selected book', url: 'selurl')
698
+ record = nil
699
+
700
+ expect { record = Activity.select(:id, :title).where(id: book.id).first }.to \
701
+ output(/Activity .* omits :_regclass/).to_stderr
702
+
703
+ expect(record).to be_instance_of(Activity)
704
+ expect(record.partial_record?).to be_falsey
705
+ expect(record.readonly?).to be_falsey
706
+ end
707
+
708
+ it 'casts records when the select asks for the record class column' do
709
+ book = ActivityBook.create!(title: 'Selected book', url: 'selurl')
710
+ record = Activity.select(:_regclass, :id, :title).where(id: book.id).first
711
+
712
+ expect(record).to be_instance_of(ActivityBook)
713
+ expect(record.partial_record?).to be_truthy
714
+ expect(record.readonly?).to be_truthy
715
+ end
716
+
717
+ it 'keeps extra selected columns on both casted and non-casted records' do
718
+ author = Author.create!(name: 'The author')
719
+ Activity.create!(title: 'Joined activity', author: author)
720
+ ActivityBook.create!(title: 'Joined book', url: 'jburl', author: author)
721
+
722
+ records = Activity.joins(:author).where(author_id: author.id)
723
+ .select(:_regclass, 'activities.*', 'authors.name as author_name')
724
+ .order(:id).to_a
725
+
726
+ expect(records[0]).to be_instance_of(Activity)
727
+ expect(records[1]).to be_instance_of(ActivityBook)
728
+ expect(records[0][:author_name]).to eql('The author')
729
+ expect(records[1][:author_name]).to eql('The author')
730
+ end
731
+
732
+ it 'keeps a user alias containing a double underscore on both plain and casted records' do
733
+ author = Author.create!(name: 'Underscore author')
734
+ Activity.create!(title: 'Underscore activity', author: author)
735
+ ActivityBook.create!(title: 'Underscore book', url: 'underscoreurl', author: author)
736
+
737
+ records = Activity.joins(:author).where(author_id: author.id)
738
+ .select(:_regclass, 'activities.*', 'authors.name as custom__alias')
739
+ .order(:id).to_a
740
+
741
+ expect(records[0]).to be_instance_of(Activity)
742
+ expect(records[1]).to be_instance_of(ActivityBook)
743
+ expect(records[0][:custom__alias]).to eql('Underscore author')
744
+ expect(records[1][:custom__alias]).to eql('Underscore author')
745
+ end
746
+
747
+ it 'warns when an explicit select fails to cast the loaded records' do
748
+ expect { Activity.select(:id, :title).to_a }.to \
749
+ output(/Activity .* omits :_regclass/).to_stderr
750
+ end
751
+
752
+ it 'does not warn when the record class column ends up selected' do
753
+ expect { Activity.select(:_regclass, :id).to_a }.not_to output.to_stderr
754
+ expect { Activity.select(:_regclass, :id).select(:title).to_a }.not_to output.to_stderr
755
+ expect { Activity.select(:id).select(:_regclass).to_a }.not_to output.to_stderr
756
+ expect { Activity.itself_only.select(:id).to_a }.not_to output.to_stderr
757
+ expect { AuthorJournalist.select(:id).to_a }.not_to output.to_stderr
758
+ end
759
+
760
+ it 'does not warn when a marker-bearing select returns only parent-table rows' do
761
+ plain = Activity.order(:id).first
762
+
763
+ expect { Activity.select(:_regclass, :id, :title).where(id: plain.id).to_a }.not_to \
764
+ output.to_stderr
765
+ end
766
+
767
+ it 'does not warn when the record class column is hand-written as raw SQL' do
768
+ plain = Activity.order(:id).first
769
+
770
+ expect {
771
+ Activity.select('tableoid::regclass AS _record_class', :id, :title).where(id: plain.id).to_a
772
+ }.not_to output.to_stderr
773
+ end
774
+
775
+ it 'does not warn about a relation only used to build a subquery' do
776
+ ActivityBook.create!(title: 'Sub author book', url: 'subauthorurl')
777
+
778
+ expect { Author.where(id: Activity.all) }.not_to output.to_stderr
779
+ end
780
+
781
+ it 'warns only once when an association scope is built more than once' do
782
+ Author.has_many :selected_activities, -> { select(:id, :title) },
783
+ class_name: 'Activity', foreign_key: :author_id
784
+
785
+ author = Author.create!(name: 'Repeated author')
786
+ ActivityBook.create!(title: 'Repeated book', url: 'repeaturl', author: author)
787
+
788
+ expect { author.selected_activities.to_a }.to \
789
+ output(/\A[^\n]*omits :_regclass[^\n]*\n\z/).to_stderr
790
+ end
791
+
792
+ it 'does not warn while building an auxiliary statement over an inheriting model' do
793
+ expect { Activity.select(:id, :title).arel }.not_to output.to_stderr
794
+ end
795
+
796
+ it 'translates the record class token into the marker' do
797
+ sql = Activity.select(:_regclass, :id).to_sql
798
+
799
+ expect(sql).to include('"activities"."tableoid"::regclass AS _record_class')
800
+ expect(sql).not_to include('"_regclass"')
801
+ end
802
+
803
+ it 'adds the record class exactly once through repeated chaining' do
804
+ relation = Activity.all
805
+ 3.times do
806
+ relation = relation.where(active: nil)
807
+ relation.to_a
808
+ end
809
+
810
+ expect(relation.to_sql.scan('_record_class').size).to eql(1)
811
+ end
812
+
813
+ it 'does not add the record class to a relation used as a subquery' do
814
+ author = Author.create!(name: 'Sub author')
815
+ ActivityBook.create!(title: 'Sub book', url: 'suburl', author: author)
816
+
817
+ expect(Author.where(id: Activity.select(:author_id)).to_a).to eql([author])
818
+ end
819
+
820
+ it 'does not add the record class to a grouped selection' do
821
+ expect(Activity.select(:kind).group(:kind).to_a.size).to eql(1)
822
+ expect(Activity.group(:kind).exists?).to be_truthy
823
+ end
824
+
825
+ it 'does not add the record class to a distinct selection' do
826
+ ActivityBook.create!(title: 'Plain activity', url: 'duperurl')
827
+
828
+ expect(Activity.distinct.select(:title).map(&:title)).to \
829
+ match_array(['Plain activity', 'A book', 'A post', 'A sample'])
830
+ end
831
+
832
+ it 'does not add the record class when reading from a subquery' do
833
+ records = Activity.from(Activity.itself_only, :activities).to_a
834
+
835
+ expect(records.size).to eql(1)
836
+ expect(records.first.title).to eql('Plain activity')
837
+ end
838
+
839
+ it 'does not add the record class to plucked columns' do
840
+ ActivityBook.create!(title: 'Plucked', url: 'plurl')
841
+ expect(Activity.pluck(:title)).to all(be_a(String))
842
+ expect(Activity.pluck(:id, :title).first.size).to eql(2)
843
+ end
844
+
845
+ it 'does not add the record class to calculations' do
846
+ ActivityBook.create!(title: 'Counted', url: 'cnturl')
847
+ expect(Activity.count).to be_a(Integer)
848
+ expect(Activity.sum(:id)).to be_a(Integer)
849
+ end
850
+
851
+ it 'does not break calculations combined with buckets' do
852
+ ActivityBook.create!(title: 'Bucketed', url: 'bkturl')
853
+ expect { Activity.buckets(:id, 0..50, count: 5).count }.not_to raise_error
854
+ expect { User.buckets(:age, 0..50, count: 5).count }.not_to raise_error
855
+ end
856
+
857
+ it 'still casts a relation that was previously used for a calculation' do
858
+ book = ActivityBook.create!(title: 'Reused', url: 'reurl')
859
+ relation = Activity.where(id: book.id)
860
+ relation.count
861
+
862
+ expect(relation.to_a.first).to be_instance_of(ActivityBook)
863
+ end
864
+
865
+ it 'does not add the record class when plucking an explicit selection' do
866
+ ActivityBook.create!(title: 'Selected pluck', url: 'spurl')
867
+ expect(Activity.select(:id, :title).pluck(:id).first).to be_a(Integer)
868
+ end
869
+
870
+ context 'when a record class cannot be resolved' do
871
+ after { Activity.reset_column_information }
872
+
873
+ it 'raises pointing at the irregular models setting' do
874
+ Activity.instance_variable_set(:@casted_dependents, {})
875
+
876
+ expect { Activity.order(:id).load.to_a }.to \
877
+ raise_error(Torque::PostgreSQL::InheritanceError, /activity_books/)
878
+ end
879
+ end
880
+
881
+ context 'using uuid' do
882
+ it 'returns the correct class' do
883
+ Question.create!(title: 'Simple question')
884
+ QuestionSelect.create!(title: 'Select question')
885
+
886
+ expect(Question.order(:created_at).load.map(&:class)).to \
887
+ eql([Question, QuestionSelect])
888
+ end
889
+ end
890
+ end
891
+
266
892
  context 'on relation' do
267
893
  let(:base) { Activity }
268
894
  let(:child) { ActivityBook }
@@ -270,14 +896,20 @@ RSpec.describe 'TableInheritance' do
270
896
 
271
897
  it 'has operation methods' do
272
898
  expect(base).to respond_to(:itself_only)
273
- expect(base).to respond_to(:cast_records)
274
- expect(base.new).to respond_to(:cast_record)
899
+ expect(base).to respond_to(:expand_records)
275
900
  end
276
901
 
277
902
  context 'itself only' do
278
- it 'does not mess with original queries' do
279
- expect(base.all.to_sql).to \
280
- eql('SELECT "activities".* FROM "activities"')
903
+ it 'adds the record class to queries on a table with dependents' do
904
+ result = 'SELECT "activities".*'
905
+ result << ', "activities"."tableoid"::regclass AS _record_class'
906
+ result << ' FROM "activities"'
907
+ expect(base.all.to_sql).to eql(result)
908
+ end
909
+
910
+ it 'does not add the record class to a table without dependents' do
911
+ expect(other.all.to_sql).to \
912
+ eql("SELECT \"authors\".* FROM \"authors\" WHERE \"authors\".\"type\" = 'AuthorJournalist'")
281
913
  end
282
914
 
283
915
  it 'adds the only condition to the query' do
@@ -295,7 +927,7 @@ RSpec.describe 'TableInheritance' do
295
927
  end
296
928
  end
297
929
 
298
- context 'cast records' do
930
+ context 'expand records' do
299
931
  before :each do
300
932
  base.create(title: 'Activity test')
301
933
  child.create(title: 'Activity book', url: 'bookurl1')
@@ -313,31 +945,28 @@ RSpec.describe 'TableInheritance' do
313
945
  result << ', COALESCE("i_0"."url", "i_1"."url", "i_2"."url") AS url, "i_0"."activated" AS activity_books__activated'
314
946
  result << ', "i_1"."activated" AS activity_posts__activated, "i_2"."activated" AS activity_post_samples__activated'
315
947
  result << ', COALESCE("i_1"."file", "i_2"."file") AS file, COALESCE("i_1"."post_id", "i_2"."post_id") AS post_id'
316
- result << ", \"activities\".\"tableoid\"::regclass::varchar IN ('activity_books', 'activity_posts', 'activity_post_samples') AS _auto_cast"
317
948
  result << ' FROM "activities"'
318
949
  result << ' LEFT OUTER JOIN "activity_books" "i_0" ON "activities"."id" = "i_0"."id"'
319
950
  result << ' LEFT OUTER JOIN "activity_posts" "i_1" ON "activities"."id" = "i_1"."id"'
320
951
  result << ' LEFT OUTER JOIN "activity_post_samples" "i_2" ON "activities"."id" = "i_2"."id"'
321
- expect(base.cast_records.all.to_sql).to eql(result)
952
+ expect(base.expand_records(eager_load: true).all.to_sql).to eql(result)
322
953
  end
323
954
 
324
955
  it 'can be have simplefied joins' do
325
956
  result = 'SELECT "activities".*, "activities"."tableoid"::regclass AS _record_class'
326
957
  result << ', "i_0"."description", "i_0"."url", "i_0"."activated"'
327
- result << ", \"activities\".\"tableoid\"::regclass::varchar IN ('activity_books') AS _auto_cast"
328
958
  result << ' FROM "activities"'
329
959
  result << ' LEFT OUTER JOIN "activity_books" "i_0" ON "activities"."id" = "i_0"."id"'
330
- expect(base.cast_records(child).all.to_sql).to eql(result)
960
+ expect(base.expand_records(child, eager_load: true).all.to_sql).to eql(result)
331
961
  end
332
962
 
333
963
  it 'can be filtered by record type' do
334
964
  result = 'SELECT "activities".*, "activities"."tableoid"::regclass AS _record_class'
335
965
  result << ', "i_0"."description", "i_0"."url", "i_0"."activated"'
336
- result << ", \"activities\".\"tableoid\"::regclass::varchar IN ('activity_books') AS _auto_cast"
337
966
  result << ' FROM "activities"'
338
967
  result << ' LEFT OUTER JOIN "activity_books" "i_0" ON "activities"."id" = "i_0"."id"'
339
968
  result << " WHERE \"activities\".\"tableoid\"::regclass::varchar IN ('activity_books')"
340
- expect(base.cast_records(child, filter: true).all.to_sql).to eql(result)
969
+ expect(base.expand_records(child, filter: true, eager_load: true).all.to_sql).to eql(result)
341
970
  end
342
971
 
343
972
  it 'works with count and does not add extra columns' do
@@ -346,7 +975,7 @@ RSpec.describe 'TableInheritance' do
346
975
  result << ' LEFT OUTER JOIN "activity_books" "i_0" ON "activities"."id" = "i_0"."id"'
347
976
  result << ' LEFT OUTER JOIN "activity_posts" "i_1" ON "activities"."id" = "i_1"."id"'
348
977
  result << ' LEFT OUTER JOIN "activity_post_samples" "i_2" ON "activities"."id" = "i_2"."id"'
349
- query = get_last_executed_query{ base.cast_records.all.count }
978
+ query = get_last_executed_query{ base.expand_records(eager_load: true).all.count }
350
979
  expect(query).to eql(result)
351
980
  end
352
981
 
@@ -356,14 +985,34 @@ RSpec.describe 'TableInheritance' do
356
985
  result << ' LEFT OUTER JOIN "activity_books" "i_0" ON "activities"."id" = "i_0"."id"'
357
986
  result << ' LEFT OUTER JOIN "activity_posts" "i_1" ON "activities"."id" = "i_1"."id"'
358
987
  result << ' LEFT OUTER JOIN "activity_post_samples" "i_2" ON "activities"."id" = "i_2"."id"'
359
- query = get_last_executed_query{ base.cast_records.all.sum(:id) }
988
+ query = get_last_executed_query{ base.expand_records(eager_load: true).all.sum(:id) }
360
989
  expect(query).to eql(result)
361
990
  end
362
991
 
992
+ it 'does not accumulate the extra columns through repeated chaining' do
993
+ relation = base.expand_records(eager_load: true)
994
+ 3.times do
995
+ relation = relation.where(active: nil)
996
+ relation.to_a
997
+ end
998
+
999
+ sql = relation.to_sql
1000
+ expect(sql.scan('_record_class').size).to eql(1)
1001
+ expect(sql.scan('"i_0"."description"').size).to eql(1)
1002
+ end
1003
+
1004
+ it 'filters by every expandable dependent when no type is given' do
1005
+ ActivityPost.create(title: 'Activity post', url: 'posturl1')
1006
+ records = base.expand_records(filter: true).order(:id).to_a
1007
+
1008
+ expect(records.map(&:class)).to eql([ActivityBook, ActivityPost])
1009
+ expect(records.map(&:url)).to eql(['bookurl1', 'posturl1'])
1010
+ end
1011
+
363
1012
  it 'returns the correct model object' do
364
1013
  ActivityPost.create(title: 'Activity post')
365
1014
  ActivityPost::Sample.create(title: 'Activity post')
366
- records = base.cast_records.order(:id).load.to_a
1015
+ records = base.expand_records(eager_load: true).order(:id).load.to_a
367
1016
 
368
1017
  expect(records[0]).to be_instance_of(Activity)
369
1018
  expect(records[1]).to be_instance_of(ActivityBook)
@@ -371,81 +1020,403 @@ RSpec.describe 'TableInheritance' do
371
1020
  expect(records[3]).to be_instance_of(ActivityPost::Sample)
372
1021
  end
373
1022
 
374
- it 'does not cast unnecessary records' do
1023
+ it 'only fully loads the requested records' do
375
1024
  ActivityPost.create(title: 'Activity post')
376
- records = base.cast_records(ActivityBook).order(:id).load.to_a
1025
+ records = base.expand_records(ActivityBook, eager_load: true).order(:id).load.to_a
377
1026
 
378
- expect(records[0]).to be_instance_of(Activity)
379
1027
  expect(records[1]).to be_instance_of(ActivityBook)
380
- expect(records[2]).to be_instance_of(Activity)
1028
+ expect(records[1].partial_record?).to be_falsey
1029
+ expect(records[2]).to be_instance_of(ActivityPost)
1030
+ expect(records[2].partial_record?).to be_truthy
381
1031
  end
382
1032
 
383
1033
  it 'correctly identifies same name attributes' do
384
1034
  ActivityPost.create(title: 'Activity post', url: 'posturl1')
385
- records = base.cast_records.order(:id).load.to_a
1035
+ records = base.expand_records(eager_load: true).order(:id).load.to_a
386
1036
 
387
1037
  expect(records[1].url).to eql('bookurl1')
388
1038
  expect(records[2].url).to eql('posturl1')
389
1039
  end
390
1040
 
391
- # TODO: Maybe in the future
392
- xit 'does not make internal inheritance attributes accessible' do
393
- record = base.cast_records.order(:id).load.last
1041
+ it 'does not make internal inheritance attributes accessible' do
1042
+ record = base.expand_records(eager_load: true).order(:id).load.last
394
1043
 
395
1044
  expect(record).to be_instance_of(ActivityBook)
396
1045
  expect(record).not_to respond_to(:_record_class)
397
- expect(record).not_to respond_to(:_auto_cast)
398
1046
  end
399
- end
400
1047
 
401
- context 'cast record' do
402
- before :each do
403
- base.create(title: 'Activity test')
404
- child.create(title: 'Activity book')
405
- other.create(name: 'An author name')
406
- base.instance_variable_set(:@casted_dependents, nil)
1048
+ it 'drops sibling dependent columns from a casted record' do
1049
+ ActivityPost.create(title: 'Activity post', url: 'posturl1')
1050
+ record = base.expand_records(eager_load: true).order(:id).load[1]
1051
+
1052
+ expect(record).to be_instance_of(ActivityBook)
1053
+ expect(record.url).to eql('bookurl1')
1054
+ expect(record.attributes).not_to have_key('file')
1055
+ expect(record.attributes).not_to have_key('post_id')
1056
+ expect { ActivityBook.new(record.attributes) }.not_to raise_error
1057
+ end
1058
+
1059
+ it 'drops a genuine dependent-prefixed column from a casted record' do
1060
+ ActivityPost.create(title: 'Activity post', url: 'posturl1')
1061
+ record = base.expand_records(eager_load: true).order(:id).load[1]
1062
+
1063
+ expect(record).to be_instance_of(ActivityBook)
1064
+ expect(record.attributes).not_to have_key('activity_posts__activated')
1065
+ end
1066
+
1067
+ it 'drops sibling dependent columns from a non-casted parent record' do
1068
+ record = base.expand_records(eager_load: true).order(:id).load[0]
1069
+
1070
+ expect(record).to be_instance_of(Activity)
1071
+ expect(record.attributes).not_to have_key('description')
1072
+ expect(record.attributes).not_to have_key('url')
1073
+ expect { Activity.new(record.attributes) }.not_to raise_error
1074
+ end
1075
+
1076
+ it 'defaults to every dependent that adds columns' do
1077
+ relation = base.expand_records
1078
+ expect(relation.expand_records_values).to \
1079
+ eql(base.inheritance_expandable_dependents.values)
407
1080
  end
408
1081
 
409
- it 'does not affect normal records' do
410
- expect(base.first.cast_record).to be_a(base)
411
- expect(child.first.cast_record).to be_a(child)
412
- expect(other.first.cast_record).to be_a(other)
1082
+ it 'limits the expansion to the given models' do
1083
+ relation = base.expand_records(child)
1084
+ expect(relation.expand_records_values).to eql([child])
413
1085
  end
414
1086
 
415
- it 'rises an error when the casted model cannot be defined' do
416
- base.instance_variable_set(:@casted_dependents, {})
417
- expect{ base.second.cast_record }.to raise_error(ArgumentError, /to type 'activity_books'/)
1087
+ it 'does not add joins without eager loading' do
1088
+ result = 'SELECT "activities".*'
1089
+ result << ', "activities"."tableoid"::regclass AS _record_class'
1090
+ result << ' FROM "activities"'
1091
+ expect(base.expand_records.all.to_sql).to eql(result)
418
1092
  end
419
1093
 
420
- it 'can return the record class even when the auxiliary statement is not mentioned' do
421
- expect(base.first._record_class).to eql('activities')
422
- expect(base.second._record_class).to eql('activity_books')
423
- expect(other.first._record_class).to eql('authors')
1094
+ it 'cannot be combined with itself only' do
1095
+ expect { base.itself_only.expand_records }.to \
1096
+ raise_error(Torque::PostgreSQL::InheritanceError, /itself_only/)
1097
+ expect { base.expand_records.itself_only }.to \
1098
+ raise_error(Torque::PostgreSQL::InheritanceError, /itself_only/)
424
1099
  end
425
1100
 
426
- it 'does trigger record casting when accessed through inheritance' do
427
- expect(base.second.cast_record).to eql(child.first)
1101
+ it 'cannot be merged with itself only' do
1102
+ expect { base.expand_records.merge(base.itself_only) }.to \
1103
+ raise_error(Torque::PostgreSQL::InheritanceError, /itself_only/)
1104
+ expect { base.itself_only.merge(base.expand_records) }.to \
1105
+ raise_error(Torque::PostgreSQL::InheritanceError, /itself_only/)
428
1106
  end
429
1107
 
430
- context 'using uuid' do
431
- let(:base) { Question }
432
- let(:child) { QuestionSelect }
1108
+ it 'loads the missing columns with one query per table' do
1109
+ ActivityPost.create(title: 'Activity post', url: 'posturl1')
1110
+ records = nil
433
1111
 
434
- before :each do
435
- base.create(title: 'Simple question')
436
- child.create(title: 'Select question')
437
- base.instance_variable_set(:@casted_dependents, nil)
1112
+ queries = capture_executed_queries do
1113
+ records = base.expand_records.order(:id).load.to_a
438
1114
  end
439
1115
 
440
- it 'does not affect normal records' do
441
- expect(base.first.cast_record).to be_a(base)
442
- expect(child.first.cast_record).to be_a(child)
1116
+ expect(queries.size).to eql(3)
1117
+ expect(records[1]).to be_instance_of(ActivityBook)
1118
+ expect(records[1].url).to eql('bookurl1')
1119
+ expect(records[2]).to be_instance_of(ActivityPost)
1120
+ expect(records[2].url).to eql('posturl1')
1121
+ end
1122
+
1123
+ it 'produces complete and writable records' do
1124
+ record = base.expand_records.order(:id).load.to_a[1]
1125
+
1126
+ expect(record.partial_record?).to be_falsey
1127
+ expect(record.readonly?).to be_falsey
1128
+ expect(record.update!(title: 'Changed')).to be_truthy
1129
+ end
1130
+
1131
+ it 'does not leave expanded records dirty' do
1132
+ record = base.expand_records.order(:id).load.to_a[1]
1133
+
1134
+ expect(record.changed?).to be_falsey
1135
+ expect(record.changes).to be_empty
1136
+ end
1137
+
1138
+ it 'only selects the primary key and the extra columns' do
1139
+ queries = capture_executed_queries do
1140
+ base.expand_records(child).order(:id).load.to_a
443
1141
  end
444
1142
 
445
- it 'does trigger record casting when accessed through inheritance' do
446
- expect(base.second.cast_record).to eql(child.first)
1143
+ expansion = queries.find { |sql| sql.include?('activity_books') }
1144
+ expect(expansion).to match(/SELECT "activity_books"\."id"/)
1145
+ expect(expansion).to include('"description"')
1146
+ expect(expansion).to include('"url"')
1147
+ expect(expansion).not_to include('"title"')
1148
+ end
1149
+
1150
+ it 'reads the expansion from only the target table' do
1151
+ ActivityPost.create(title: 'Activity post', url: 'posturl1')
1152
+
1153
+ queries = capture_executed_queries do
1154
+ base.expand_records(ActivityPost).order(:id).load.to_a
447
1155
  end
1156
+
1157
+ expansion = queries.find { |sql| sql.include?('activity_posts') }
1158
+ expect(expansion).to include('FROM ONLY "activity_posts"')
1159
+ expect(expansion).not_to include('_record_class')
1160
+ end
1161
+
1162
+ it 'leaves records partial when the inherited row is gone' do
1163
+ vanished = child.create(title: 'Vanished book', url: 'bookurl2')
1164
+ covered = child.order(:id).first
1165
+ row = { 'description' => nil, 'url' => covered.url, 'activated' => nil }
1166
+ only_covered = ->(*) { { covered.id => row } }
1167
+
1168
+ allow_any_instance_of(Torque::PostgreSQL::Inheritance::Expander).to \
1169
+ receive(:fetch, &only_covered)
1170
+
1171
+ records = base.expand_records.order(:id).load.to_a.last(2)
1172
+
1173
+ expect(records.first.id).to eql(covered.id)
1174
+ expect(records.first.partial_record?).to be_falsey
1175
+ expect(records.first.url).to eql('bookurl1')
1176
+
1177
+ expect(records.last.id).to eql(vanished.id)
1178
+ expect(records.last.partial_record?).to be_truthy
1179
+ expect { records.last.url }.to raise_error(ActiveModel::MissingAttributeError)
1180
+ end
1181
+
1182
+ it 'preserves an explicit readonly through expansion' do
1183
+ record = base.readonly.expand_records.order(:id).load.to_a[1]
1184
+
1185
+ expect(record.url).to eql('bookurl1')
1186
+ expect(record.readonly?).to be_truthy
1187
+ expect { record.update!(title: 'Changed') }.to \
1188
+ raise_error(ActiveRecord::ReadOnlyRecord)
1189
+ end
1190
+ end
1191
+ end
1192
+
1193
+ context 'on associations' do
1194
+ let(:author) { Author.create!(name: 'An author name') }
1195
+
1196
+ before :each do
1197
+ Activity.create!(title: 'Plain activity', author: author)
1198
+ ActivityBook.create!(title: 'A book', url: 'bookurl1', author: author)
1199
+ ActivityPost.create!(title: 'A post', url: 'posturl1', author: author)
1200
+ end
1201
+
1202
+ it 'returns the correct classes through a preloaded association' do
1203
+ activities = Author.includes(:activities).first.activities.sort_by(&:id)
1204
+
1205
+ expect(activities.map(&:class)).to \
1206
+ eql([Activity, ActivityBook, ActivityPost])
1207
+ end
1208
+
1209
+ it 'leaves preloaded records partial without expanding' do
1210
+ activities = Author.includes(:activities).first.activities.sort_by(&:id)
1211
+
1212
+ expect(activities[1].partial_record?).to be_truthy
1213
+ expect { activities[1].url }.to raise_error(ActiveModel::MissingAttributeError)
1214
+ end
1215
+
1216
+ it 'expands preloaded records when merged' do
1217
+ relation = Author.includes(:activities).merge(Activity.expand_records)
1218
+ activities = relation.first.activities.sort_by(&:id)
1219
+
1220
+ expect(activities[1]).to be_instance_of(ActivityBook)
1221
+ expect(activities[1].url).to eql('bookurl1')
1222
+ expect(activities[1].partial_record?).to be_falsey
1223
+ expect(activities[1].changed?).to be_falsey
1224
+ expect(activities[2].url).to eql('posturl1')
1225
+ end
1226
+
1227
+ it 'uses one query per table when expanding through an association' do
1228
+ other_author = Author.create!(name: 'Another author name')
1229
+ Activity.create!(title: 'Other activity', author: other_author)
1230
+ ActivityBook.create!(title: 'Other book', url: 'bookurl2', author: other_author)
1231
+ ActivityPost.create!(title: 'Other post', url: 'posturl2', author: other_author)
1232
+
1233
+ queries = capture_executed_queries do
1234
+ Author.includes(:activities).merge(Activity.expand_records).load.to_a
448
1235
  end
1236
+
1237
+ expect(queries.size).to eql(4)
1238
+ end
1239
+
1240
+ it 'accumulates targets across repeated cross-model merges' do
1241
+ relation = Author.includes(:activities)
1242
+ .merge(Activity.expand_records(ActivityBook))
1243
+ .merge(Activity.expand_records(ActivityPost))
1244
+
1245
+ expect(relation.expand_records_scoped_value[Activity]).to \
1246
+ contain_exactly(ActivityBook, ActivityPost)
1247
+ end
1248
+
1249
+ it 'refuses to merge an eager loaded expansion from another model' do
1250
+ expect { Author.includes(:activities).merge(Activity.expand_records(eager_load: true)) }.to \
1251
+ raise_error(Torque::PostgreSQL::InheritanceError, /eager load/)
1252
+ end
1253
+
1254
+ it 'does not raise when a polymorphic association is mixed into the includes' do
1255
+ user = User.create!(name: 'A user')
1256
+ Comment.create!(user: user, content: 'A comment')
1257
+
1258
+ expect do
1259
+ Comment.includes(:commentable).merge(Activity.expand_records).load.to_a
1260
+ end.not_to raise_error
1261
+ end
1262
+ end
1263
+
1264
+ context 'on eager loading' do
1265
+ let(:author) { Author.create!(name: 'An author name') }
1266
+
1267
+ let(:post_record) { Post.create!(title: 'A post record', author: author) }
1268
+
1269
+ before :each do
1270
+ Activity.create!(title: 'Plain activity', author: author)
1271
+ ActivityBook.create!(title: 'A book', url: 'bookurl1', author: author)
1272
+ ActivityPost.create!(title: 'A post', url: 'posturl1', author: author, post: post_record)
1273
+ ActivityPost::Sample.create!(title: 'A sample', url: 'sampleurl1', author: author)
1274
+ end
1275
+
1276
+ it 'casts records the same way includes does' do
1277
+ eager = Activity.eager_load(:author).order(:id).to_a
1278
+ included = Activity.includes(:author).order(:id).to_a
1279
+
1280
+ expect(eager.map(&:class)).to eql(included.map(&:class))
1281
+ expect(eager.map(&:class)).to \
1282
+ eql([Activity, ActivityBook, ActivityPost, ActivityPost::Sample])
1283
+ end
1284
+
1285
+ it 'leaves eager loaded records partial' do
1286
+ record = Activity.eager_load(:author).order(:id).to_a[1]
1287
+
1288
+ expect(record).to be_instance_of(ActivityBook)
1289
+ expect(record.partial_record?).to be_truthy
1290
+ expect(record.readonly?).to be_truthy
1291
+ expect { record.url }.to raise_error(ActiveModel::MissingAttributeError)
1292
+ end
1293
+
1294
+ it 'still loads the association without an extra query' do
1295
+ record = Activity.eager_load(:author).order(:id).first
1296
+
1297
+ queries = capture_executed_queries { record.author }
1298
+ expect(queries).to be_empty
1299
+ expect(record.author).to eql(author)
1300
+ end
1301
+
1302
+ it 'loads a nested eager load without disturbing the aliasing' do
1303
+ queries = capture_executed_queries do
1304
+ record = Activity.eager_load(author: :posts).order(:id).load.first
1305
+
1306
+ expect(record).to be_instance_of(Activity)
1307
+ expect(record.author).to eql(author)
1308
+ expect(record.author.posts.map(&:title)).to eql(['A post record'])
1309
+ end
1310
+
1311
+ expect(queries.size).to eql(1)
1312
+ end
1313
+
1314
+ it 'loads multiple eager loads without disturbing the aliasing' do
1315
+ queries = capture_executed_queries do
1316
+ record = ActivityPost.eager_load(:author, :post).order(:id).first
1317
+
1318
+ expect(record).to be_instance_of(ActivityPost)
1319
+ expect(record.author).to eql(author)
1320
+ expect(record.post.title).to eql('A post record')
1321
+ end
1322
+
1323
+ expect(queries.size).to eql(1)
1324
+ end
1325
+
1326
+ it 'does not add the marker when the user provides an explicit select' do
1327
+ sql = Activity.eager_load(:author).select(:id, :title).to_sql
1328
+ expect(sql).not_to include('tableoid')
1329
+ end
1330
+
1331
+ it 'does not add the marker when restricted to itself_only' do
1332
+ sql = Activity.itself_only.eager_load(:author).to_sql
1333
+ expect(sql).not_to include('tableoid')
1334
+ end
1335
+
1336
+ it 'does not add the root marker when the root model does not physically inherit' do
1337
+ sql = Author.eager_load(:activities).to_sql
1338
+ expect(sql).not_to include('_record_class')
1339
+ end
1340
+
1341
+ it 'casts an inheriting model loaded as a joined part' do
1342
+ records = Author.eager_load(:activities).first.activities.sort_by(&:id)
1343
+
1344
+ expect(records.map(&:class)).to \
1345
+ eql([Activity, ActivityBook, ActivityPost, ActivityPost::Sample])
1346
+ end
1347
+
1348
+ it 'casts a joined part the same way includes does' do
1349
+ eager = Author.eager_load(:activities).first.activities.sort_by(&:id)
1350
+ included = Author.includes(:activities).first.activities.sort_by(&:id)
1351
+
1352
+ expect(eager.map(&:class)).to eql(included.map(&:class))
1353
+ end
1354
+
1355
+ it 'casts a joined part without any extra query' do
1356
+ queries = capture_executed_queries do
1357
+ records = Author.eager_load(:activities).to_a.first.activities
1358
+
1359
+ expect(records.map(&:class)).to include(ActivityBook)
1360
+ end
1361
+
1362
+ expect(queries.size).to eql(1)
1363
+ end
1364
+
1365
+ it 'leaves a casted joined part partial and read-only' do
1366
+ record = Author.eager_load(:activities).first.activities.sort_by(&:id)[1]
1367
+
1368
+ expect(record).to be_instance_of(ActivityBook)
1369
+ expect(record.partial_record?).to be_truthy
1370
+ expect(record.readonly?).to be_truthy
1371
+ expect { record.url }.to raise_error(ActiveModel::MissingAttributeError)
1372
+ end
1373
+
1374
+ it 'aliases the joined marker using the pattern of the other columns' do
1375
+ sql = Author.eager_load(:activities).to_sql
1376
+
1377
+ expect(sql).to match(/"activities"\."tableoid"::regclass AS t1_r\d+/)
1378
+ expect(sql.scan('tableoid').size).to eql(1)
1379
+ end
1380
+
1381
+ it 'casts both ends when the root inherits as well' do
1382
+ record = Activity.eager_load(author: :activities).order(:id).to_a[1]
1383
+
1384
+ expect(record).to be_instance_of(ActivityBook)
1385
+ expect(record.author.activities.sort_by(&:id).map(&:class)).to \
1386
+ eql([Activity, ActivityBook, ActivityPost, ActivityPost::Sample])
1387
+ end
1388
+
1389
+ it 'keeps the joined marker stable when the relation is built twice' do
1390
+ relation = Author.eager_load(:activities)
1391
+ relation.to_sql
1392
+
1393
+ expect(relation.to_sql.scan('tableoid').size).to eql(1)
1394
+ expect(relation.first.activities.map(&:class)).to include(ActivityBook)
1395
+ end
1396
+
1397
+ it 'does not add the marker when the relation has an explicit from clause' do
1398
+ sql = Activity.from(Activity.itself_only, :activities).eager_load(:author).to_sql
1399
+ expect(sql).not_to include('tableoid')
1400
+ end
1401
+
1402
+ it 'adds the marker exactly once to the generated sql' do
1403
+ sql = Activity.eager_load(:author).to_sql
1404
+
1405
+ expect(sql.scan('_record_class').size).to eql(1)
1406
+ expect(sql.scan('tableoid').size).to eql(1)
1407
+ end
1408
+
1409
+ it 'casts and fully expands when merged with expand_records, with no extra author query' do
1410
+ relation = Activity.eager_load(:author).merge(Activity.expand_records)
1411
+ records = relation.order(:id).to_a
1412
+
1413
+ expect(records.map(&:class)).to \
1414
+ eql([Activity, ActivityBook, ActivityPost, ActivityPost::Sample])
1415
+ expect(records.map(&:partial_record?)).to eql([false, false, false, false])
1416
+ expect(records[1].url).to eql('bookurl1')
1417
+
1418
+ queries = capture_executed_queries { records.each(&:author) }
1419
+ expect(queries).to be_empty
449
1420
  end
450
1421
  end
451
1422
  end