with_model 2.2.0 → 2.3.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.
@@ -7,6 +7,9 @@ require "English"
7
7
  require "with_model/constant_stubber"
8
8
  require "with_model/descendants_tracker"
9
9
  require "with_model/methods"
10
+ require "with_model/invalid_superclass"
11
+ require "with_model/missing_superclass"
12
+ require "with_model/null_table"
10
13
  require "with_model/table"
11
14
 
12
15
  module WithModel
@@ -16,17 +19,43 @@ module WithModel
16
19
  attr_writer :model_block, :table_block, :table_options
17
20
 
18
21
  # @param [Symbol] name The constant name to assign the model class to.
19
- # @param [Class] superclass The superclass for the created class. Should
20
- # have `ActiveRecord::Base` as an ancestor.
22
+ # @param superclass The superclass for the created class. Either a Class
23
+ # having `ActiveRecord::Base` as an ancestor, a String naming one, or a
24
+ # callable returning one. A String or callable is resolved afresh for
25
+ # every example, which is what allows another `with_model` model - whose
26
+ # constant does not exist when this line is read - to be the superclass.
21
27
  def initialize(name, superclass: ActiveRecord::Base)
22
28
  @name = name.to_sym
23
29
  @model_block = nil
24
30
  @table_block = nil
25
31
  @table_options = {}
26
- @superclass = superclass
32
+ @table_specified = false
33
+ @skip_table = false
34
+ @superclass_spec = superclass
27
35
  end
28
36
 
37
+ # Records what {WithModel::Model::DSL#table} was asked for, including the
38
+ # fact that it was asked for at all.
39
+ def specify_table(options, block)
40
+ @table_specified = true
41
+
42
+ if options == false
43
+ raise ArgumentError, "table does not take a block when its first argument is falsy" if block
44
+
45
+ @skip_table = true
46
+ else
47
+ @table_options = options
48
+ @table_block = block
49
+ end
50
+ end
51
+
52
+ # Whether a table was specified at all. A `table` call with no arguments
53
+ # counts, so this cannot be inferred from the options and block alone.
54
+ def table_specified? = @table_specified
55
+
29
56
  def create
57
+ @superclass = resolve_superclass
58
+ @table = nil
30
59
  table.create
31
60
  @model = Class.new(@superclass) do
32
61
  extend WithModel::Methods
@@ -36,22 +65,69 @@ module WithModel
36
65
  end
37
66
 
38
67
  def destroy
68
+ # Test runners tear down even when setup raised, so `create` may not have
69
+ # reached the point of building the model. Nothing was stubbed and nothing
70
+ # wrote rows, so there is nothing to undo.
71
+ return unless @model
72
+
73
+ # Before `unstub_const`: a teardown that identifies this model's own rows
74
+ # can only do so while the class still has its name.
75
+ table.teardown(@model)
39
76
  stubber.unstub_const
40
77
  cleanup_descendants_tracking
41
78
  reset_dependencies_cache
42
- table.destroy
43
79
  WithModel::DescendantsTracker.clear([@model])
44
80
  @model = nil
45
81
  end
46
82
 
47
83
  private
48
84
 
85
+ def resolve_superclass
86
+ spec = @superclass_spec
87
+ spec = spec.call if spec.respond_to?(:call)
88
+ spec = constantize_superclass(spec) if class_name?(spec)
89
+
90
+ unless spec.is_a?(Class) && spec <= ActiveRecord::Base
91
+ raise InvalidSuperclass,
92
+ "superclass must be a Class descending from ActiveRecord::Base, but was #{spec.inspect}. " \
93
+ "To refer to another with_model class, or anything else that is only defined once the " \
94
+ "test is running, name it with a String or a Symbol, or pass a callable returning it."
95
+ end
96
+
97
+ spec
98
+ end
99
+
100
+ # A Symbol reads naturally here, since with_model names its own models with
101
+ # them. Anything else has to answer to `to_str`, which only real strings do:
102
+ # every object has a `to_s`, so accepting that would quietly look up a
103
+ # constant named "42".
104
+ def class_name?(spec)
105
+ spec.is_a?(Symbol) || spec.respond_to?(:to_str)
106
+ end
107
+
108
+ # `to_s` is safe here where `class_name?` has already vouched for the value,
109
+ # and it keeps the Symbol intact for the message below, which reports what was
110
+ # passed in rather than what it was converted to.
111
+ #
112
+ # The NameError is worth quoting rather than replacing: for a namespaced name
113
+ # it reports which segment was missing, which this message cannot work out.
114
+ # Raising inside the rescue leaves it as the `cause` for anything that wants
115
+ # the backtrace.
116
+ def constantize_superclass(name)
117
+ name.to_s.constantize
118
+ rescue NameError => e
119
+ raise MissingSuperclass,
120
+ "superclass #{name.inspect} could not be resolved: #{e.message}. Names are resolved while " \
121
+ "the test is running, so a with_model superclass has to be declared before the models " \
122
+ "that inherit it."
123
+ end
124
+
49
125
  def const_name
