Skip to content

superclass: with a concrete parent should just be STI #57

Description

@nertzy

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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions