with_model 2.1.7 → 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.
@@ -0,0 +1,87 @@
1
+ require "active_support/descendants_tracker"
2
+
3
+ module WithModel
4
+ # Based on https://github.com/rails/rails/blob/491afff27e2dd3d5f301b478b9a43d3c31709af8/activesupport/lib/active_support/descendants_tracker.rb
5
+ module DescendantsTracker
6
+ if RUBY_ENGINE == "ruby"
7
+ # On MRI `ObjectSpace::WeakMap` keys are weak references.
8
+ # So we can simply use WeakMap as a `Set`.
9
+ class WeakSet < ObjectSpace::WeakMap # :nodoc:
10
+ alias_method :to_a, :keys
11
+
12
+ def <<(object)
13
+ self[object] = true
14
+ end
15
+ end
16
+ else
17
+ # On TruffleRuby `ObjectSpace::WeakMap` keys are strong references.
18
+ # So we use `object_id` as a key and the actual object as a value.
19
+ #
20
+ # JRuby for now doesn't have Class#descendant, but when it will, it will likely
21
+ # have the same WeakMap semantic than Truffle so we future proof this as much as possible.
22
+ class WeakSet # :nodoc:
23
+ def initialize
24
+ @map = ObjectSpace::WeakMap.new
25
+ end
26
+
27
+ def [](object)
28
+ @map.key?(object.object_id)
29
+ end
30
+ alias_method :include?, :[]
31
+
32
+ def []=(object, _present)
33
+ @map[object.object_id] = object
34
+ end
35
+
36
+ def to_a
37
+ @map.values
38
+ end
39
+
40
+ def <<(object)
41
+ self[object] = true
42
+ end
43
+ end
44
+ end
45
+ @excluded_descendants = WeakSet.new
46
+
47
+ class << self
48
+ def clear(classes) # :nodoc:
49
+ classes.each do |klass|
50
+ @excluded_descendants << klass
51
+ klass.descendants.each do |descendant|
52
+ @excluded_descendants << descendant
53
+ end
54
+ end
55
+ end
56
+
57
+ def reject!(classes) # :nodoc:
58
+ if @excluded_descendants
59
+ classes.reject! { |d| @excluded_descendants.include?(d) }
60
+ end
61
+ classes
62
+ end
63
+ end
64
+
65
+ module DestroyedClassesFiltering
66
+ def subclasses
67
+ WithModel::DescendantsTracker.reject!(super)
68
+ end
69
+
70
+ def descendants
71
+ WithModel::DescendantsTracker.reject!(super)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ class ActiveRecord::Base
78
+ extend WithModel::DescendantsTracker::DestroyedClassesFiltering
79
+ end
80
+
81
+ module ActiveSupport
82
+ module DescendantsTracker
83
+ class << self
84
+ attr_reader :clear_disabled
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithModel
4
+ # Raised when the `superclass:` a model was given cannot be used: it is not an
5
+ # Active Record class at all, or it is one that cannot supply a table to a model
6
+ # which has none of its own - because it has no table itself
7
+ # (`ActiveRecord::Base`, or an abstract class), or because its table has no
8
+ # inheritance column and so cannot tell a subclass's rows apart.
9
+ #
10
+ # An `ArgumentError`, because each is a fact about the argument rather than about
11
+ # when it was looked at, and the superclass is needed the moment the model is
12
+ # built - unlike a table, which an example is free to create later.
13
+ class InvalidSuperclass < ArgumentError
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "with_model/invalid_superclass"
4
+
5
+ module WithModel
6
+ # Raised when the name given for a `superclass:` resolves to nothing at all.
7
+ #
8
+ # A kind of {WithModel::InvalidSuperclass}, so every superclass that cannot be
9
+ # used is catchable in one place, while a name that is simply not there - a
10
+ # typo, or a with_model superclass declared after the models inheriting it -
11
+ # can be told apart from a class that exists but cannot supply a table.
12
+ class MissingSuperclass < InvalidSuperclass
13
+ end
14
+ end
@@ -11,10 +11,13 @@ module WithModel
11
11
  # Provide a schema definition for the table, passed to ActiveRecord's `create_table`.
12
12
  # The table name will be auto-generated.
13
13
  #
14
+ # Pass `false` instead of options to create no table at all, so that the
15
+ # model inherits its superclass's table as Rails Single Table Inheritance
16
+ # requires. `table(false)` takes no block.
17
+ #
14
18
  # @see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-create_table
15
19
  def table(options = {}, &block)
16
- @model.table_options = options
17
- @model.table_block = block
20
+ @model.specify_table(options, block)
18
21
  end
19
22
 
20
23
  # Provide a class body for the ActiveRecord model.
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_record'
4
- require 'active_support/core_ext/string/inflections'
5
- require 'English'
6
- require 'with_model/constant_stubber'
7
- require 'with_model/methods'
8
- require 'with_model/table'
3
+ require "logger"
4
+ require "active_record"
5
+ require "active_support/core_ext/string/inflections"
6
+ require "English"
7
+ require "with_model/constant_stubber"
8
+ require "with_model/descendants_tracker"
9
+ require "with_model/methods"
10
+ require "with_model/invalid_superclass"
11
+ require "with_model/missing_superclass"
12
+ require "with_model/null_table"
13
+ require "with_model/table"
9
14
 
10
15
  module WithModel
11
16
  # In general, direct use of this class should be avoided. Instead use