50
126
  @name.to_s.camelize.to_sym
51
127
  end
52
128
 
53
129
  def setup_model
54
- @model.table_name = table_name
130
+ table.configure(@model)
55
131
  @model.class_eval(&@model_block) if @model_block
56
132
  @model.reset_column_information
57
133
  end
@@ -72,7 +148,11 @@ module WithModel
72
148
  end
73
149
 
74
150
  def table
75
- @table ||= Table.new table_name, @table_options, connection: @superclass.connection, &@table_block
151
+ @table ||= if @skip_table
152
+ NullTable.new(@superclass, @name)
153
+ else
154
+ Table.new table_name, @table_options, connection: @superclass.connection, &@table_block
155
+ end
76
156
  end
77
157
 
78
158
  def table_name
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "with_model/invalid_superclass"
5
+
6
+ module WithModel
7
+ # Stands in for a {WithModel::Table} when a model should inherit its
8
+ # superclass's table instead of getting one of its own, as Rails Single Table
9
+ # Inheritance requires. Selected by `table(false)`.
10
+ #
11
+ # In general, direct use of this class should be avoided. Instead use
12
+ # either the {WithModel high-level API} or {WithModel::Model::DSL low-level API}.
13
+ class NullTable
14
+ # @param [Class] superclass The resolved superclass whose table will be
15
+ # inherited.
16
+ # @param [Symbol, String] name The model's name, so that a refusal can say
17
+ # which model it is talking about.
18
+ def initialize(superclass, name)
19
+ @superclass = superclass
20
+ @name = name
21
+ end
22
+
23
+ # Creates nothing, but refuses a superclass that cannot support single
24
+ # table inheritance.
25
+ #
26
+ # Refusals describe the model's situation rather than the call that produced
27
+ # it. Any expression that evaluates to false selects a NullTable, so there
28
+ # is no call spelling to quote - and in with_model 3.0, omitting `table`
29
+ # will arrive here too.
30
+ #
31
+ # A superclass whose table does not exist *yet* is deliberately allowed: the
32
+ # table may be created later in the example, and a test may legitimately
33
+ # want a model whose table is missing. Active Record raises a clear
34
+ # `StatementInvalid` naming the table if it never appears, so refusing here
35
+ # would only forbid working setups.
36
+ #
37
+ # What is left is {WithModel::InvalidSuperclass}: both refusals are permanent
38
+ # facts about the superclass passed in, not about when it was looked at, so no
39
+ # amount of waiting makes them work.
40
+ def create
41
+ refuse "#{@superclass} has none to inherit" unless table_name?
42
+
43
+ # Nothing more can be checked until there is a table to look at.
44
+ return unless table_exists?
45
+ return if inheritance_column?
46
+
47
+ refuse "#{@superclass}'s table #{@superclass.table_name.inspect} has no " \
48
+ "#{inheritance_column.inspect} column, so Active Record cannot tell its rows " \
49
+ "apart from a subclass's"
50
+ end
51
+
52
+ # Deliberately does nothing: leaving `table_name` unassigned is what lets
53
+ # Active Record's own inheritance supply the superclass's table.
54
+ def configure(klass)
55
+ end
56
+
57
+ # Removes the rows this model wrote, which would otherwise outlive the
58
+ # constant that names them and make the superclass unloadable
59
+ # (`ActiveRecord::SubclassNotFound`).
60
+ #
61
+ # `unscoped` is required because a `default_scope` on the superclass would
62
+ # otherwise hide rows from the delete; the inheritance-column condition is
63
+ # then reapplied explicitly, since `unscoped` also discards the type
64
+ # condition that keeps this from touching the superclass's own rows.
65
+ #
66
+ # A table that does not exist holds no rows to remove, and failing here
67
+ # would fail an example whose body had already passed.
68
+ def teardown(klass)
69
+ return unless klass.table_exists?
70
+
71
+ klass.unscoped.where(klass.inheritance_column => klass.sti_name).delete_all
72
+ end
73
+
74
+ # Drops nothing; there is no table of our own to drop.
75
+ def destroy
76
+ end
77
+
78
+ private
79
+
80
+ def inheritance_column = @superclass.inheritance_column
81
+
82
+ # `ActiveRecord::Base` and abstract classes alike have no `table_name`, so
83
+ # this is what "nothing to inherit" actually looks like - `abstract_class?`
84
+ # is false for `ActiveRecord::Base` and so does not describe both.
85
+ def table_name? = @superclass.table_name.present?
86
+
87
+ def table_exists? = @superclass.table_exists?
88
+
89
+ def inheritance_column?
90
+ inheritance_column.present? && @superclass.columns_hash.key?(inheritance_column)
91
+ end
92
+
93
+ def refuse(problem)
94
+ raise InvalidSuperclass,
95
+ "with_model #{@name.inspect} has no table of its own, but #{problem}"
96
+ end
97
+ end
98
+ end
@@ -25,6 +25,18 @@ module WithModel
25
25
  connection.create_table(@name, **@options, &@block)
