When the superclass is concrete, Rails already makes Class.new(Car) an STI subclass sharing cars and writing type. with_model then undoes it — setup_model assigns table_name unconditionally, and create always calls table.create, so the subclass gets a generated table instead of its parent's.
So this isn't a missing feature so much as with_model overriding a default it doesn't need to.
The fix
Two spellings, both meaning "don't create a table, inherit the parent's":
table false — the canonical, explicit opt-out. DSL#table currently only takes (options = {}, &block) and passes options straight to create_table as **@options, so false needs a special case; today it raises TypeError: no implicit conversion of false into Hash.
- omitting
table entirely — same meaning, since an omitted block already says there is no table to make.
Either way: skip table.create / table.destroy, and don't assign table_name. STI then falls out for free, with no STI-specific concept in the DSL at all:
class Car < ActiveRecord::Base # concrete, has a `type` column
end
with_model :Ford, superclass: Car do
table false
model do
def self.wheels = 4
end
end
Ford.table_name # => "cars"
Ford.base_class # => Car
Ford.create!.type # => "Ford"
Docs and examples should lead with table false — it reads as a deliberate choice, where a missing table block is indistinguishable from forgetting one.
An abstract parent keeps working exactly as now, because those call sites declare a table block.
Why it matters
STI hierarchies are where a throwaway subclass is most useful in specs — "given some other subclass of Workflow, does this shared code path stay generic?" Without it people reach for:
fake = Class.new(Workflow)
fake.define_singleton_method(:name) { "Workflow::FakeFamily" }
which leaks into Workflow.descendants for the life of the process, so any registry derived from descendants/subclasses sees it in unrelated examples — passes in isolation, fails under a different seed or parallel worker. with_model already fixes exactly that (cleanup_descendants_tracking + DescendantsTracker.clear) and hands back a real stubbed constant, so partial-double verification works instead of being silently bypassed. It just can't currently express the STI case.
Breaking change: needs 3.0
Omitting the table block currently still creates an id-only table (Table#create calls create_table with a nil block); afterwards it would create nothing. Any existing spec that omits the block and then persists a record breaks, and it breaks at runtime rather than at load, so this is a major version bump, not a CHANGELOG footnote.
The README's abstract-parent Ford example omits the block but only asserts Ford < Car and never persists, so it survives.
Still open
-
Row cleanup. Dropping the table is what currently guarantees no residue. With STI there's no table to drop, so rows written during an example outlive it unless the host suite is transactional — and a leftover row's type names a constant that's about to be unstubbed, so a later read raises. Document the transactional requirement, or delete matching rows on teardown? Documenting seems more in keeping with the gem.
-
Namespaced constants. STI stores the full name, so with_model :"Workflow::Ford" is the realistic call. ConstantStubber already splits on ::, so this may already work — worth a spec either way.
-
Deprecation cycle before the flip? table false can ship in 2.x on its own — it's purely additive, since it currently raises. That makes a clean migration: 2.x warns when table is omitted and points at table false, callers who want no table adopt it immediately, callers who want today's id-only table add an explicit table {}, and 3.0's change of meaning for the omission is then a no-op for everyone who migrated. Worth it, or is a clean break proportionate for a test-only gem with a narrow surface?
Notes
spec/readme_spec.rb runs the README's examples, so README and that spec move together.
- Local checkout here is 2.1.7 while 2.2.0 is published.
When the superclass is concrete, Rails already makes
Class.new(Car)an STI subclass sharingcarsand writingtype. with_model then undoes it —setup_modelassignstable_nameunconditionally, andcreatealways callstable.create, so the subclass gets a generated table instead of its parent's.So this isn't a missing feature so much as with_model overriding a default it doesn't need to.
The fix
Two spellings, both meaning "don't create a table, inherit the parent's":
table false— the canonical, explicit opt-out.DSL#tablecurrently only takes(options = {}, &block)and passes options straight tocreate_tableas**@options, sofalseneeds a special case; today it raisesTypeError: no implicit conversion of false into Hash.tableentirely — same meaning, since an omitted block already says there is no table to make.Either way: skip
table.create/table.destroy, and don't assigntable_name. STI then falls out for free, with no STI-specific concept in the DSL at all:Docs and examples should lead with
table false— it reads as a deliberate choice, where a missingtableblock is indistinguishable from forgetting one.An abstract parent keeps working exactly as now, because those call sites declare a
tableblock.Why it matters
STI hierarchies are where a throwaway subclass is most useful in specs — "given some other subclass of
Workflow, does this shared code path stay generic?" Without it people reach for:which leaks into
Workflow.descendantsfor the life of the process, so any registry derived fromdescendants/subclassessees it in unrelated examples — passes in isolation, fails under a different seed or parallel worker. with_model already fixes exactly that (cleanup_descendants_tracking+DescendantsTracker.clear) and hands back a real stubbed constant, so partial-double verification works instead of being silently bypassed. It just can't currently express the STI case.Breaking change: needs 3.0
Omitting the
tableblock currently still creates an id-only table (Table#createcallscreate_tablewith a nil block); afterwards it would create nothing. Any existing spec that omits the block and then persists a record breaks, and it breaks at runtime rather than at load, so this is a major version bump, not a CHANGELOG footnote.The README's abstract-parent
Fordexample omits the block but only assertsFord < Carand never persists, so it survives.Still open
Row cleanup. Dropping the table is what currently guarantees no residue. With STI there's no table to drop, so rows written during an example outlive it unless the host suite is transactional — and a leftover row's
typenames a constant that's about to be unstubbed, so a later read raises. Document the transactional requirement, or delete matching rows on teardown? Documenting seems more in keeping with the gem.Namespaced constants. STI stores the full name, so
with_model :"Workflow::Ford"is the realistic call.ConstantStubberalready splits on::, so this may already work — worth a spec either way.Deprecation cycle before the flip?
table falsecan ship in 2.x on its own — it's purely additive, since it currently raises. That makes a clean migration: 2.x warns whentableis omitted and points attable false, callers who want no table adopt it immediately, callers who want today's id-only table add an explicittable {}, and 3.0's change of meaning for the omission is then a no-op for everyone who migrated. Worth it, or is a clean break proportionate for a test-only gem with a narrow surface?Notes
spec/readme_spec.rbruns the README's examples, so README and that spec move together.