Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
### Unreleased

- Add `table(false)` to create no table, so that a model whose superclass is a
concrete Active Record class uses single table inheritance.
- Accept a String, a Symbol, or a callable for `superclass:`, resolved for each
example, so that another `with_model` model can be the superclass.
- Raise `WithModel::InvalidSuperclass` for a `superclass:` that cannot be used,
and `WithModel::MissingSuperclass` for a name that resolves to nothing. Both
are `ArgumentError`s, and the latter is an `InvalidSuperclass`, so either can
be rescued as narrowly as needed.
- Deprecate omitting `table`, which currently creates a table with only an id
column. In 3.0 it will create no table.
- Create minitest models in the order they are declared, so that a model can
refer to another model declared above it.
- Require Ruby 3.2 or later.

### 2.2.0

- Fix dependency tracking issue when `cache_classes: true` is set in Rails 7+.
Expand Down
128 changes: 108 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Install as usual: `gem install with_model` or add `gem 'with_model'` to your Gem
Extend `WithModel` into RSpec:

```ruby
require 'with_model'
require "with_model"

RSpec.configure do |config|
config.extend WithModel
Expand All @@ -31,7 +31,7 @@ end
Extend `WithModel` into minitest/spec and set the test runner explicitly:

```ruby
require 'with_model'
require "with_model"

WithModel.runner = :minitest

Expand All @@ -45,11 +45,16 @@ end
After setting up as above, call `with_model` and inside its block pass it a `table` block and a `model` block.

```ruby
require 'spec_helper'
require "spec_helper"

describe "A blog post" do
module MyModule; end
module MyModule; end

# A pre-existing model
class Car < ActiveRecord::Base
self.abstract_class = true
end

describe "A blog post" do
with_model :BlogPost do
# The table block (and an options hash) is passed to Active Record migration’s `create_table`.
table do |t|
Expand All @@ -60,15 +65,16 @@ describe "A blog post" do
# The model block is the Active Record model’s class body.
model do
include MyModule

has_many :comments
validates_presence_of :title

def self.some_class_method
'chunky'
"chunky"
end

def some_instance_method
'bacon'
"bacon"
end
end
end
Expand All @@ -91,38 +97,37 @@ describe "A blog post" do
end

it "has the module" do
expect(BlogPost.include?(MyModule)).to eq true
expect(BlogPost.include?(MyModule)).to be true
end

it "has the class method" do
expect(BlogPost.some_class_method).to eq 'chunky'
expect(BlogPost.some_class_method).to eq "chunky"
end

it "has the instance method" do
expect(BlogPost.new.some_instance_method).to eq 'bacon'
expect(BlogPost.new.some_instance_method).to eq "bacon"
end

it "can do all the things a regular model can" do
record = BlogPost.new
expect(record).not_to be_valid
record.title = "foo"
expect(record).to be_valid
expect(record.save).to eq true
expect(record.save).to be true
expect(record.reload).to eq record
record.comments.create!(:text => "Lorem ipsum")
record.comments.create!(text: "Lorem ipsum")
expect(record.comments.count).to eq 1
end

# with_model classes can have inheritance.
class Car < ActiveRecord::Base
self.abstract_class = true
end

# with_model classes can have inheritance. Car is abstract, so it has no table
# and Ford gets one of its own. To inherit a concrete superclass's table
# instead, see "Single table inheritance" below.
with_model :Ford, superclass: Car do
table
end

it "has a specified superclass" do
expect(Ford < Car).to eq true
expect(Ford.new).to be_a(Car)
end
end

Expand Down Expand Up @@ -150,8 +155,8 @@ end

describe "with table options" do
with_model :WithOptions do
table :id => false do |t|
t.string 'foo'
table id: false do |t|
t.string "foo"
t.timestamps null: false
end
end
Expand All @@ -162,6 +167,89 @@ describe "with table options" do
end
```

## Single table inheritance

Pass `table(false)` to create no table at all. Active Record's own inheritance
then supplies the superclass's table, which is what single table inheritance
needs.

The superclass can be another `with_model` model. Its constant does not exist yet
when the `superclass:` argument is read, so name it with a String or a Symbol, or
pass a callable returning it, and it will be resolved once per example.

```ruby
describe "with_model supports Single Table Inheritance" do
with_model :Sandwich do
table do |t|
t.string "type"
t.string "bread"
end
end

with_model :ChunkyBacon, superclass: :Sandwich do
table(false)
end

it "shares the superclass's table" do
expect(ChunkyBacon.table_name).to eq Sandwich.table_name
end

it "stores its own type" do
sandwich = ChunkyBacon.create!(bread: "rye")

expect(sandwich.reload.type).to eq "ChunkyBacon"
expect(Sandwich.first).to be_a ChunkyBacon
end
end
```

