with_model 2.2.0 → 2.3.1

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,672 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "table(false)" do
6
+ # Defines a named Active Record class for one example, equivalent to:
7
+ #
8
+ # class HasLateTable < ActiveRecord::Base
9
+ # end
10
+ #
11
+ # RSpec removes the constant afterward; the matching `after` hook below also
12
+ # clears Active Record's descendant trackers so the class object cannot leak.
13
+ def stub_active_record_class(name, superclass: ActiveRecord::Base)
14
+ klass = Class.new(superclass)
15
+ stub_const(name.to_s, klass)
16
+ (@stubbed_active_record_classes ||= []) << klass
17
+ klass
18
+ end
19
+
20
+ after do
21
+ classes = @stubbed_active_record_classes
22
+ next unless classes
23
+
24
+ ActiveSupport::DescendantsTracker.clear(classes) \
25
+ unless ActiveSupport::DescendantsTracker.clear_disabled
26
+ ActiveSupport::Dependencies::Reference.clear! \
27
+ if defined?(ActiveSupport::Dependencies::Reference)
28
+ WithModel::DescendantsTracker.clear(classes)
29
+ end
30
+
31
+ describe "with a concrete superclass" do
32
+ with_model :HasTypeColumn do
33
+ table do |t|
34
+ t.string :type
35
+ t.string :name
36
+ end
37
+ end
38
+
39
+ with_model :Truck, superclass: :HasTypeColumn do
40
+ table(false)
41
+ model do
42
+ def honk = "beep"
43
+ end
44
+ end
45
+
46
+ it "inherits the superclass table rather than creating its own" do
47
+ expect(Truck.table_name).to eq HasTypeColumn.table_name
48
+ end
49
+
50
+ it "creates no table of its own" do
51
+ expect(ActiveRecord::Base.connection.tables.grep(/with_model_truck/)).to be_empty
52
+ end
53
+
54
+ it "reports the superclass as the STI base class" do
55
+ expect(Truck.base_class).to eq HasTypeColumn
56
+ end
57
+
58
+ it "still evaluates the model block" do
59
+ expect(Truck.new.honk).to eq "beep"
60
+ end
61
+
62
+ it "writes its own name to the inheritance column" do
63
+ Truck.create!(name: "big")
64
+ expect(HasTypeColumn.first.type).to eq "Truck"
65
+ end
66
+
67
+ it "is found through the superclass as an instance of itself" do
68
+ Truck.create!(name: "big")
69
+ expect(HasTypeColumn.first).to be_a Truck
70
+ end
71
+
72
+ # Deliberately a second example asserting the same thing: both models are
73
+ # rebuilt for every example, so a superclass resolved once and cached on the
74
+ # Model instance would still point to the first example's dead parent.
75
+ it "works in a second example too" do
76
+ Truck.create!(name: "also big")
77
+ expect(HasTypeColumn.first).to be_a Truck
78
+ end
79
+ end
80
+
81
+ describe "teardown" do
82
+ with_model :HasTypeColumn do
83
+ table do |t|
84
+ t.string :type
85
+ t.string :name
86
+ end
87
+ end
88
+
89
+ context "when child and superclass rows exist" do
90
+ after do
91
+ expect(HasTypeColumn.where(type: "Truck").count).to eq 0
92
+ expect(HasTypeColumn.where(type: nil).count).to eq 1
93
+ end
94
+
95
+ with_model :Truck, superclass: :HasTypeColumn do
96
+ table(false)
97
+ end
98
+
99
+ it "deletes its own rows and leaves the superclass's rows alone" do
100
+ Truck.create!(name: "child row")
101
+ HasTypeColumn.create!(name: "parent row")
102
+ end
103
+ end
104
+
105
+ context "when a child row exists" do
106
+ after do
107
+ expect { HasTypeColumn.first }.not_to raise_error
108
+ end
109
+
110
+ with_model :Truck, superclass: :HasTypeColumn do
111
+ table(false)
112
+ end
113
+
114
+ it "leaves the superclass queryable" do
115
+ Truck.create!(name: "child row")
116
+ end
117
+ end
118
+
119
+ context "with an abstract superclass" do
120
+ with_model :TablelessSuperclass do
121
+ table
122
+ model do
123
+ self.abstract_class = true
124
+ self.table_name = nil
125
+ end
126
+ end
127
+
128
+ it "does not delete rows when create raises WithModel::InvalidSuperclass" do
129
+ HasTypeColumn.create!(name: "parent row")
130
+ tableless_subclass = WithModel::Model.new(
131
+ :TablelessSubclass,
132
+ superclass: TablelessSuperclass
133
+ )
134
+ WithModel::Model::DSL.new(tableless_subclass).table(false)
135
+
136
+ expect { tableless_subclass.create }.to raise_error(WithModel::InvalidSuperclass)
137
+ expect { tableless_subclass.destroy }.not_to raise_error
138
+
139
+ expect(HasTypeColumn.count).to eq 1
140
+ end
141
+ end
142
+
143
+ context "when destruction of its row is aborted" do
144
+ with_model :AbortsDestroy do
145
+ table do |t|
146
+ t.string :type
147
+ end
148
+ model do
149
+ before_destroy { throw :abort }
150
+ end
151
+ end
152
+
153
+ subject(:null_table) do
154
+ WithModel::NullTable.new(AbortsDestroy, :TemporaryAbortedDestroy)
155
+ end
156
+
157
+ after do
158
+ expect(Object.const_defined?(:TemporaryAbortedDestroy, false)).to be false
159
+ end
160
+
161
+ with_model :TemporaryAbortedDestroy, superclass: :AbortsDestroy do
162
+ table(false)
163
+ end
164
+
165
+ it "fails loudly" do
166
+ TemporaryAbortedDestroy.create!
167
+
168
+ expect do
169
+ null_table.teardown(TemporaryAbortedDestroy)
170
+ end.to raise_error(ActiveRecord::RecordNotDestroyed)
171
+
172
+ AbortsDestroy.where(type: TemporaryAbortedDestroy.sti_name).delete_all
173
+ end
174
+ end
175
+
176
+ context "when its rows have dependent records protected by a foreign key" do
177
+ with_model :HasDependentChildren do
178
+ table do |t|
179
+ t.string :type
180
+ end
181
+ model do
182
+ has_many :dependent_children,
183
+ class_name: "DependentChild",
184
+ foreign_key: :parent_id,
185
+ inverse_of: :parent,
186
+ dependent: :destroy
187
+ end
188
+ end
189
+
190
+ with_model :DependentChild do
191
+ table do |t|
192
+ t.references :parent,
193
+ null: false,
194
+ foreign_key: {to_table: HasDependentChildren.table_name}
195
+ end
196
+ model do
197
+ belongs_to :parent,
198
+ class_name: "HasDependentChildren",
199
+ inverse_of: :dependent_children
200
+ end
201
+ end
202
+
203
+ with_model :OtherDependentParent, superclass: :HasDependentChildren do
204
+ table(false)
205
+ end
206
+
207
+ after do
208
+ expect(HasDependentChildren.unscoped.ids).to contain_exactly(@superclass.id, @sibling.id)
209
+ expect(DependentChild.pluck(:parent_id)).to contain_exactly(@superclass.id, @sibling.id)
210
+ end
211
+
212
+ with_model :TemporaryDependentParent, superclass: :HasDependentChildren do
213
+ table(false)
214
+ end
215
+
216
+ it "destroys its dependents without removing superclass or sibling rows" do
217
+ temporary = TemporaryDependentParent.create!
218
+ @superclass = HasDependentChildren.create!
219
+ @sibling = OtherDependentParent.create!
220
+ [temporary, @superclass, @sibling].each do |parent|
221
+ parent.dependent_children.create!
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ describe "with a superclass that has a default_scope" do
228
+ with_model :HasDefaultScope do
229
+ table do |t|
230
+ t.string :type
231
+ t.boolean :archived, default: false
232
+ end
233
+ model do
234
+ default_scope { where(archived: false) }
235
+ end
236
+ end
237
+
238
+ after do
239
+ expect(HasDefaultScope.unscoped.where(type: "ScopedChild").count).to eq 0
240
+ end
241
+
242
+ with_model :ScopedChild, superclass: :HasDefaultScope do
243
+ table(false)
244
+ end
245
+
246
+ it "deletes rows the default scope would have hidden" do
247
+ ScopedChild.create!(archived: false)
248
+ ScopedChild.create!(archived: true)
249
+ end
250
+ end
251
+
252
+ describe "with a non-standard inheritance_column" do
253
+ with_model :HasCustomInheritanceColumn do
254
+ table do |t|
255
+ t.string :kind
256
+ t.string :name
257
+ end
258
+ model do
259
+ self.inheritance_column = "kind"
260
+ end
261
+ end
262
+
263
+ with_model :KindedChild, superclass: :HasCustomInheritanceColumn do
264
+ table(false)
265
+ end
266
+
267
+ it "writes to the custom column" do
268
+ KindedChild.create!(name: "x")
269
+ expect(HasCustomInheritanceColumn.first.kind).to eq "KindedChild"
270
+ end
271
+
272
+ it "accepts a Symbol inheritance_column" do
273
+ expect(HasCustomInheritanceColumn.inheritance_column).to eq "kind"
274
+ end
275
+ end
276
+
277
+ # The anchor case: a validation or teardown that hard-codes "type" passes
278
+ # every other example here and still scopes on the wrong column.
279
+ describe "with both a type column and a custom inheritance_column" do
280
+ with_model :CustomInheritanceWithType do
281
+ table do |t|
282
+ t.string :kind
283
+ t.string :type
284
+ t.string :name
285
+ end
286
+ model do
287
+ self.inheritance_column = "kind"
288
+ end
289
+ end
290
+
291
+ with_model :KindedTypeChild, superclass: :CustomInheritanceWithType do
292
+ table(false)
293
+ end
294
+
295
+ it "writes the custom column and leaves type null" do
296
+ KindedTypeChild.create!(name: "x")
297
+ row = CustomInheritanceWithType.first
298
+ expect(row.kind).to eq "KindedTypeChild"
299
+ expect(row.type).to be_nil
300
+ end
301
+ end
302
+
303
+ describe "with a with_model superclass" do
304
+ with_model :Parent do
305
+ table do |t|
306
+ t.string :type
307
+ t.string :name
308
+ end
309
+ end
310
+
311
+ with_model :StringChild, superclass: "Parent" do
312
+ table(false)
313
+ end
314
+
315
+ with_model :SymbolChild, superclass: :Parent do
316
+ table(false)
317
+ end
318
+
319
+ with_model :CallableChild, superclass: -> { Parent } do
320
+ table(false)
321
+ end
322
+
323
+ it "resolves a String superclass" do
324
+ expect(StringChild.superclass).to eq Parent
325
+ expect(StringChild.table_name).to eq Parent.table_name
326
+ end
327
+
328
+ # The same spelling with_model names its own models with.
329
+ it "resolves a Symbol superclass" do
330
+ expect(SymbolChild.superclass).to eq Parent
331
+ expect(SymbolChild.table_name).to eq Parent.table_name
332
+ end
333
+
334
+ it "resolves a callable superclass" do
335
+ expect(CallableChild.superclass).to eq Parent
336
+ end
337
+
338
+ it "round-trips through the parent" do
339
+ StringChild.create!(name: "x")
340
+ expect(Parent.first).to be_a StringChild
341
+ end
342
+
343
+ # Second example: the parent class object is rebuilt every example, so a
344
+ # superclass memoized on the first resolution refers to a dead class here.
345
+ it "resolves against the current parent in a later example" do
346
+ expect(StringChild.superclass).to eq Parent
347
+ expect(StringChild.superclass).to be Parent
348
+ end
349
+ end
350
+
351
+ describe "namespaced constants" do
352
+ with_model :HasTypeColumn do
353
+ table do |t|
354
+ t.string :type
355
+ t.string :name
356
+ end
357
+ end
358
+
359
+ # WithModel::ConstantStubber resolves a namespaced name with `const_get` and
360
+ # stubs only the last segment, so the namespace has to exist already.
361
+ before { stub_const("TableFalseSpec", Module.new) }
362
+
363
+ with_model "TableFalseSpec::Step", superclass: :HasTypeColumn do
364
+ table(false)
365
+ end
366
+
367
+ it "stores and round-trips the fully qualified name" do
368
+ TableFalseSpec::Step.create!(name: "x")
369
+ expect(HasTypeColumn.first.type).to eq "TableFalseSpec::Step"
370
+ expect(HasTypeColumn.first).to be_a TableFalseSpec::Step
371
+ end
372
+ end
373
+
374
+ # A missing table is a fact about the schema at that moment, not about the
375
+ # model, so it is allowed: the table may arrive later in the example, or its
376
+ # absence may be the very thing under test.
377
+ describe "when the superclass's table does not exist" do
378
+ # The shape a Rails app's ApplicationRecord takes: abstract, and inherited
379
+ # from rather than ActiveRecord::Base directly.
380
+ with_model :ApplicationRecord do
381
+ table
382
+ model do
383
+ self.abstract_class = true
384
+ self.table_name = nil
385
+ end
386
+ end
387
+
388
+ before do
389
+ stub_active_record_class(:HasMissingTable, superclass: ApplicationRecord)
390
+ end
391
+
392
+ with_model :Orphan, superclass: -> { HasMissingTable } do
393
+ table(false)
394
+ end
395
+
396
+ it "still defines the model, which inherits the missing table's name" do
397
+ # Not `new`: building an instance reads column information, which is
398
+ # itself a use of the absent table.
399
+ expect(Orphan).to be < HasMissingTable
400
+ expect(Orphan.table_name).to eq HasMissingTable.table_name
401
+ end
402
+
403
+ it "lets Active Record raise its own error, naming the table" do
404
+ expect { Orphan.create!(name: "x") }
405
+ .to raise_error(ActiveRecord::StatementInvalid, /#{HasMissingTable.table_name}/)
406
+ end
407
+
408
+ it "tears down without failing an example that otherwise passed" do
409
+ # Reaching the end of this example at all is the assertion: teardown has
410
+ # no rows to delete and must not go looking for them.
411
+ expect(Orphan.name).to eq "Orphan"
412
+ end
413
+ end
414
+
415
+ describe "when the superclass's table is created during the example" do
416
+ before { stub_active_record_class(:HasLateTable) }
417
+
418
+ with_model :LateChild, superclass: -> { HasLateTable } do
419
+ table(false)
420
+ end
421
+
422
+ after do
423
+ ActiveRecord::Base.connection.drop_table HasLateTable.table_name, if_exists: true
424
+ end
425
+
426
+ it "inherits the table once it exists" do
427
+ ActiveRecord::Base.connection.create_table HasLateTable.table_name, force: true do |t|
428
+ t.string :type
429
+ t.string :name
430
+ end
431
+ HasLateTable.reset_column_information
432
+
433
+ LateChild.create!(name: "late")
434
+
435
+ expect(HasLateTable.count).to eq 1
436
+ expect(HasLateTable.first).to be_a LateChild
437
+ end
438
+ end
439
+
440
+ describe "invalid configurations" do
441
+ # Creates immediately the same tableless subclass normally generated by:
442
+ #
443
+ # with_model :TablelessSubclass, superclass: superclass do
444
+ # table(false)
445
+ # end
446
+ #
447
+ # Bypassing `with_model`'s setup hook lets these examples assert failures
448
+ # raised while the superclass is resolved or the table configuration checked.
449
+ def create_tableless_subclass(superclass:, &dsl)
450
+ model = WithModel::Model.new(:TablelessSubclass, superclass: superclass)
451
+ WithModel::Model::DSL.new(model).instance_eval(&dsl || proc { table(false) })
452
+ model.create
453
+ end
454
+
455
+ it "names the model, which the superclass's name does not identify" do
456
+ expect { create_tableless_subclass(superclass: ActiveRecord::Base) }
457
+ .to raise_error(
458
+ WithModel::InvalidSuperclass,
459
+ /with_model :TablelessSubclass has no table of its own/
460
+ )
461
+ end
462
+
463
+ it "raises WithModel::InvalidSuperclass for ActiveRecord::Base" do
464
+ expect { create_tableless_subclass(superclass: ActiveRecord::Base) }
465
+ .to raise_error(WithModel::InvalidSuperclass, /ActiveRecord::Base has none to inherit/)
466
+ end
467
+
468
+ context "with an abstract superclass" do
469
+ with_model :TablelessSuperclass do
470
+ table
471
+ model do
472
+ self.abstract_class = true
473
+ self.table_name = nil
474
+ end
475
+ end
476
+
477
+ it "raises WithModel::InvalidSuperclass" do
478
+ expect { create_tableless_subclass(superclass: TablelessSuperclass) }
479
+ .to raise_error(WithModel::InvalidSuperclass, /TablelessSuperclass has none to inherit/)
480
+ end
481
+ end
482
+
483
+ context "with an abstract superclass a Rails app would call ApplicationRecord" do
484
+ with_model :ApplicationRecord do
485
+ table
486
+ model do
487
+ self.abstract_class = true
488
+ self.table_name = nil
489
+ end
490
+ end
491
+
492
+ it "raises WithModel::InvalidSuperclass" do
493
+ expect { create_tableless_subclass(superclass: ApplicationRecord) }
494
+ .to raise_error(WithModel::InvalidSuperclass, /ApplicationRecord has none to inherit/)
495
+ end
496
+ end
497
+
498
+ context "when the superclass table lacks the inheritance column" do
499
+ with_model :HasNoTypeColumn do
500
+ table do |t|
501
+ t.string :name
502
+ end
503
+ end
504
+
505
+ it "raises WithModel::InvalidSuperclass and names the missing column" do
506
+ expect { create_tableless_subclass(superclass: HasNoTypeColumn) }
507
+ .to raise_error(WithModel::InvalidSuperclass, /"type" column/)
508
+ end
509
+ end
510
+
511
+ context "when the superclass disables inheritance" do
512
+ with_model :HasNilInheritanceColumn do
513
+ table do |t|
514
+ t.string :name
515
+ end
516
+ model do
517
+ self.inheritance_column = nil
518
+ end
519
+ end
520
+
521
+ it "raises WithModel::InvalidSuperclass" do
522
+ expect { create_tableless_subclass(superclass: HasNilInheritanceColumn) }
523
+ .to raise_error(WithModel::InvalidSuperclass)
524
+ end
525
+ end
526
+
527
+ context "when table(false) is passed a block" do
528
+ with_model :HasTypeColumn do
529
+ table do |t|
530
+ t.string :type
531
+ t.string :name
532
+ end
533
+ end
534
+
535
+ it "raises ArgumentError" do
536
+ expect do
537
+ create_tableless_subclass(superclass: HasTypeColumn) { table(false) { |t| t.string :nope } }
538
+ end.to raise_error(ArgumentError, /table does not take a block when its first argument is falsy/)
539
+ end
540
+ end
541
+
542
+ describe "a superclass that cannot be found" do
543
+ it "quotes the reason it could not be resolved" do
544
+ expect { create_tableless_subclass(superclass: "NoSuchParent") }
545
+ .to raise_error(WithModel::MissingSuperclass,
546
+ /superclass "NoSuchParent" could not be resolved: uninitialized constant NoSuchParent/)
547
+ end
548
+
549
+ it "names the segment of a namespaced superclass that is missing" do
550
+ expect { create_tableless_subclass(superclass: "NoSuchNamespace::Deep::Parent") }
551
+ .to raise_error(WithModel::MissingSuperclass, /uninitialized constant NoSuchNamespace\./)
552
+ end
553
+
554
+ it "reports an unresolvable Symbol as the Symbol that was passed" do
555
+ expect { create_tableless_subclass(superclass: :NoSuchParent) }
556
+ .to raise_error(WithModel::MissingSuperclass,
557
+ /superclass :NoSuchParent could not be resolved: uninitialized constant NoSuchParent/)
558
+ end
559
+
560
+ it "keeps the NameError as its cause" do
561
+ # Ruby sets this for a raise inside a rescue, so the original backtrace
562
+ # stays reachable.
563
+ expect { create_tableless_subclass(superclass: "NoSuchParent") }
564
+ .to raise_error(WithModel::MissingSuperclass) { |error|
565
+ expect(error.cause).to be_a NameError
566
+ }
567
+ end
568
+ end
569
+
570
+ it "tells a missing superclass apart from an unusable one, but catches both" do
571
+ expect(WithModel::MissingSuperclass).to be < WithModel::InvalidSuperclass
572
+ expect(WithModel::InvalidSuperclass).to be < ArgumentError
573
+ end
574
+
575
+ it "says how to name a class that is not defined yet" do
576
+ expect { create_tableless_subclass(superclass: 42) }
577
+ .to raise_error(WithModel::InvalidSuperclass,
578
+ /name it with a String or a Symbol, or pass a callable returning it/)
579
+ end
580
+
581
+ it "raises WithModel::InvalidSuperclass for a value that only has a to_s" do
582
+ # Every object has one, so honoring it would look up the constant "42".
583
+ expect { create_tableless_subclass(superclass: 42) }
584
+ .to raise_error(WithModel::InvalidSuperclass, /but was 42/)
585
+ end
586
+
587
+ it "raises WithModel::InvalidSuperclass for a callable returning a non-ActiveRecord value" do
588
+ expect { create_tableless_subclass(superclass: -> { 42 }) }
589
+ .to raise_error(WithModel::InvalidSuperclass, /but was 42/)
590
+ end
591
+ end
592
+ end
593
+
594
+ RSpec.describe "omitting table" do
595
+ before do
596
+ allow(WithModel.deprecator).to receive(:warn)
597
+ end
598
+
599
+ # Defines the supplied block as if it had appeared in an isolated group:
600
+ #
601
+ # RSpec.describe "throwaway" do
602
+ # # supplied definitions
603
+ # end
604
+ #
605
+ # The warning happens at definition time, so the assertion cannot rely on this
606
+ # file's own example-group load order.
607
+ def define_group(&body)
608
+ RSpec::Core::ExampleGroup.describe("throwaway", &body)
609
+ end
610
+
611
+ it "warns when no table is specified" do
612
+ define_group { with_model(:Warned) }
613
+
614
+ expect(WithModel.deprecator).to have_received(:warn) do |message, _callstack|
615
+ expect(message).to match(/table/)
616
+ end
617
+ end
618
+
619
+ it "names the 3.0 horizon in the warning" do
620
+ define_group { with_model(:Warned) }
621
+
622
+ expect(WithModel.deprecator).to have_received(:warn) do |message, _callstack|
623
+ expect(message).to include("3.0")
624
+ end
625
+ end
626
+
627
+ # Left to itself ActiveSupport::Deprecation blames the first frame it does not
628
+ # recognize as Rails or the standard library, which is with_model's own source
629
+ # - the same line for every omission in a suite.
630
+ it "blames the with_model call rather than with_model's own source" do
631
+ expected_source_line = __LINE__ + 1
632
+ define_group { with_model(:Warned) }
633
+
634
+ expect(WithModel.deprecator).to have_received(:warn) do |_message, callstack|
635
+ expect(callstack.first.path).to eq(__FILE__)
636
+ expect(callstack.first.lineno).to eq(expected_source_line)
637
+ expect(callstack.first.path).not_to include("lib/with_model.rb")
638
+ end
639
+ end
640
+
641
+ context "when table(false) is used" do
642
+ with_model :HasTypeColumn do
643
+ table do |t|
644
+ t.string :type
645
+ t.string :name
646
+ end
647
+ end
648
+
649
+ it "does not warn" do
650
+ define_group { with_model(:Quiet, superclass: HasTypeColumn) { table(false) } }
651
+
652
+ expect(WithModel.deprecator).not_to have_received(:warn)
653
+ end
654
+ end
655
+
656
+ it "does not warn for an empty table block" do
657
+ define_group do
658
+ with_model(:Quiet) do
659
+ table do
660
+ end
661
+ end
662
+ end
663
+
664
+ expect(WithModel.deprecator).not_to have_received(:warn)
665
+ end
666
+
667
+ it "does not warn for a `table` call with no arguments" do
668
+ define_group { with_model(:Quiet) { table } }
669
+
670
+ expect(WithModel.deprecator).not_to have_received(:warn)
671
+ end
672
+ end