@@ -14,17 +19,43 @@ module WithModel
14
19
  attr_writer :model_block, :table_block, :table_options
15
20
 
16
21
  # @param [Symbol] name The constant name to assign the model class to.
17
- # @param [Class] superclass The superclass for the created class. Should
18
- # 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.
19
27
  def initialize(name, superclass: ActiveRecord::Base)
20
28
  @name = name.to_sym
21
29
  @model_block = nil
22
30
  @table_block = nil
23
31
  @table_options = {}
24
- @superclass = superclass
32
+ @table_specified = false
33
+ @skip_table = false
34
+ @superclass_spec = superclass
25
35
  end
26
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
+
27
56
  def create
57
+ @superclass = resolve_superclass
58
+ @table = nil
28
59
  table.create
29
60
  @model = Class.new(@superclass) do
30
61
  extend WithModel::Methods
@@ -34,35 +65,76 @@ module WithModel
34
65
  end
35
66
 
36
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)
37
76
  stubber.unstub_const
38
77
  cleanup_descendants_tracking
39
78
  reset_dependencies_cache
40
- table.destroy
79
+ WithModel::DescendantsTracker.clear([@model])
41
80
  @model = nil
42
81
  end
43
82
 
44
83
  private
45
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
+
46
125
  def const_name
47
126
  @name.to_s.camelize.to_sym
48
127
  end
49
128
 
50
129
  def setup_model
51
- @model.table_name = table_name
130
+ table.configure(@model)
52
131
  @model.class_eval(&@model_block) if @model_block
53
132
  @model.reset_column_information
54
133
  end
55
134
 
56
135
  def cleanup_descendants_tracking
57
- if defined?(ActiveSupport::DescendantsTracker)
58
- if ActiveSupport::VERSION::MAJOR >= 7
59
- ActiveSupport::DescendantsTracker.clear([@model])
60
- else
61
- ActiveSupport::DescendantsTracker.class_variable_get(:@@direct_descendants).delete(ActiveRecord::Base)
62
- end
63
- elsif @model.superclass.respond_to?(:direct_descendants)
64
- @model.superclass.direct_descendants.delete(@model)
65
- end
136
+ ActiveSupport::DescendantsTracker.clear([@model]) \
137
+ unless ActiveSupport::DescendantsTracker.clear_disabled
66
138
  end
67
139
 
68
140
  def reset_dependencies_cache
@@ -76,7 +148,11 @@ module WithModel
76
148
  end
77
149
 
78
150
  def table
79
- @table ||= Table.new table_name, @table_options, &@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
80
156
  end
81
157
 
82
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_record'
3
+ require "active_record"
4
4
 
5
5
  module WithModel
6
6
  # In general, direct use of this class should be avoided. Instead use
@@ -8,12 +8,14 @@ module WithModel
8
8
  class Table
9
9
  # @param [Symbol] name The name of the table to create.
10
10
  # @param options Passed to ActiveRecord `create_table`.
11
+ # @param connection The connection to use for creating the table.
11
12
  # @param block Passed to ActiveRecord `create_table`.
12
13
  # @see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-create_table
13
- def initialize(name, options = {}, &block)
14
+ def initialize(name, options = {}, connection: ActiveRecord::Base.connection, &block)
14
15
  @name = name.freeze
15
16
  @options = options.freeze
16
17
  @block = block
18
+ @connection = connection
17
19
  end
18
20
 
19
21
  # Creates the table with the initialized options. Drops the table if
@@ -23,12 +25,26 @@ module WithModel
23
25
  connection.create_table(@name, **@options, &@block)
24
26
  end
25
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
+
26
40
  def destroy
27
41
  connection.drop_table(@name)
28
42
  end
29
43
 
30
44
  private
31
45
 
46
+ attr_reader :connection
47
+
32
48
  def exists?
33
49
  if connection.respond_to?(:data_source_exists?)
34
50
  connection.data_source_exists?(@name)
@@ -36,9 +52,5 @@ module WithModel
36
52
  connection.table_exists?(@name)
37
53
  end
38
54
  end
39
-
40
- def connection
41
- ActiveRecord::Base.connection
42
- end
43
55
  end
44
56
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithModel
4
- VERSION = '2.1.7'
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 'with_model/model'
4
- require 'with_model/model/dsl'
5
- require 'with_model/table'
6
- require 'with_model/version'
3
+ require "active_support/deprecation"
4
+ require "with_model/invalid_superclass"
5
+ require "with_model/missing_superclass"
6
+ require "with_model/model"
7
+ require "with_model/model/dsl"
8
+ require "with_model/null_table"
9
+ require "with_model/table"
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,12 +74,32 @@ 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
62
100
  # @param scope Passed to `before`/`after` in the test context. Rspec only.
63
101
  # @param [Symbol] runner The test running, either :rspec or :minitest, defaults to :rspec
64
- def setup_object(object, scope: nil, runner: nil) # rubocop:disable Metrics/MethodLength
102
+ def setup_object(object, scope: nil, runner: nil)
65
103
  case runner || WithModel.runner
66
104
  when :rspec
67
105
  before(*scope) do
@@ -76,7 +114,7 @@ module WithModel
76
114
  include MiniTestLifeCycle.call(object)
77
115
  end
78
116
  else
79
- raise ArgumentError, 'Unsupported test runner set, expected :rspec or :minitest'
117
+ raise ArgumentError, "Unsupported test runner set, expected :rspec or :minitest"
80
118
  end
81
119
  end
82
120
  end