Resolve concrete types for single-table inheritance families - #15
Merged
Conversation
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.
Merged
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 #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:
resolveResourceserves reads, row in, concrete resource out.subtypesserves writes, the set of types a relation targeting the base accepts. Declaring neither changes nothing anywhere, the feature is fully opt-in.Behaviour
included(deduplicated under the concrete type), relationship-endpoint GETs, and sparse fieldsets key on the concrete type. Type derivation lives in one overridabletypeName()on the resource;typeForandtypeForRowboth delegate to it, cached per resource class.datamember, 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.acceptedTypesFor(Model)feedsparseLinkage, the deserializer's relationship members, and primarydata.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.football-teams/7where row 7 is a rugby team names a resource that does not exist, yet existence checks alone would attach it silently.verifyRelatedExistnow 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.DeserializeOptions.expectedTypepins primary acceptance to the one type a URL names, and the deserialized result carries the validatedtypeso a controller can stamp the discriminator. Runtime-built resource families (kinds as user data) are supported and pinned by tests.Beyond the original scope comment, called out honestly
typeName()as a public override hook (single home for type derivation).resolveResourcechaining with a cycle guard.expectedType, andresult.type(added for wildcard endpoints over runtime families).belongsTo coverage
The example app gains a nullable
Article.coverbelongsTo 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 areplacewhose claimed type the row's discriminator contradicts, including that the rejected replace leaves the foreign key untouched.Breaking changes
verifyRelatedExistgains a required registry parameter (documented low-level API).data.typeoncesubtypesis declared. Without declarations, behaviour is unchanged.Worth a minor bump.
Tests and docs
45 new unit tests across
sti_registry,sti_documents,sti_writesplus 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 newdocs/polymorphism.mdis transcribed from captured output, not written by hand.Unit 174 passed, example functional 62 passed, lint and typecheck clean in both.