26
26
  end
27
27
 
28
+ # Points the model at this table.
29
+ def configure(klass)
30
+ klass.table_name = @name
31
+ end
32
+
33
+ # Removes everything this table holds by dropping it. The model is not
34
+ # needed, but is accepted so that {WithModel::NullTable} - which does need
35
+ # it - can stand in here.
36
+ def teardown(_klass)
37
+ destroy
38
+ end
39
+
28
40
  def destroy
29
41
  connection.drop_table(@name)
30
42
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithModel
4
- VERSION = "2.2.0"
4
+ VERSION = "2.3.0"
5
5
  end
data/lib/with_model.rb CHANGED
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/deprecation"
4
+ require "with_model/invalid_superclass"
5
+ require "with_model/missing_superclass"
3
6
  require "with_model/model"
4
7
  require "with_model/model/dsl"
8
+ require "with_model/null_table"
5
9
  require "with_model/table"
6
10
  require "with_model/version"
7
11
 
8
12
  module WithModel
9
13
  class MiniTestLifeCycle < Module
10
14
  def initialize(object)
15
+ # Each with_model includes a fresh module, so the last one declared sits
16
+ # earliest in the ancestor chain. Calling super() first means setup runs
17
+ # in declaration order while teardown unwinds in reverse, which is what
18
+ # lets one with_model refer to another declared above it.
11
19
  define_method :before_setup do
12
- object.create
13
20
  super() if defined?(super)
21
+ object.create
14
22
  end
15
23
 
16
24
  define_method :after_teardown do
@@ -32,6 +40,14 @@ module WithModel
32
40
  @runner ||= :rspec
33
41
  end
34
42
 
43
+ # The deprecator used for with_model's own deprecation warnings. Callers can
44
+ # silence it (`WithModel.deprecator.silenced = true`) or escalate it
45
+ # (`behavior = :raise`) while migrating, and Rails applications can register
46
+ # it in `Rails.application.deprecators`.
47
+ def self.deprecator
48
+ @deprecator ||= ActiveSupport::Deprecation.new("3.0", "with_model")
49
+ end
50
+
35
51
  # @param [Symbol] name The constant name to assign the model class to.
36
52
  # @param scope Passed to `before`/`after` in the test context. RSpec only.
37
53
  # @param options Passed to {WithModel::Model#initialize}.
@@ -41,6 +57,8 @@ module WithModel
41
57
  model = Model.new name, **options
42
58
  dsl = Model::DSL.new model
43
59
  dsl.instance_exec(&block) if block
60
+ # caller_locations(1) is this method's caller: the `with_model` line itself.
61
+ WithModel.warn_omitted_table(name, caller_locations(1)) unless model.table_specified?
44
62
 
45
63
  setup_object(model, scope: scope, runner: runner)
46
64
  end
@@ -56,6 +74,26 @@ module WithModel
56
74
  setup_object(table, scope: scope, runner: runner)
57
75
  end
58
76
 
77
+ # Warns once per call site, at definition time, rather than once per example.
78
+ # The horizon is stated in the message because `Deprecation#warn` does not
79
+ # interpolate the deprecator's `deprecation_horizon`.
80
+ #
81
+ # The callstack has to be handed in. `ActiveSupport::Deprecation` skips Rails'
82
+ # own frames and the standard library when working out where a warning came
83
+ # from, but with_model's frames look like anyone else's to it, so left to itself
84
+ # it reports this file for every omission in a suite.
85
+ #
86
+ # @param callstack Frames to blame, beginning with the caller to report.
87
+ def self.warn_omitted_table(name, callstack)
88
+ deprecator.warn(
89
+ "with_model #{name.inspect} was called without a `table`, which creates a table with only " \
90
+ "an id column. In with_model 3.0 no table will be created. Call `table` (with no " \
91
+ "arguments or an empty block) to keep a table, or `table(false)` to inherit the " \
92
+ "superclass's table (single table inheritance).",
93
+ callstack
94
+ )
95
+ end
96
+
59
97
  private
