Skip to content

Add table(false) for single table inheritance - #61

Merged
nertzy merged 4 commits into
masterfrom
sti-table-false
Jul 28, 2026
Merged

Add table(false) for single table inheritance#61
nertzy merged 4 commits into
masterfrom
sti-table-false

Conversation

@nertzy

@nertzy nertzy commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #57.

with_model has always minted a table per model and assigned table_name unconditionally, so a model whose superclass was a concrete Active Record class silently got its own empty table and single table inheritance never engaged. table(false) now creates no table and leaves table_name alone, so Active Record's own inheritance supplies the superclass's table.

with_model :Sandwich do
  table do |t|
    t.string "type"
    t.string "bread"
  end
end

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

superclass: also accepts a name now — a String or a Symbol — or a callable, resolved for each example rather than once, because another with_model model's constant does not exist when the with_model line is read. A Symbol reads naturally given that with_model names its own models with them. Anything else has to answer to to_str, not to_s: every object has a to_s, so honoring that would quietly look up a constant named "42".

Cleaning up rows

A model created this way writes rows into a table it does not own, and those rows name a class that is about to stop existing — leaving them behind makes the superclass unloadable with ActiveRecord::SubclassNotFound. So they are deleted when the model goes away, before the constant is unstubbed, since a class needs its name to identify its own rows. The delete goes through unscoped with the inheritance-column condition reapplied, because a default_scope on the superclass would otherwise hide rows from it, and it reads inheritance_column rather than assuming type — assuming would delete every row in the superclass's table.

Refusing what cannot be inherited

WithModel::InvalidSuperclass is raised for a superclass: that cannot be used: 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. WithModel::MissingSuperclass, a kind of InvalidSuperclass, is raised for a name that resolves to nothing.

Both subclass ArgumentError — each is a fact about the argument rather than about when it was looked at — so the usual rescue keeps working, while either can be caught as narrowly as needed.

The messages describe the model's situation rather than the call that produced it. Any expression evaluating to false selects this path — table(false), table false, table(supports_sti?) — so there is no call spelling to quote, and in 3.0 an omitted table will arrive here too. Each names the model, so a file with several with_model blocks says which one failed:

with_model :Truck has no table of its own, but ApplicationRecord has none to inherit

with_model :Truck has no table of its own, but Van's table "vans" has no "type"
column, so Active Record cannot tell its rows apart from a subclass's

An unresolvable name quotes Ruby's own NameError, which reports something the message cannot work out for itself — for a namespaced name, which segment is missing — and stays reachable as the error's cause:

superclass :Sandwitch could not be resolved: uninitialized constant Sandwitch.
Names are resolved while the test is running, so a with_model superclass has to
be declared before the models that inherit it.

A superclass whose table does not exist yet is deliberately allowed. That is a fact about the schema at one moment rather than about the model: the table may be created partway through the example, and a test may legitimately want a model whose table is missing. Active Record already raises a clear StatementInvalid naming the table if it never appears, so refusing would only forbid working setups. Both cases are now covered by specs.

Allowing it exposed a bug the refusal had been masking: teardown deleted rows unconditionally, so a missing table raised from teardown and failed an example whose body had already passed. Teardown now returns early when there is no table, since a table that does not exist holds no rows to remove.

Deprecating the omitted table

Omitting table currently creates a table with only an id column, which is indistinguishable from forgetting to write one. It is now deprecated in favor of calling table explicitly, and in 3.0 it will create no table. The warning goes through WithModel.deprecator, so it can be silenced or raised, and fires once per call site at definition time rather than once per example.

The callstack is handed to the deprecator explicitly. ActiveSupport::Deprecation blames the first frame it does not recognize as Rails or the standard library, and with_model's frames look like anyone else's to it, so left alone it reported lib/with_model.rb for every omission in a suite — the same line every time, which is useless for finding them. It now names the with_model call, and a spec asserts that using __FILE__/__LINE__ so the misattribution cannot come back.

The suite omitted table in fifteen places. Twelve were incidental and now call it. Three exist to characterize that behavior — one asserts the resulting table has only an id column — so they keep omitting it and silence the warning instead. Both harnesses raise on the warning, so a new omission fails the run that introduced it.

Also documented

foreign_key: true infers the table it points at from the column name, so it looks for a table that does not exist here. That is a migration-DSL limitation rather than a with_model one — any model with an unconventional table_name hits it — and the fix is to name the table. Documented in the README, with the detail written up in #45.

Matching the README to its spec

The README spec is written by hand rather than extracted from the README, so the two had drifted. The last commit brings every README example back in line with the spec that runs it, and updates their style — hash rockets and single quotes that Standard no longer produces — so that code copied out of the README is code this repository would keep. The two are now identical line for line.

