Skip to content

Resolve concrete types for single-table inheritance families - #15

Merged
evoactivity merged 4 commits into
mainfrom
feat/sti-polymorphic-relationships
Aug 16, 2026
Merged

Resolve concrete types for single-table inheritance families#15
evoactivity merged 4 commits into
mainfrom
feat/sti-polymorphic-relationships

Conversation

@evoactivity

@evoactivity evoactivity commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Closes #9.

The problem

With single-table inheritance, typed subclasses share one table and a discriminator column tells them apart. Lucid hydrates base-targeted relations as the base class, which is correct for an ORM with no discriminator concept, but the serializer derived a row's JSON:API type from its class rather than from the row. Every such row serialized as the base type, and writes rejected the concrete types. Client caches key on type + id, so the abstract type creates a second identity for the same row that nothing ever resolves.

The API

Two statics on the base resource:

export default class SportsTeamResource extends JsonApiResource<SportsTeam> {
  static model = () => SportsTeam
  static subtypes = () => [FootballTeamResource, RugbyTeamResource]
  static resolveResource(row: SportsTeam) {
    return { football: FootballTeamResource, rugby: RugbyTeamResource }[row.sport]
  }
}

resolveResource serves reads, row in, concrete resource out. subtypes serves writes, the set of types a relation targeting the base accepts. Declaring neither changes nothing anywhere, the feature is fully opt-in.

Behaviour

  • Reads: rows resolve to concrete types everywhere they appear — primary data, linkage, included (deduplicated under the concrete type), relationship-endpoint GETs, and sparse fieldsets key on the concrete type. Type derivation lives in one overridable typeName() on the resource; typeFor and typeForRow both delegate to it, cached per resource class.
  • Unloaded belongsTo targeting an STI base emits no data member, keeping its links. A bare foreign key cannot name a concrete type, and guessing the base type puts the same row in one payload under two identities, the abstract one dangling — a full-linkage violation client caches then fork on. Non-STI targets keep FK-derived linkage byte-for-byte unchanged, pinned by tests.
  • Writes accept the family. acceptedTypesFor(Model) feeds parseLinkage, the deserializer's relationship members, and primary data.type, so any member type is accepted, mixed types work in one payload, and the 409 names every acceptable type. The abstract base type is rejected on every path — it never belongs in a payload.
  • Type claims are verified. The family shares one id space, so football-teams/7 where row 7 is a rugby team names a resource that does not exist, yet existence checks alone would attach it silently. verifyRelatedExist now takes the claimed identifiers and the registry (required, so no call site can skip it — the documented low-level recipe gains the third argument) and 404s contradicted claims.
  • Wildcard endpoints for runtime-defined kinds: DeserializeOptions.expectedType pins primary acceptance to the one type a URL names, and the deserialized result carries the validated type so a controller can stamp the discriminator. Runtime-built resource families (kinds as user data) are supported and pinned by tests.
  • Registration: registering a base auto-registers its declared subtypes as a safety net; explicit registrations always win, in either order.

Beyond the original scope comment, called out honestly

  • typeName() as a public override hook (single home for type derivation).
  • resolveResource chaining with a cycle guard.
  • Subtype auto-registration.
  • Primary-data family acceptance, expectedType, and result.type (added for wildcard endpoints over runtime families).

belongsTo coverage

The example app gains a nullable Article.cover belongsTo targeting the base, so the last two behaviours pinned only at unit level now have functional coverage: the links-only member for an unloaded belongsTo, and the 404 for a replace whose claimed type the row's discriminator contradicts, including that the rejected replace leaves the foreign key untouched.

Breaking changes

  • verifyRelatedExist gains a required registry parameter (documented low-level API).
  • An STI base endpoint no longer accepts the abstract base type as primary data.type once subtypes is declared. Without declarations, behaviour is unchanged.

Worth a minor bump.

Tests and docs

45 new unit tests across sti_registry, sti_documents, sti_writes plus fixture families; 10 functional tests in the example app over a real database, which also gains the working reference implementation (Attachment/Image/Video with scoped subclasses, per-file resources). Every request/response example in the new docs/polymorphism.md is transcribed from captured output, not written by hand.

Unit 174 passed, example functional 62 passed, lint and typecheck clean in both.