60
98
 
61
99
  # @param [Object] object The new model object instance to create
@@ -69,6 +69,7 @@ describe "ActiveRecord behaviors" do
69
69
  with_table :animals
70
70
 
71
71
  with_model :StuffedAnimal do
72
+ table
72
73
  model do
73
74
  has_many :tea_cups, as: :pet
74
75
  end
@@ -101,7 +102,9 @@ describe "ActiveRecord behaviors" do
101
102
  end
102
103
  end
103
104
 
104
- with_model :Country
105
+ with_model :Country do
106
+ table
107
+ end
105
108
 
106
109
  context "in earlier examples" do
107
110
  it "works as normal" do
@@ -4,6 +4,7 @@ require "spec_helper"
4
4
 
5
5
  describe "Descendants tracking" do
6
6
  with_model :BlogPost do
7
+ table
7
8
  model do
8
9
  def self.inspect
9
10
  "BlogPost class #{object_id}"
data/spec/readme_spec.rb CHANGED
@@ -2,21 +2,25 @@
2
2
 
3
3
  require "spec_helper"
4
4
 
5
- describe "A blog post" do
6
- before do
7
- stub_const("MyModule", Module.new)
8
- end
5
+ module MyModule; end
6
+
7
+ # A pre-existing model
8
+ class Car < ActiveRecord::Base
9
+ self.abstract_class = true
10
+ end
9
11
 
12
+ describe "A blog post" do
10
13
  with_model :BlogPost do
11
- # The table block works just like a migration.
14
+ # The table block (and an options hash) is passed to Active Record migration’s `create_table`.
12
15
  table do |t|
13
16
  t.string :title
14
17
  t.timestamps null: false
15
18
  end
16
19
 
17
- # The model block works just like the class definition.
20
+ # The model block is the Active Record model’s class body.
18
21
  model do
19
22
  include MyModule
23
+
20
24
  has_many :comments
21
25
  validates_presence_of :title
22
26
 
@@ -70,15 +74,15 @@ describe "A blog post" do
70
74
  expect(record.comments.count).to eq 1
71
75
  end
72
76
 
73
- # with_model classes can have inheritance.
74
- class Car < ActiveRecord::Base # standard:disable Lint/ConstantDefinitionInBlock
75
- self.abstract_class = true
77
+ # with_model classes can have inheritance. Car is abstract, so it has no table
78
+ # and Ford gets one of its own. To inherit a concrete superclass's table
79
+ # instead, see "Single table inheritance" below.
80
+ with_model :Ford, superclass: Car do
81
+ table
76
82
  end
77
83
 
78
- with_model :Ford, superclass: Car
79
-
80
84
  it "has a specified superclass" do
81
- expect(Ford < Car).to be true
85
+ expect(Ford.new).to be_a(Car)
82
86
  end
83
87
  end
84
88
 
@@ -116,3 +120,48 @@ describe "with table options" do
116
120
  expect(WithOptions.columns.map(&:name)).not_to include("id")
117
121
  end
118
122
  end
123
+
124
+ describe "with_model supports Single Table Inheritance" do
125
+ with_model :Sandwich do
126
+ table do |t|
127
+ t.string "type"
128
+ t.string "bread"
129
+ end
130
+ end
131
+
132
+ with_model :ChunkyBacon, superclass: :Sandwich do
133
+ table(false)
134
+ end
135
+
136
+ it "shares the superclass's table" do
137
+ expect(ChunkyBacon.table_name).to eq Sandwich.table_name
138
+ end
139
+
140
+ it "stores its own type" do
141
+ sandwich = ChunkyBacon.create!(bread: "rye")
142
+
143
+ expect(sandwich.reload.type).to eq "ChunkyBacon"
144
+ expect(Sandwich.first).to be_a ChunkyBacon
145
+ end
146
+ end
147
+
148
+ describe "with_model supports foreign keys" do
149
+ with_model :Author do
150
+ table
151
+ end
152
+
153
+ with_model :Book do
154
+ table do |t|
155
+ t.references :author, foreign_key: {to_table: Author.table_name}
156
+ end
157
+
158
+ model do
159
+ belongs_to :author
160
+ end
161
+ end
162
+
163
+ it "has a foreign key" do
164
+ expect { Book.create!(author_id: 0) }
165
+ .to raise_error ActiveRecord::InvalidForeignKey
166
+ end
167
+ end
data/spec/spec_helper.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require "bundler/setup"
4
4
  require "with_model"
5
5
 
6
+ WithModel.deprecator.behavior = :raise
7
+
6
8
  RSpec.configure do |config|
7
9
  config.extend WithModel
8
10