A `superclass:` that cannot be used raises `WithModel::InvalidSuperclass`: one
that is not an Active Record class, one with no table of its own
(`ActiveRecord::Base`, or an abstract class such as a Rails app's
`ApplicationRecord`), or one whose table has no inheritance column to tell a
subclass's rows apart. A name that resolves to nothing raises
`WithModel::MissingSuperclass`, a kind of `InvalidSuperclass`, quoting the
constant Ruby could not find — for a namespaced name, that is the segment which
is actually missing. Both are `ArgumentError`s.

A superclass whose table does not exist *yet* is allowed, since the table may be
created later in the example, and Active Record reports its absence clearly
enough on its own. Rows the model wrote are deleted when it goes away, since they
name a class that is about to stop existing and would make the superclass
unloadable.

## Foreign keys

`foreign_key: true` asks Active Record to infer the table a foreign key points
at, and it infers `authors` from `author_id`. Generated table names are unique
rather than conventional, so name the table instead:

```ruby
describe "with_model supports foreign keys" do
with_model :Author do
table
end

with_model :Book do
table do |t|
t.references :author, foreign_key: {to_table: Author.table_name}
end

model do
belongs_to :author
end
end

it "has a foreign key" do
expect { Book.create!(author_id: 0) }
.to raise_error ActiveRecord::InvalidForeignKey
end
end
```

This reads `Author.table_name`, so declare `Author` first: models are created in
the order they are declared, and destroyed in the reverse order.

## Requirements

See the [gemspec metadata](https://rubygems.org/gems/with_model) for dependency requirements. RSpec and minitest are indirect dependencies, and `with_model` should support any maintained version of both.
Expand Down
34 changes: 34 additions & 0 deletions lib/with_model.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# frozen_string_literal: true

require "active_support/deprecation"
require "with_model/invalid_superclass"
require "with_model/missing_superclass"
require "with_model/model"
require "with_model/model/dsl"
require "with_model/null_table"
require "with_model/table"
require "with_model/version"

Expand Down Expand Up @@ -36,6 +40,14 @@ def self.runner
@runner ||= :rspec
end

# The deprecator used for with_model's own deprecation warnings. Callers can
# silence it (`WithModel.deprecator.silenced = true`) or escalate it
# (`behavior = :raise`) while migrating, and Rails applications can register
# it in `Rails.application.deprecators`.
def self.deprecator
@deprecator ||= ActiveSupport::Deprecation.new("3.0", "with_model")
end

# @param [Symbol] name The constant name to assign the model class to.
# @param scope Passed to `before`/`after` in the test context. RSpec only.
# @param options Passed to {WithModel::Model#initialize}.
Expand All @@ -45,6 +57,8 @@ def with_model(name, scope: nil, **options, &block)
model = Model.new name, **options
dsl = Model::DSL.new model
dsl.instance_exec(&block) if block
# caller_locations(1) is this method's caller: the `with_model` line itself.
WithModel.warn_omitted_table(name, caller_locations(1)) unless model.table_specified?

setup_object(model, scope: scope, runner: runner)
end
Expand All @@ -60,6 +74,26 @@ def with_table(name, scope: nil, **options, &block)
setup_object(table, scope: scope, runner: runner)
end

# Warns once per call site, at definition time, rather than once per example.
# The horizon is stated in the message because `Deprecation#warn` does not
# interpolate the deprecator's `deprecation_horizon`.
#
# The callstack has to be handed in. `ActiveSupport::Deprecation` skips Rails'
# own frames and the standard library when working out where a warning came
# from, but with_model's frames look like anyone else's to it, so left to itself
# it reports this file for every omission in a suite.
#
# @param callstack Frames to blame, beginning with the caller to report.
def self.warn_omitted_table(name, callstack)
deprecator.warn(
"with_model #{name.inspect} was called without a `table`, which creates a table with only " \
"an id column. In with_model 3.0 no table will be created. Call `table` (with no " \
"arguments or an empty block) to keep a table, or `table(false)` to inherit the " \
"superclass's table (single table inheritance).",
callstack
)
end

private

# @param [Object] object The new model object instance to create
Expand Down
15 changes: 15 additions & 0 deletions lib/with_model/invalid_superclass.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module WithModel
# Raised when the `superclass:` a model was given cannot be used: it is not an
# Active Record class at all, or it is one that cannot supply a table to a model
# which has none of its own - because it has no table itself
# (`ActiveRecord::Base`, or an abstract class), or because its table has no
# inheritance column and so cannot tell a subclass's rows apart.
#
# An `ArgumentError`, because each is a fact about the argument rather than about
# when it was looked at, and the superclass is needed the moment the model is
# built - unlike a table, which an example is free to create later.
class InvalidSuperclass < ArgumentError
end
end
14 changes: 14 additions & 0 deletions lib/with_model/missing_superclass.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

require "with_model/invalid_superclass"

module WithModel
# Raised when the name given for a `superclass:` resolves to nothing at all.
#
# A kind of {WithModel::InvalidSuperclass}, so every superclass that cannot be
# used is catchable in one place, while a name that is simply not there - a
# typo, or a with_model superclass declared after the models inheriting it -
# can be told apart from a class that exists but cannot supply a table.
class MissingSuperclass < InvalidSuperclass
end
end
Loading
Loading