The inheritance example's superclass is abstract, which is easy to mistake for single table inheritance now that with_model supports it, so its comment says which it is and points at the other. It also moved out of the describe block: a constant defined inside a block is still a top-level constant, and nesting it only hid that — which is what the standard:disable comment there had been suppressing.

Notes

  • CHANGELOG.md entries are under Unreleased; no version bump.
  • Each commit passes bin/rake on its own.

@nertzy
nertzy force-pushed the sti-table-false branch 10 times, most recently from 0fdada3 to 3f5ff55 Compare July 28, 2026 20:47
nertzy added 3 commits July 28, 2026 15:50
with_model has always minted a table per model and assigned table_name
unconditionally, so a model whose superclass was a concrete Active Record
class silently got its own empty table and STI never engaged. table(false)
now selects a NullTable that creates nothing and leaves table_name alone,
letting Active Record's own inheritance supply the superclass's table.

Table gains configure/teardown so Model no longer needs to know which kind
of table it has. NullTable#teardown deletes the rows this model wrote --
they would otherwise outlive the constant that names them and make the
superclass unloadable -- and runs before the constant is unstubbed, since a
class needs its name to identify its own rows. The delete goes through
unscoped, because a default_scope on the superclass would hide rows from
it, with the inheritance-column condition reapplied explicitly so it cannot
touch the superclass's own rows. It reads inheritance_column rather than
assuming "type", since assuming would silently delete every row in the
superclass's table.

NullTable refuses anything it cannot inherit from -- ActiveRecord::Base, an
abstract class, or a table without the inheritance column -- because each
of those would otherwise fail later and less clearly.

superclass: now also accepts a String or a callable, resolved for every
example rather than once, so another with_model model -- whose constant
does not exist when the with_model line is read -- can be the superclass.

Omitting table is deprecated ahead of 3.0, where it will create no table.
The warning fires once per call site at definition time rather than once
per example, and states the horizon itself because Deprecation#warn does
not interpolate it. The suite omitted table in fifteen places; twelve were
incidental and now call table, while "without a block", "with an empty
block", and "without a table or model block" keep omitting it, since they
exist to characterize that behavior and one of them asserts the resulting
table has only an id column. They silence the warning instead, and both
harnesses raise on it so a new omission fails the run that introduced it.
The foreign key section answers a question that has been open for a while:
`foreign_key: true` infers the table it points at from the column name, and
generated table names are deliberately not conventional, so it looks for a
table that does not exist. Naming the table with `to_table:` is the fix, and
it only became possible to write once minitest created models in declaration
order -- the child's table block has to be able to read the parent's table
name.

Both examples are mirrored into the README spec, which is maintained by hand
and had already drifted on the inheritance example.

The changelog entry is filed under Unreleased rather than a version, since
the release is a separate decision.
The README spec is written by hand rather than extracted from the README, so
the two drifted: the README kept hash rockets and single quotes that Standard
has since stopped producing, wrote `eq true` where the spec asserts `be true`,
and passed `create!` a rocket hash. None of it was wrong, but a reader copying
it got code this repository's own style would rewrite.

The module the blog post example includes is now defined the way the README
shows it, rather than stubbed, so the example a reader copies is the example
that runs.

The inheritance example's superclass is abstract, which is easy to mistake for
single table inheritance now that with_model supports it, so its comment says
which one it is and where to find the other.
@nertzy
nertzy force-pushed the sti-table-false branch from 3f5ff55 to 8e60465 Compare July 28, 2026 20:50
The minitest tests are a second integration path, not a second feature: the
RSpec suite drives Model#create and #destroy through before and after hooks,
minitest through before_setup and after_teardown, and only the latter has to
reverse itself to unwind in declaration order. So these cover the seam, and
leave the refusals, the superclass forms and the inheritance_column variants
to the specs, where they are harness-independent.

Row cleanup could not be shown with a with_model superclass, which is what the
first attempt at this used: dropping the parent's table disposes of the child's
rows whatever teardown does, so the test passed with the deletion commented
out. It needs a superclass whose table outlives the test, which is also the
case single table inheritance is for - an application's own model. Two tests
now write a row and first insist the table is empty, so whichever minitest runs
second fails if the rows survived, at every seed rather than at lucky ones.

Teardown order was likewise unobservable through single table inheritance,
since a child that shares its parent's table leaves nothing behind to trip
over. A foreign key does: the referenced table cannot be dropped while a row
still points at it, so tearing these down in declaration order raises
ActiveRecord::InvalidForeignKey after the body has already passed.

Minitest::Spec had no coverage at all, though the README offers it. It needs no
wiring of its own, inheriting the DSL from Minitest::Test, and nested describe
blocks inherit the models declared around them.
@nertzy
nertzy merged commit 767b41e into master Jul 28, 2026
17 checks passed
@nertzy
nertzy deleted the sti-table-false branch July 28, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

superclass: with a concrete parent should just be STI

1 participant