Single-table inheritance puts several typed subclasses over one table
with a discriminator column. Lucid hydrates base-targeted relations as
the base class, so every such row serialized under the base type and
writes 409ed on anything else (issue #9).

A base resource now declares its family:

  static subtypes = () => [FootballTeamResource, RugbyTeamResource]
  static resolveResource(row) { ... }

resolveResource maps a row to its concrete resource; the registry
consults it wherever a row is serialized (resourceForRow, and the new
typeForRow), so primary data, linkage, included documents, and sparse
fieldsets all carry concrete types. Hooks can chain; a repeated class
ends the walk so a cycle cannot loop.

Type derivation itself moves into typeName() on the resource, the
single overridable home for the rule. typeFor and typeForRow both
delegate to it, cached per resource class, and auto-derived resources
now carry their model so every resource is self-describing.

An unloaded belongsTo targeting an STI base emits no data member. The
FK fallback would have to guess a type, and the guess put the same row
in one payload under two identities, the abstract one dangling, which
is a full-linkage violation a client cache then forks on. The member
keeps its links; non-STI targets keep FK-derived linkage unchanged.

Writes accept the family. acceptedTypesFor(Model) on the registry
feeds parseLinkage and both deserializer branches, so any member type
is accepted, mixed types work in one to-many payload, and the 409
names every acceptable type. The abstract base type is rejected: it
never belongs in a payload. fetchLinkage emits concrete types per row.

Still to come: verifyRelatedExist checking claimed types against the
discriminator, functional coverage in the example app, and docs.
An STI family shares one id space, so existence alone cannot validate
an identifier: images/7 where row 7 is a video named a resource that
does not exist, yet it attached silently. verifyRelatedExist now takes
the claimed identifiers and the registry and 404s a claim the row's
discriminator contradicts. The registry is required so no call site
can skip the check; the documented low-level recipe gains the third
argument. Non-STI targets are untouched.

The registry also registers a base resource's declared subtypes with
it, so an STI family costs one import line in config.

The example app gains the working reference: an Attachment base with
Image and Video over one table, a mixed to-many on articles, and
functional tests that pin concrete linkage, family writes, and both
404s over HTTP.
Primary data.type was compared against one string, the model's own
type, which blocked resource-level writes for STI subtypes and, worse,
accepted the abstract base type. The check now runs through
acceptedTypesFor, the same home the relationship members use, so an
STI base model accepts any family member and rejects the abstract
type, while every other model keeps its exact single-type behaviour.
DeserializeOptions.expectedType narrows acceptance to the one type a
wildcard URL names, threaded through the context helper, which was
silently dropping unknown options. The result now carries the
validated type so a controller can stamp the discriminator.

The review pass found a stale exported type, references claimed to be
{ relation, ids } while carrying claims. It also folded the two 409
type-list messages into one formatTypeList home, parsed to-many
identifiers once instead of twice, moved renderRelationship onto
typeForRow so relationship links stop using the abstract type for
base-hydrated rows, shared the STI resource fixtures across the spec
files, and made the no-data-member test assert unconditionally.

The example app aligns with the happy path: Image and Video resources
in their own files as the generator produces them, all three
registered explicitly, and the Attachment subclasses gain the query
scoping they were missing, exercised by seeds that create through
them.

The polymorphism guide covers the ground from the start: what a
mixed-type relationship is, why client caches need concrete types,
single-table inheritance and why it is the one supported shape, the
declarations with real captured request and response payloads, the
hypothetical document that shows why an unloaded belongsTo emits no
data member, families discovered at runtime including wildcard
endpoints and a boot-seed preload restricted to the web environment,
and an honest section on whether to design a schema this way.
The example app had no belongsTo targeting the STI base, so two
behaviours were pinned only at unit level: the links-only member for
an unloaded belongsTo, and the 404 for a replace claiming a type the
row's discriminator contradicts. Articles gain a nullable cover
attachment, and three functional tests cover both, including that a
rejected replace leaves the foreign key untouched.

database/schema.ts joins the example app's prettier ignores. The
file is regenerated unformatted by every migration run, which forced
lint to run before migrations in CI and a manual prettier pass after
each regeneration. The workflow comment documenting that ordering
constraint is updated, since the constraint is gone.
@evoactivity evoactivity added the enhancement New feature or request label Aug 16, 2026
@evoactivity
evoactivity merged commit d8d712c into main Aug 16, 2026
4 checks passed
@evoactivity
evoactivity deleted the feat/sti-polymorphic-relationships branch August 16, 2026 19:03
@github-actions github-actions Bot mentioned this pull request Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Single table inheritance: base-targeted relations serialize and validate with the base type

1 participant