Add table(false) for single table inheritance - #61
Merged
Conversation
nertzy
force-pushed
the
sti-table-false
branch
10 times, most recently
from
July 28, 2026 20:47
0fdada3 to
3f5ff55
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #57.
with_model has always minted a table per model and assigned
table_nameunconditionally, 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 leavestable_namealone, so Active Record's own inheritance supplies the superclass's table.superclass:also accepts a name now — a String or a Symbol — or a callable, resolved for each example rather than once, because anotherwith_modelmodel's constant does not exist when thewith_modelline is read. A Symbol reads naturally given that with_model names its own models with them. Anything else has to answer toto_str, notto_s: every object has ato_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 throughunscopedwith the inheritance-column condition reapplied, because adefault_scopeon the superclass would otherwise hide rows from it, and it readsinheritance_columnrather than assumingtype— assuming would delete every row in the superclass's table.Refusing what cannot be inherited
WithModel::InvalidSuperclassis raised for asuperclass: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'sApplicationRecord), or one whose table has no inheritance column to tell a subclass's rows apart.WithModel::MissingSuperclass, a kind ofInvalidSuperclass, 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 omittedtablewill arrive here too. Each names the model, so a file with severalwith_modelblocks says which one failed: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'scause: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
StatementInvalidnaming 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
tableOmitting
tablecurrently creates a table with only an id column, which is indistinguishable from forgetting to write one. It is now deprecated in favor of callingtableexplicitly, and in 3.0 it will create no table. The warning goes throughWithModel.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::Deprecationblames 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 reportedlib/with_model.rbfor every omission in a suite — the same line every time, which is useless for finding them. It now names thewith_modelcall, and a spec asserts that using__FILE__/__LINE__so the misattribution cannot come back.The suite omitted
tablein 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: trueinfers 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 unconventionaltable_namehits 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
describeblock: a constant defined inside a block is still a top-level constant, and nesting it only hid that — which is what thestandard:disablecomment there had been suppressing.Notes
CHANGELOG.mdentries are under Unreleased; no version bump.bin/rakeon its own.